Add a Cloudflare Pages _headers file with baseline security headers #14
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The site is served over HTTPS by Cloudflare Pages with no response security
headers configured. There is no
_headersfile and no header configurationanywhere in the repo.
REPO_POLICIES.mdscopes most of its HTTP hardening block to Go web servicesbefore 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
_headersfile from thebuild output root. Everything else in the block needs application code that
does not exist here.
Fix
Add
static/_headers(Hugo copiesstatic/verbatim intopublic/, so itlands at the deploy root where Cloudflare Pages expects it).
Suggested baseline — the implementer should verify each against the actual page
before committing:
Notes on the CSP, which is the only non-obvious one: the site loads no scripts,
no fonts, and no external stylesheets —
baseof.htmlinlines the entire CSSinto a
<style>block viareadFile. Sodefault-src 'none'withstyle-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-srcallowances for scripts that do not exist.includeSubDomainson HSTS is worth a moment's thought: it applies to everysubdomain of
lora.vegas. If any subdomain is or might be served over plainHTTP, drop
includeSubDomains. Do not addpreload— that is effectivelyirreversible and needs an explicit decision.
Definition of done
static/_headersexists with the headers above (or a documented, justifiedvariation).
make test, the file appears atpublic/_headers— confirm this, itis the whole point.
particular, load
public/index.htmlin a browser with the CSP applied andconfirm 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.
X-Frame-Options: DENYand CSPframe-ancestors 'none'are consistent witheach other and with any intent to let others embed the channel listings. If
embedding is desired, both need to change together.
make checkpasses andscript/cibuildsucceeds.TODO.mdupdated in the same commit.https://lora.vegas/ (
curl -sI https://lora.vegas/). Cloudflare Pagessilently ignores a malformed
_headersfile, so a green build provesnothing here. Report the result on this issue.
No sensible static-site equivalent — explicitly not applicable
The rest of the
REPO_POLICIES.mdHTTP hardening block requires an applicationprocess. Cloudflare Pages runs no code for this site; it serves files. None of
the following can or should be implemented here:
http.MaxBytesReaderrequest body limits — no request handling.ReadTimeout/ReadHeaderTimeout/WriteTimeout/IdleTimeout— noserver process to configure.
context.WithTimeouton request paths — no request paths.Secure,HttpOnly,SameSite) — no sessions, nocookies set by this site.
X-Forwarded-Forhandling — nothing reads client IPs.configurable from this repo.
/.well-known/healthcheckand/metricsendpoints — scoped byCODE_STYLEGUIDE.mdto "services/servers"; there is no process to healthcheck or instrument.
DEBUGenv var, port 8080 +PORToverride,runit/runsvinit entrypoint — all server-process concerns. The
Dockerfilehere is a build-and-check harness that exits; it is not a service image.
Ref:
REPO_POLICIES.mdHTTP service hardening — "When in doubt, harden."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 twoof them by default:
Both happen to match exactly what the proposed
_headersfile specifies, sothose two lines would be no-ops rather than additions.
Still genuinely missing, and still the substance of this issue:
Strict-Transport-SecurityContent-Security-PolicyX-Frame-OptionsPermissions-PolicyAlso 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:
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.
_headersfileis working post-merge — they will be there whether the file is parsed or
not. Verify specifically on
Strict-Transport-SecurityorContent-Security-Policy, which are only present if the file took effect.Cloudflare Pages silently ignores a malformed
_headersfile, so pickingthe 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.
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 carriesx-content-type-options: nosniff,referrer-policy: strict-origin-when-cross-originand
access-control-allow-origin: *, and carries nostrict-transport-security,content-security-policy,x-frame-optionsorpermissions-policy. So the comment'scorrection 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 repochanges except
TODO.md.static/does not exist at repo root today; Hugo unions theproject's
static/over the theme'sthemes/loravega/static/, per-path, so a rootstatic/_headerscannot shadowthemes/loravega/static/css/style.css— differentpaths. I will prove that rather than assert it by diffing the built
public/tree andthe sha256 of
public/css/style.cssbefore and after.Header values, each checked against the built page
I dumped every element and attribute in the built
public/index.html. It contains noscript,img,link,iframe,form,video,audio,objectorembedelement, no
style=attribute and noon*=handler. It contains exactly one inline<style>block (the whole of the theme CSS, inlined byreadFileinbaseof.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. Noscript-srcallowance, because there are no scripts.img-src 'self'— kept even though the page has no images: browsers request/favicon.icoon their own and that fetch is governed byimg-src, so under a baredefault-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 withX-Frame-Options: DENY; the two agree, andnothing 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-toin the policy andform-actiongoverns form submission only), so theoutbound links keep working. I will confirm that in a browser rather than argue it.
HSTS
max-age=31536000, nopreloadfor the reason given in the issue.includeSubDomainsI 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
make test, then confirm the file lands atpublic/_headersand thatpublic/css/style.cssandpublic/index.htmlare unchanged from the pre-changebuild.
public/locally with the exact_headersvalues applied as realresponse 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.
make check, thenscript/cibuildwith evidence the check layer actually ranrather than reporting
CACHED.TODO.mdin 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-securityorcontent-security-policyspecifically —checking
x-content-type-optionswould pass whether or not Pages parsed the file.access-control-allow-origin: *will get a sentence in the PR explaining the decisioneither way.
Implemented in #37 (branch
issue-14-security-headers, one commit on top ofmain).static/_headersadded withStrict-Transport-Security,X-Content-Type-Options,Referrer-Policy,X-Frame-Options,Permissions-PolicyandContent-Security-Policy, plus aTODO.mdupdate. Nothing else changes.Against the definition of done:
the built page supports it: no
script,img,link,iframe,formor mediaelement, no
style=and noon*=attribute, one inline<style>block.public/_headers. Aftermake testit is there and byte-identicalto
static/_headers.public/css/style.cssandpublic/index.htmlare unchangedfrom the pre-change build, so the new root
static/unions with the theme'srather than shadowing it.
public/from a localserver that parses the committed
_headersand emits it as real response headers,then drove it with headless Chrome collecting
securitypolicyviolationevents: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.
X-Frame-Options: DENYandframe-ancestors 'none'agree, and framing fromanother origin is refused in the browser.
make checkpasses;script/cibuildsucceeds with the check layer genuinelyexecuted rather than served from cache.
TODO.mdupdated in the same commit.Two decisions worth flagging here rather than only in the PR:
includeSubDomains(and withoutpreload).www.lora.vegasis the only other name in DNS and it is served by this same Pagesproject — verified by the apex and
wwwreturning byte-identical bodies — so thisfile sets HSTS on its responses directly.
includeSubDomainswould therefore buynothing 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 credentialedendpoints, 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-securityorcontent-security-policy.x-content-type-optionswould be a false pass, sinceCloudflare sends it whether or not the file was parsed.
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;mainrun green throughout (check,build,deployall success).curl -sSI— identical onhttps://lora.vegas/andhttps://www.lora.vegas/: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-securityandcontent-security-policy, not onx-content-type-options— Cloudflaresends 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 torule out.
Also confirmed live rather than inferred: the inline
<style>block is presentand applying (
body{font-family:-apple-system,...), and the page carries no<script>,<link>,<img>or<iframe>elements at all — consistent withdefault-src 'none'and confirming nothing is being blocked.wwwwas checked because droppingincludeSubDomainsrests entirely onwwwbeing 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:
preloadwas deliberately not added — effectively irreversible.includeSubDomainswas 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/_headerstostyle.cssbytes (any CSS edit silently breaks stylinguntil the hash is updated) and moving
_headersout ofstatic/. This issueblessed
unsafe-inlinegiven the theme inlines CSS by design.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:plus
x-content-type-options: nosniffandreferrer-policy: strict-origin-when-cross-origin, which Cloudflare was already sending. HSTS carries neitherpreloadnorincludeSubDomains, as intended.Checked against the deploy of
910f343; page is 200 with the expected title,index.xmlandsitemap.xmlboth 200.TODO.mdstill carries a Future Step asking for this confirmation. It can be dropped by whichever commit next touches that file.