“It’s a static site. There’s nothing to hack.” I’ve said it, and it’s mostly true. There’s no database, no login, and no server-side code on cloud405.com. But the attack surface doesn’t disappear. It moves to the connection and to the browser: someone downgrading the connection on hotel Wi-Fi, a malicious page framing mine to trick clicks, a browser guessing that a file is script when it isn’t, full URLs leaking to third parties, and, as I covered in the last post, script that gets injected at build time.

Browsers have defenses for all of those. They just won’t use most of them unless the server asks. That’s what security response headers are for, and on CloudFront you can add them at the edge without touching the origin.

One policy, every behavior

CloudFront’s response headers policy is a named set of headers that gets attached to cache behaviors. This site’s lives in Terraform and is attached to the default behavior and every path-specific one, so there’s no corner of the site served without it:

resource "aws_cloudfront_response_headers_policy" "security_headers" {
  name = "cloud405-security-headers"

  security_headers_config {
    strict_transport_security {
      access_control_max_age_sec = 63072000 # 2 years
      include_subdomains         = false
      preload                    = false
      override                   = true
    }
    content_type_options {
      override = true
    }
    frame_options {
      frame_option = "DENY"
      override     = true
    }
    referrer_policy {
      referrer_policy = "strict-origin-when-cross-origin"
      override        = true
    }
  }

  custom_headers_config {
    items {
      header   = "Permissions-Policy"
      value    = "geolocation=(), camera=(), microphone=(), browsing-topics=()"
      override = true
    }
  }
}

override = true on each one means the edge value wins over anything the origin sends. The policy is the single source of truth.

What each header actually stops

Strict-Transport-Security (HSTS). CloudFront already redirects HTTP to HTTPS, but a redirect still means the first request goes out in plaintext. On a hostile network, that first request is where an SSL-stripping attack happens: the attacker answers it, never forwards the redirect, and proxies the whole session over HTTP. HSTS tells the browser to skip plain HTTP entirely for this host for the next two years. After the first visit, there’s no plaintext request left to intercept.

I deliberately left includeSubDomains and preload off. The timezone tool, a private dashboard, and the redesign previews all live on subdomains as separate stacks. includeSubDomains would commit every one of them to HTTPS-only for two years, including any I haven’t built yet. Preloading bakes the policy into browsers themselves, and getting removed from the preload list is slow. Both are good settings to adopt on purpose, after checking every subdomain, not as a side effect of securing the blog.

X-Content-Type-Options: nosniff. Without it, browsers may “sniff” a response and decide a file served as text/plain is really JavaScript. With it, the declared content type is the only type. It closes off a class of attacks where an uploaded or misconfigured file gets executed as script.

X-Frame-Options: DENY. Nobody has a legitimate reason to embed this site in a frame. Denying it shuts down clickjacking, where an attacker loads a page invisibly over their own and lines up your buttons under their visitor’s cursor. The modern equivalent is the CSP frame-ancestors directive, and when CSP lands I’ll set both, since older browsers only honor this one.

Referrer-Policy: strict-origin-when-cross-origin. When someone clicks an outbound link, the destination sees only https://cloud405.com, not the full path they came from. Same-origin navigation keeps the full URL, and nothing at all is sent when going from HTTPS to HTTP.

Permissions-Policy. A blog has no use for geolocation, the camera, the microphone, or ad-topic APIs, so the page opts out of all of them. If script ever does get injected, it can’t so much as prompt the visitor for any of those.

The transport, both legs

On the viewer side, the distribution redirects HTTP to HTTPS, serves HTTP/2 and HTTP/3, and uses the TLSv1.2_2021 security policy, which refuses anything older than TLS 1.2 and restricts the cipher suites to modern ones.

The origin side is the honest part of this post. Production still uses the S3 website endpoint as its origin. That endpoint only speaks HTTP, so the hop from CloudFront to S3 is unencrypted, and it only works with a public bucket. The Terraform even carries an origin_ssl_protocols list that includes SSLv3 and TLS 1.0. It’s unused, since the origin is HTTP-only, but any configuration scanner will flag it, and it becomes a real problem the moment someone flips the origin protocol without reading the rest of the block.

The redesign previews already do it properly: a private bucket with Block Public Access on, server-side encryption, and Origin Access Control signing every request with SigV4. The bucket policy only allows s3:GetObject from the CloudFront service principal, and only when the request’s source ARN is that specific distribution.

The catch is that the S3 REST endpoint doesn’t resolve /cloud/some-post/ to index.html the way the website endpoint does, and every URL on this site is shaped like that. A small CloudFront Function fixes it:

function handler(event) {
  var request = event.request;
  var uri = request.uri;
  if (uri.endsWith('/')) {
    request.uri = uri + 'index.html';
  } else if (!uri.split('/').pop().includes('.')) {
    request.uri = uri + '/index.html';
  }
  return request;
}

Moving production onto the same pattern is next.

Why CSP had to wait

Content-Security-Policy is the most powerful header on this list and the easiest one to break a site with. It’s an allowlist for every script, stylesheet, font, and image the page loads. Get a directive wrong and the browser silently blocks the resource. The page renders, just without its fonts or its JavaScript, and you won’t know unless you look.

So before writing one, I inventoried what the site actually loads:

  • Its own stylesheet and one deferred script, both from the same origin.
  • Google Fonts: the CSS from fonts.googleapis.com and the font files from fonts.gstatic.com.
  • One small inline script in the page head that applies the saved light or dark theme before first paint, so the page doesn’t flash the wrong colors.

That inline script is the interesting one. The lazy fix is 'unsafe-inline', which also allows any script an attacker manages to inject, and that defeats the main reason to have a CSP. The better fix for a static site is a hash: the policy allows exactly one inline script whose SHA-256 matches. The catch is that the hash covers the exact bytes, and Jekyll’s whitespace handling can change them, so the hash should be computed from the built output in CI rather than by hand.

The candidate policy looks like this:

default-src 'self';
script-src 'self' 'sha256-<hash of the theme script>';
style-src 'self' https://fonts.googleapis.com;
font-src https://fonts.gstatic.com;
img-src 'self';
object-src 'none';
base-uri 'self';
form-action 'none';
frame-ancestors 'none';

It ships first as Content-Security-Policy-Report-Only, which reports violations in the browser console without blocking anything. Once a pass through every post layout comes back clean (inline style attributes, like the ones Markdown table alignment can generate, are the usual surprise), it gets promoted to the enforcing header.

Checking your own

You can see what any site sends in a few seconds:

curl -sI https://cloud405.com/ | grep -iE 'strict-transport|x-content-type|x-frame|referrer-policy|permissions-policy|content-security'

Mozilla’s HTTP Observatory will grade the same headers and explain each finding. Headers aren’t a substitute for not having vulnerabilities, but they’re cheap, the browser enforces them for you, and on a static site they’re most of the client-side defense you get.