Add a Cloudflare Pages _headers file with baseline security headers (closes #14)
All checks were successful
check / check (push) Successful in 9s

Hugo copies static/ verbatim into public/, so static/_headers lands at
the deploy output root, which is where Pages reads it from. This is the
first root-level static/ in the repo; Hugo unions it with the theme's
static/ per path rather than shadowing it, and the built tree confirms
that: public/css/style.css and public/index.html are byte-identical to
the previous build and the static file count goes from 1 to 2.

The live "before" was measured rather than assumed. Cloudflare already
sends X-Content-Type-Options and Referrer-Policy by default, so those
two lines are restatements; the substance is Strict-Transport-Security,
Content-Security-Policy, X-Frame-Options and Permissions-Policy, none of
which the site sends today.

Every value is checked against the built page, which loads nothing: no
script, img, link, iframe, form or media element, no style= and no on*=
attribute. It has exactly one inline <style> block, filled by readFile
in baseof.html. So default-src 'none' with style-src 'unsafe-inline' is
both achievable and tight, and 'unsafe-inline' is required by, and only
by, that deliberate inlining. There is no script-src allowance because
there are no scripts. X-Frame-Options: DENY and frame-ancestors 'none'
agree.

HSTS carries neither preload nor includeSubDomains. www.lora.vegas is
the only other name in DNS and it is served by this same Pages project,
so this file sets HSTS on its responses directly; includeSubDomains
would instead bind every future subdomain for a year, with no way to
walk it back inside the max-age window without also dropping the apex
protection.

Verified in a headless Chrome against a local server that parses the
committed _headers and applies it as real response headers: zero CSP
violations, the inlined stylesheet parses to 17 rules with the computed
body padding, tagline colour and link colour all coming from the theme
CSS, framing from another origin refused by frame-ancestors, and all
five named outbound links still navigating with status 200.

Whether Pages actually parses the file cannot be verified from here.
Pages silently ignores a malformed _headers, so the green build proves
nothing about it; that check belongs after the next deploy and must be
made on Strict-Transport-Security or Content-Security-Policy, since
X-Content-Type-Options would pass either way.
This commit is contained in:
clawbot
2026-08-09 16:50:27 +00:00
parent 821a293391
commit 8d639aa258
2 changed files with 77 additions and 1 deletions

36
TODO.md
View File

@@ -18,7 +18,9 @@ dotfiles; `LICENSE` is the only mandated file still missing. Every external
reference in the repo is now pinned by cryptographic hash (or, for the wrangler reference in the repo is now pinned by cryptographic hash (or, for the wrangler
CLI install, an exact version), and the Hugo that builds the published site is a CLI install, an exact version), and the Hugo that builds the published site is a
deliberate pinned version rather than whatever the base image's package repo deliberate pinned version rather than whatever the base image's package repo
serves. serves. The site now ships a Cloudflare Pages `_headers` file, so its response
security headers are declared in the repo instead of being whatever the edge
defaults to — unverified in production until the next deploy.
# Next Step # Next Step
@@ -28,6 +30,30 @@ remaining policy scaffold is otherwise complete.
# Completed Steps # Completed Steps
- 2026-08-09: added `static/_headers` so Cloudflare Pages serves baseline
response security headers (closes #14). Hugo copies `static/` verbatim into
`public/`, which is the deploy root Pages reads the file from; this is the
first root-level `static/` in the repo, and the built tree confirms it unions
with the theme's rather than shadowing it — `public/css/style.css` and
`public/index.html` are byte-identical to the previous build and the static
file count goes 1 to 2. The live "before" was measured, not assumed:
Cloudflare already sends `X-Content-Type-Options` and `Referrer-Policy` by
default, so the substance here is `Strict-Transport-Security`,
`Content-Security-Policy`, `X-Frame-Options` and `Permissions-Policy`. The CSP
is `default-src 'none'` with `style-src 'unsafe-inline'`, which the built page
supports exactly: it has no script, img, link, iframe, form or media element
and no `style=`/`on*=` attribute, only the one inline `&lt;style&gt;` block
`baseof.html` fills by `readFile`. Verified in a headless Chrome against a
local server that parses the committed `_headers` and applies it as real
response headers: zero CSP violations, the inlined stylesheet parses to 17
rules and the computed body padding, tagline colour and link colour all come
from the theme CSS, framing the page from another origin is refused by
`frame-ancestors 'none'` (consistent with `X-Frame-Options: DENY`), and all
five named outbound links still navigate with status 200. HSTS carries neither
`preload` nor `includeSubDomains`: `www.lora.vegas` is the only other name in
DNS and it is served by this same Pages project, so this file sets HSTS on its
responses directly, and `includeSubDomains` would instead bind every future
subdomain for a year with no way to walk it back inside the max-age window
- 2026-08-09: restructured `README.md` into the canonical section set (closes - 2026-08-09: restructured `README.md` into the canonical section set (closes
#11): a Description first line, then Getting Started, Entrypoints, Rationale, #11): a Description first line, then Getting Started, Entrypoints, Rationale,
Design, TODO, License, Author. The non-standard About / Contributing / Design, TODO, License, Author. The non-standard About / Contributing /
@@ -178,6 +204,14 @@ remaining policy scaffold is otherwise complete.
here (#20) here (#20)
- Move the deploy container to a pinned node 22 so the wrangler pin can advance - Move the deploy container to a pinned node 22 so the wrangler pin can advance
past 4.86.0 (#21) past 4.86.0 (#21)
- After the next deploy, confirm the `_headers` file actually took effect:
`curl -sSI https://lora.vegas/` must show `strict-transport-security` or
`content-security-policy`. Cloudflare Pages silently ignores a malformed
`_headers`, and checking `x-content-type-options` would pass either way
because the edge sends it regardless (#14)
- Decide the HSTS `includeSubDomains` and `preload` posture for `lora.vegas`.
Both are owner calls: neither can be walked back inside the max-age window,
and `includeSubDomains` binds hostnames this repo does not control (#14)
- Sync the reformat of `REPO_POLICIES.md` back upstream to `prompts` so the - Sync the reformat of `REPO_POLICIES.md` back upstream to `prompts` so the
canonical copy is clean under the shared prettier settings and future syncs canonical copy is clean under the shared prettier settings and future syncs
are a straight byte copy are a straight byte copy

42
static/_headers Normal file
View File

@@ -0,0 +1,42 @@
# Cloudflare Pages response headers.
#
# Hugo copies static/ verbatim into public/, so this file lands at the
# deploy output root, which is where Pages reads it from. Pages consumes
# the file rather than publishing it. Values here override what
# Cloudflare would otherwise send.
#
# Every value below was checked against the built public/index.html, not
# copied from a template. That page loads nothing: no script, img, link,
# iframe, form, video, audio, object or embed element, no style= or on*=
# attribute. It has exactly one inline <style> block, which
# themes/loravega/layouts/_default/baseof.html fills with the whole of
# themes/loravega/static/css/style.css via readFile. That inlining is a
# deliberate theme design choice, and it is the sole reason style-src
# needs 'unsafe-inline'.
#
# img-src 'self' is kept even though the page has no images. Browsers
# request /favicon.ico unprompted and that fetch is governed by img-src;
# measured in Chrome, with this allowance the request is made and 404s,
# and without it the request is suppressed outright. Neither hurts
# today, but same-origin images are the one resource class this site
# would plausibly grow, and 'self' loosens nothing cross-origin.
#
# X-Content-Type-Options and Referrer-Policy are already sent by
# Cloudflare by default and are restated here on purpose. They are a
# default, not a guarantee, and this file is where the site's header
# posture is declared.
#
# Strict-Transport-Security deliberately carries neither preload nor
# includeSubDomains. preload is effectively irreversible and is the
# owner's call. includeSubDomains would bind every hostname under
# lora.vegas for a year, and it buys nothing today: www.lora.vegas is
# the only other name in DNS, it is served by this same Pages project,
# so this block sets HSTS on its responses directly.
/*
Strict-Transport-Security: max-age=31536000
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'