Add a Cloudflare Pages _headers file with baseline security headers #14

Closed
opened 2026-08-09 03:43:48 +02:00 by clawbot · 5 comments
Collaborator

Problem

The site is served over HTTPS by Cloudflare Pages with no response security
headers configured. There is no _headers file and no header configuration
anywhere in the repo.

REPO_POLICIES.md scopes most of its HTTP hardening block to Go web services
before tagging 1.0, and almost all of that block genuinely has no static-site
equivalent (see below). But the block closes with:

> This list is non-exhaustive… if a standard security hardening measure exists
> for HTTP services and is not listed here, it is still expected. When in
> doubt, harden.

Response security headers are the one item in that block that a static site
can implement, because Cloudflare Pages reads a _headers file from the
build output root. Everything else in the block needs application code that
does not exist here.

Fix

Add static/_headers (Hugo copies static/ verbatim into public/, so it
lands at the deploy root where Cloudflare Pages expects it).

Suggested baseline — the implementer should verify each against the actual page
before committing:

/*
  Strict-Transport-Security: max-age=31536000; includeSubDomains
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  X-Frame-Options: DENY
  Permissions-Policy: geolocation=(), microphone=(), camera=()
  Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'

Notes on the CSP, which is the only non-obvious one: the site loads no scripts,
no fonts, and no external stylesheets — baseof.html inlines the entire CSS
into a <style> block via readFile. So default-src 'none' with
style-src 'unsafe-inline' is achievable and genuinely tight. 'unsafe-inline'
for styles is required precisely because the CSS is inlined; that is a
deliberate design choice in the theme, not an oversight. Do not add
script-src allowances for scripts that do not exist.

includeSubDomains on HSTS is worth a moment's thought: it applies to every
subdomain of lora.vegas. If any subdomain is or might be served over plain
HTTP, drop includeSubDomains. Do not add preload — that is effectively
irreversible and needs an explicit decision.

Definition of done

  1. static/_headers exists with the headers above (or a documented, justified
    variation).
  2. After make test, the file appears at public/_headers — confirm this, it
    is the whole point.
  3. Every header value has been checked against the actual rendered page. In
    particular, load public/index.html in a browser with the CSP applied and
    confirm nothing is blocked: the page must render with full styling and all
    outbound links (meshtastic.org, signal.group, discord.gg, sneak.berlin,
    git.eeqj.de) must still work.
  4. X-Frame-Options: DENY and CSP frame-ancestors 'none' are consistent with
    each other and with any intent to let others embed the channel listings. If
    embedding is desired, both need to change together.
  5. make check passes and script/cibuild succeeds.
  6. TODO.md updated in the same commit.
  7. After merge, verify the headers are actually being served on
    https://lora.vegas/ (curl -sI https://lora.vegas/). Cloudflare Pages
    silently ignores a malformed _headers file, so a green build proves
    nothing here. Report the result on this issue.

No sensible static-site equivalent — explicitly not applicable

The rest of the REPO_POLICIES.md HTTP hardening block requires an application
process. Cloudflare Pages runs no code for this site; it serves files. None of
the following can or should be implemented here:

  • http.MaxBytesReader request body limits — no request handling.
  • ReadTimeout / ReadHeaderTimeout / WriteTimeout / IdleTimeout — no
    server process to configure.
  • context.WithTimeout on request paths — no request paths.
  • Rate limiting — no origin to protect; Cloudflare's edge handles this.
  • CSRF tokens — no forms, no state-changing endpoints.
  • bcrypt/scrypt/argon2 password hashing — no accounts, no passwords.
  • Session cookie flags (Secure, HttpOnly, SameSite) — no sessions, no
    cookies set by this site.
  • Trusted-proxy X-Forwarded-For handling — nothing reads client IPs.
  • CORS allowlist — no API, no cross-origin reads to permit.
  • Error-message leakage controls — no application errors are generated.
  • TLS posture / cipher configuration — terminated by Cloudflare, not
    configurable from this repo.
  • /.well-known/healthcheck and /metrics endpoints — scoped by
    CODE_STYLEGUIDE.md to "services/servers"; there is no process to health
    check or instrument.
  • JSON structured logging, DEBUG env var, port 8080 + PORT override,
    runit/runsvinit entrypoint — all server-process concerns. The Dockerfile
    here is a build-and-check harness that exits; it is not a service image.

Ref: REPO_POLICIES.md HTTP service hardening — "When in doubt, harden."

## Problem The site is served over HTTPS by Cloudflare Pages with no response security headers configured. There is no `_headers` file and no header configuration anywhere in the repo. `REPO_POLICIES.md` scopes most of its HTTP hardening block to Go web services before tagging 1.0, and almost all of that block genuinely has no static-site equivalent (see below). But the block closes with: > This list is non-exhaustive… if a standard security hardening measure exists > for HTTP services and is not listed here, it is still expected. When in > doubt, harden. Response security headers are the one item in that block that a static site **can** implement, because Cloudflare Pages reads a `_headers` file from the build output root. Everything else in the block needs application code that does not exist here. ## Fix Add `static/_headers` (Hugo copies `static/` verbatim into `public/`, so it lands at the deploy root where Cloudflare Pages expects it). Suggested baseline — the implementer should verify each against the actual page before committing: ``` /* Strict-Transport-Security: max-age=31536000; includeSubDomains X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin X-Frame-Options: DENY Permissions-Policy: geolocation=(), microphone=(), camera=() Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none' ``` Notes on the CSP, which is the only non-obvious one: the site loads no scripts, no fonts, and no external stylesheets — `baseof.html` inlines the entire CSS into a `<style>` block via `readFile`. So `default-src 'none'` with `style-src 'unsafe-inline'` is achievable and genuinely tight. `'unsafe-inline'` for styles is required precisely *because* the CSS is inlined; that is a deliberate design choice in the theme, not an oversight. Do not add `script-src` allowances for scripts that do not exist. `includeSubDomains` on HSTS is worth a moment's thought: it applies to every subdomain of `lora.vegas`. If any subdomain is or might be served over plain HTTP, drop `includeSubDomains`. Do **not** add `preload` — that is effectively irreversible and needs an explicit decision. ## Definition of done 1. `static/_headers` exists with the headers above (or a documented, justified variation). 2. After `make test`, the file appears at `public/_headers` — confirm this, it is the whole point. 3. Every header value has been checked against the actual rendered page. In particular, load `public/index.html` in a browser with the CSP applied and confirm nothing is blocked: the page must render with full styling and all outbound links (meshtastic.org, signal.group, discord.gg, sneak.berlin, git.eeqj.de) must still work. 4. `X-Frame-Options: DENY` and CSP `frame-ancestors 'none'` are consistent with each other and with any intent to let others embed the channel listings. If embedding is desired, both need to change together. 5. `make check` passes and `script/cibuild` succeeds. 6. `TODO.md` updated in the same commit. 7. After merge, verify the headers are actually being served on https://lora.vegas/ (`curl -sI https://lora.vegas/`). Cloudflare Pages silently ignores a malformed `_headers` file, so a green build proves nothing here. Report the result on this issue. ## No sensible static-site equivalent — explicitly not applicable The rest of the `REPO_POLICIES.md` HTTP hardening block requires an application process. Cloudflare Pages runs no code for this site; it serves files. None of the following can or should be implemented here: - `http.MaxBytesReader` request body limits — no request handling. - `ReadTimeout` / `ReadHeaderTimeout` / `WriteTimeout` / `IdleTimeout` — no server process to configure. - `context.WithTimeout` on request paths — no request paths. - Rate limiting — no origin to protect; Cloudflare's edge handles this. - CSRF tokens — no forms, no state-changing endpoints. - bcrypt/scrypt/argon2 password hashing — no accounts, no passwords. - Session cookie flags (`Secure`, `HttpOnly`, `SameSite`) — no sessions, no cookies set by this site. - Trusted-proxy `X-Forwarded-For` handling — nothing reads client IPs. - CORS allowlist — no API, no cross-origin reads to permit. - Error-message leakage controls — no application errors are generated. - TLS posture / cipher configuration — terminated by Cloudflare, not configurable from this repo. - `/.well-known/healthcheck` and `/metrics` endpoints — scoped by `CODE_STYLEGUIDE.md` to "services/servers"; there is no process to health check or instrument. - JSON structured logging, `DEBUG` env var, port 8080 + `PORT` override, runit/runsvinit entrypoint — all server-process concerns. The `Dockerfile` here is a build-and-check harness that exits; it is not a service image. Ref: `REPO_POLICIES.md` HTTP service hardening — "When in doubt, harden."
Author
Collaborator

Correction to the issue body — I checked the live site's actual response
headers and the opening claim is wrong.

The body says "no response security headers configured". In fact
curl -sSI https://lora.vegas/ shows Cloudflare Pages is already serving two
of them by default:

x-content-type-options: nosniff
referrer-policy: strict-origin-when-cross-origin

Both happen to match exactly what the proposed _headers file specifies, so
those two lines would be no-ops rather than additions.

Still genuinely missing, and still the substance of this issue:

  • Strict-Transport-Security
  • Content-Security-Policy
  • X-Frame-Options
  • Permissions-Policy

Also visible in the live response and worth noting: access-control-allow-origin: *
is being sent. That is Cloudflare Pages' default for static assets and is
harmless for a public site with no credentialed endpoints, but it should be a
conscious choice rather than an unexamined default — worth a sentence in the
PR explaining why it is being left alone (or removed, if the implementer finds
a reason to).

Two consequences for the definition of done:

  1. Add a step: capture curl -sSI https://lora.vegas/ before the change,
    so the post-merge verification is a genuine before/after rather than a
    check against an assumption. The "before" is now recorded above.
  2. Do not treat the two already-present headers as evidence the _headers file
    is working post-merge — they will be there whether the file is parsed or
    not. Verify specifically on Strict-Transport-Security or
    Content-Security-Policy, which are only present if the file took effect.
    Cloudflare Pages silently ignores a malformed _headers file, so picking
    the wrong header to check would produce a false pass.

This is the same failure mode that just cost a revert on #7: verifying against
what the environment is assumed to do rather than what it actually does.

Correction to the issue body — I checked the live site's actual response headers and the opening claim is wrong. The body says "no response security headers configured". In fact `curl -sSI https://lora.vegas/` shows Cloudflare Pages is already serving two of them by default: ``` x-content-type-options: nosniff referrer-policy: strict-origin-when-cross-origin ``` Both happen to match exactly what the proposed `_headers` file specifies, so those two lines would be no-ops rather than additions. Still genuinely missing, and still the substance of this issue: - `Strict-Transport-Security` - `Content-Security-Policy` - `X-Frame-Options` - `Permissions-Policy` Also visible in the live response and worth noting: `access-control-allow-origin: *` is being sent. That is Cloudflare Pages' default for static assets and is harmless for a public site with no credentialed endpoints, but it should be a conscious choice rather than an unexamined default — worth a sentence in the PR explaining why it is being left alone (or removed, if the implementer finds a reason to). Two consequences for the definition of done: 1. Add a step: capture `curl -sSI https://lora.vegas/` **before** the change, so the post-merge verification is a genuine before/after rather than a check against an assumption. The "before" is now recorded above. 2. Do not treat the two already-present headers as evidence the `_headers` file is working post-merge — they will be there whether the file is parsed or not. Verify specifically on `Strict-Transport-Security` or `Content-Security-Policy`, which are only present if the file took effect. Cloudflare Pages silently ignores a malformed `_headers` file, so picking the wrong header to check would produce a false pass. This is the same failure mode that just cost a revert on #7: verifying against what the environment is assumed to do rather than what it actually does.
Author
Collaborator

Implementation plan

Re-confirmed the "before" state from the comment above with
curl -sSI https://lora.vegas/ just now (2026-08-09): the live response carries
x-content-type-options: nosniff, referrer-policy: strict-origin-when-cross-origin
and access-control-allow-origin: *, and carries no strict-transport-security,
content-security-policy, x-frame-options or permissions-policy. So the comment's
correction holds and the body's "no response security headers" opening does not.

What I will add

A single new file, static/_headers, with one /* block. No other file in the repo
changes except TODO.md. static/ does not exist at repo root today; Hugo unions the
project's static/ over the theme's themes/loravega/static/, per-path, so a root
static/_headers cannot shadow themes/loravega/static/css/style.css — different
paths. I will prove that rather than assert it by diffing the built public/ tree and
the sha256 of public/css/style.css before and after.

Header values, each checked against the built page

I dumped every element and attribute in the built public/index.html. It contains no
script, img, link, iframe, form, video, audio, object or embed
element, no style= attribute and no on*= handler. It contains exactly one inline
<style> block (the whole of the theme CSS, inlined by readFile in
baseof.html) and 12 anchors to meshtastic.org, signal.group, discord.gg,
sneak.berlin, git.eeqj.de, lasmesh.com, maps.app.goo.gl and
http://las.packetflood.net:44920/.

So the issue's suggested CSP is achievable verbatim:

  • default-src 'none' — nothing else is fetched.
  • style-src 'unsafe-inline' — required by, and only by, the inlined
    <style> block. No script-src allowance, because there are no scripts.
  • img-src 'self' — kept even though the page has no images: browsers request
    /favicon.ico on their own and that fetch is governed by img-src, so under a bare
    default-src 'none' it would be blocked and logged as a violation.
  • form-action 'none', base-uri 'none' — no forms, no <base>.
  • frame-ancestors 'none' paired with X-Frame-Options: DENY; the two agree, and
    nothing in the repo suggests an intent to let others embed the channel listings.

Anchor navigation is not restricted by any of those directives (there is no
navigate-to in the policy and form-action governs form submission only), so the
outbound links keep working. I will confirm that in a browser rather than argue it.

HSTS

max-age=31536000, no preload for the reason given in the issue. includeSubDomains
I will decide on evidence and state the reasoning in the PR — it binds hostnames this
repo does not control, and unlike the other headers it cannot be walked back inside the
max-age window without also dropping the apex protection.

Verification

  1. make test, then confirm the file lands at public/_headers and that
    public/css/style.css and public/index.html are unchanged from the pre-change
    build.
  2. Serve the built public/ locally with the exact _headers values applied as real
    response headers and load it in a headless browser, collecting CSP violation
    reports and console errors. Click through to confirm the outbound links still
    navigate. This is the check that matters: static reasoning about a CSP is how you
    ship a broken one.
  3. make check, then script/cibuild with evidence the check layer actually ran
    rather than reporting CACHED.
  4. TODO.md in the same commit.

Explicitly not done here

Post-merge verification (step 7 of the definition of done) needs a live deploy and is
not mine to do. I will say so plainly in the PR, along with the note that it must be
checked on strict-transport-security or content-security-policy specifically —
checking x-content-type-options would pass whether or not Pages parsed the file.

access-control-allow-origin: * will get a sentence in the PR explaining the decision
either way.

## Implementation plan Re-confirmed the "before" state from the comment above with `curl -sSI https://lora.vegas/` just now (2026-08-09): the live response carries `x-content-type-options: nosniff`, `referrer-policy: strict-origin-when-cross-origin` and `access-control-allow-origin: *`, and carries **no** `strict-transport-security`, `content-security-policy`, `x-frame-options` or `permissions-policy`. So the comment's correction holds and the body's "no response security headers" opening does not. ### What I will add A single new file, `static/_headers`, with one `/*` block. No other file in the repo changes except `TODO.md`. `static/` does not exist at repo root today; Hugo unions the project's `static/` over the theme's `themes/loravega/static/`, per-path, so a root `static/_headers` cannot shadow `themes/loravega/static/css/style.css` — different paths. I will prove that rather than assert it by diffing the built `public/` tree and the sha256 of `public/css/style.css` before and after. ### Header values, each checked against the built page I dumped every element and attribute in the built `public/index.html`. It contains no `script`, `img`, `link`, `iframe`, `form`, `video`, `audio`, `object` or `embed` element, no `style=` attribute and no `on*=` handler. It contains exactly one inline `<style>` block (the whole of the theme CSS, inlined by `readFile` in `baseof.html`) and 12 anchors to meshtastic.org, signal.group, discord.gg, sneak.berlin, git.eeqj.de, lasmesh.com, maps.app.goo.gl and `http://las.packetflood.net:44920/`. So the issue's suggested CSP is achievable verbatim: - `default-src 'none'` — nothing else is fetched. - `style-src 'unsafe-inline'` — required by, and only by, the inlined `<style>` block. No `script-src` allowance, because there are no scripts. - `img-src 'self'` — kept even though the page has no images: browsers request `/favicon.ico` on their own and that fetch is governed by `img-src`, so under a bare `default-src 'none'` it would be blocked and logged as a violation. - `form-action 'none'`, `base-uri 'none'` — no forms, no `<base>`. - `frame-ancestors 'none'` paired with `X-Frame-Options: DENY`; the two agree, and nothing in the repo suggests an intent to let others embed the channel listings. Anchor navigation is not restricted by any of those directives (there is no `navigate-to` in the policy and `form-action` governs form submission only), so the outbound links keep working. I will confirm that in a browser rather than argue it. ### HSTS `max-age=31536000`, no `preload` for the reason given in the issue. `includeSubDomains` I will decide on evidence and state the reasoning in the PR — it binds hostnames this repo does not control, and unlike the other headers it cannot be walked back inside the max-age window without also dropping the apex protection. ### Verification 1. `make test`, then confirm the file lands at `public/_headers` and that `public/css/style.css` and `public/index.html` are unchanged from the pre-change build. 2. Serve the built `public/` locally with the exact `_headers` values applied as real response headers and load it in a headless browser, collecting CSP violation reports and console errors. Click through to confirm the outbound links still navigate. This is the check that matters: static reasoning about a CSP is how you ship a broken one. 3. `make check`, then `script/cibuild` with evidence the check layer actually ran rather than reporting `CACHED`. 4. `TODO.md` in the same commit. ### Explicitly not done here Post-merge verification (step 7 of the definition of done) needs a live deploy and is not mine to do. I will say so plainly in the PR, along with the note that it must be checked on `strict-transport-security` or `content-security-policy` specifically — checking `x-content-type-options` would pass whether or not Pages parsed the file. `access-control-allow-origin: *` will get a sentence in the PR explaining the decision either way.
Author
Collaborator

Implemented in #37 (branch
issue-14-security-headers, one commit on top of main).

static/_headers added with Strict-Transport-Security, X-Content-Type-Options,
Referrer-Policy, X-Frame-Options, Permissions-Policy and
Content-Security-Policy, plus a TODO.md update. Nothing else changes.

Against the definition of done:

  1. File exists, values checked. The CSP is the suggested one verbatim, because
    the built page supports it: no script, img, link, iframe, form or media
    element, no style= and no on*= attribute, one inline <style> block.
  2. Lands at public/_headers. After make test it is there and byte-identical
    to static/_headers. public/css/style.css and public/index.html are unchanged
    from the pre-change build, so the new root static/ unions with the theme's
    rather than shadowing it.
  3. CSP verified in a browser, not argued. Served the built public/ from a local
    server that parses the committed _headers and emits it as real response headers,
    then drove it with headless Chrome collecting securitypolicyviolation events:
    zero violations, the inline stylesheet parses to 17 rules with the theme's computed
    padding and colours, and all five named outbound links navigate with status 200.
  4. X-Frame-Options: DENY and frame-ancestors 'none' agree, and framing from
    another origin is refused in the browser.
  5. make check passes; script/cibuild succeeds with the check layer genuinely
    executed rather than served from cache.
  6. TODO.md updated in the same commit.
  7. Not done, and not doable from the branch. Step 7 needs a live deploy.

Two decisions worth flagging here rather than only in the PR:

  • HSTS ships without includeSubDomains (and without preload).
    www.lora.vegas is the only other name in DNS and it is served by this same Pages
    project — verified by the apex and www returning byte-identical bodies — so this
    file sets HSTS on its responses directly. includeSubDomains would therefore buy
    nothing today while binding every future subdomain for a year, and it cannot be
    walked back inside the max-age window without also dropping the apex protection.
    Left as a tracked owner decision in TODO.md.
  • access-control-allow-origin: * is left as-is. No cookies, no credentialed
    endpoints, no API, no private origin; everything served is already public, so
    cross-origin reads grant nothing a plain fetch does not, and narrowing it would
    only break legitimate third-party fetches.

On step 7 specifically: it must be checked on strict-transport-security or
content-security-policy. x-content-type-options would be a false pass, since
Cloudflare sends it whether or not the file was parsed.

Implemented in https://git.eeqj.de/sneak/lora.vegas/pulls/37 (branch `issue-14-security-headers`, one commit on top of `main`). `static/_headers` added with `Strict-Transport-Security`, `X-Content-Type-Options`, `Referrer-Policy`, `X-Frame-Options`, `Permissions-Policy` and `Content-Security-Policy`, plus a `TODO.md` update. Nothing else changes. Against the definition of done: 1. **File exists, values checked.** The CSP is the suggested one verbatim, because the built page supports it: no `script`, `img`, `link`, `iframe`, `form` or media element, no `style=` and no `on*=` attribute, one inline `<style>` block. 2. **Lands at `public/_headers`.** After `make test` it is there and byte-identical to `static/_headers`. `public/css/style.css` and `public/index.html` are unchanged from the pre-change build, so the new root `static/` unions with the theme's rather than shadowing it. 3. **CSP verified in a browser, not argued.** Served the built `public/` from a local server that parses the committed `_headers` and emits it as real response headers, then drove it with headless Chrome collecting `securitypolicyviolation` events: zero violations, the inline stylesheet parses to 17 rules with the theme's computed padding and colours, and all five named outbound links navigate with status 200. 4. **`X-Frame-Options: DENY` and `frame-ancestors 'none'` agree**, and framing from another origin is refused in the browser. 5. **`make check` passes; `script/cibuild` succeeds** with the check layer genuinely executed rather than served from cache. 6. **`TODO.md` updated in the same commit.** 7. **Not done, and not doable from the branch.** Step 7 needs a live deploy. Two decisions worth flagging here rather than only in the PR: - **HSTS ships without `includeSubDomains`** (and without `preload`). `www.lora.vegas` is the only other name in DNS and it is served by this same Pages project — verified by the apex and `www` returning byte-identical bodies — so this file sets HSTS on its responses directly. `includeSubDomains` would therefore buy nothing today while binding every future subdomain for a year, and it cannot be walked back inside the max-age window without also dropping the apex protection. Left as a tracked owner decision in `TODO.md`. - **`access-control-allow-origin: *` is left as-is.** No cookies, no credentialed endpoints, no API, no private origin; everything served is already public, so cross-origin reads grant nothing a plain fetch does not, and narrowing it would only break legitimate third-party fetches. On step 7 specifically: it must be checked on `strict-transport-security` or `content-security-policy`. `x-content-type-options` would be a false pass, since Cloudflare sends it whether or not the file was parsed.
Author
Collaborator

Post-merge verification done. Cloudflare Pages parses the file — headers are
live on both hostnames.
DoD item 7 satisfied; closing.

PR #37 merged as 7d7bec5; main run green throughout (check, build,
deploy all success).

curl -sSI — identical on https://lora.vegas/ and https://www.lora.vegas/:

strict-transport-security: max-age=31536000
content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'
permissions-policy: geolocation=(), microphone=(), camera=()
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff

All four previously-missing headers are present. The two Cloudflare already
sent are unchanged. Both hostnames return 200 and the page content is intact.

The check was deliberately made on strict-transport-security and
content-security-policy, not on x-content-type-options — Cloudflare
sends that one regardless, so it would have passed even if Pages had silently
ignored a malformed _headers. That was the failure mode this step existed to
rule out.

Also confirmed live rather than inferred: the inline <style> block is present
and applying (body{font-family:-apple-system,...), and the page carries no
<script>, <link>, <img> or <iframe> elements at all — consistent with
default-src 'none' and confirming nothing is being blocked.

www was checked because dropping includeSubDomains rests entirely on www
being the same Pages project, which was established behaviourally. It receives
the same headers directly, so the apex-only HSTS is sufficient in practice —
that reasoning now has production evidence behind it rather than only a
body-hash comparison.

Two open questions for @sneak, neither blocking and neither filed as work:

  • HSTS preload was deliberately not added — effectively irreversible.
    includeSubDomains was dropped for the reasons above. Both remain your call.
  • style-src 'unsafe-inline' could become a 'sha256-...' expression,
    satisfying the policy's preference more strictly, at the cost of coupling
    static/_headers to style.css bytes (any CSS edit silently breaks styling
    until the hash is updated) and moving _headers out of static/. This issue
    blessed unsafe-inline given the theme inlines CSS by design.
**Post-merge verification done. Cloudflare Pages parses the file — headers are live on both hostnames.** DoD item 7 satisfied; closing. PR #37 merged as `7d7bec5`; `main` run green throughout (`check`, `build`, `deploy` all success). `curl -sSI` — identical on `https://lora.vegas/` and `https://www.lora.vegas/`: ``` strict-transport-security: max-age=31536000 content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none' permissions-policy: geolocation=(), microphone=(), camera=() x-frame-options: DENY referrer-policy: strict-origin-when-cross-origin x-content-type-options: nosniff ``` All four previously-missing headers are present. The two Cloudflare already sent are unchanged. Both hostnames return 200 and the page content is intact. The check was deliberately made on `strict-transport-security` and `content-security-policy`, **not** on `x-content-type-options` — Cloudflare sends that one regardless, so it would have passed even if Pages had silently ignored a malformed `_headers`. That was the failure mode this step existed to rule out. Also confirmed live rather than inferred: the inline `<style>` block is present and applying (`body{font-family:-apple-system,...`), and the page carries no `<script>`, `<link>`, `<img>` or `<iframe>` elements at all — consistent with `default-src 'none'` and confirming nothing is being blocked. `www` was checked because dropping `includeSubDomains` rests entirely on `www` being the same Pages project, which was established behaviourally. It receives the same headers directly, so the apex-only HSTS is sufficient in practice — that reasoning now has production evidence behind it rather than only a body-hash comparison. Two open questions for @sneak, neither blocking and neither filed as work: - **HSTS `preload`** was deliberately not added — effectively irreversible. `includeSubDomains` was dropped for the reasons above. Both remain your call. - **`style-src 'unsafe-inline'`** could become a `'sha256-...'` expression, satisfying the policy's preference more strictly, at the cost of coupling `static/_headers` to `style.css` bytes (any CSS edit silently breaks styling until the hash is updated) and moving `_headers` out of `static/`. This issue blessed `unsafe-inline` given the theme inlines CSS by design.
Author
Collaborator

Verified in production. This closes the "unverified until the next deploy" caveat this issue shipped with.

curl -I https://lora.vegas/ now returns all four headers the edge did not previously send:

strict-transport-security: max-age=31536000
content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'
x-frame-options: DENY
permissions-policy: geolocation=(), microphone=(), camera=()

plus x-content-type-options: nosniff and referrer-policy: strict-origin-when-cross-origin, which Cloudflare was already sending. HSTS carries neither preload nor includeSubDomains, as intended.

Checked against the deploy of 910f343; page is 200 with the expected title, index.xml and sitemap.xml both 200.

TODO.md still carries a Future Step asking for this confirmation. It can be dropped by whichever commit next touches that file.

Verified in production. This closes the "unverified until the next deploy" caveat this issue shipped with. `curl -I https://lora.vegas/` now returns all four headers the edge did not previously send: ``` strict-transport-security: max-age=31536000 content-security-policy: default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none' x-frame-options: DENY permissions-policy: geolocation=(), microphone=(), camera=() ``` plus `x-content-type-options: nosniff` and `referrer-policy: strict-origin-when-cross-origin`, which Cloudflare was already sending. HSTS carries neither `preload` nor `includeSubDomains`, as intended. Checked against the deploy of `910f343`; page is 200 with the expected title, `index.xml` and `sitemap.xml` both 200. `TODO.md` still carries a Future Step asking for this confirmation. It can be dropped by whichever commit next touches that file.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/lora.vegas#14