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:
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
static/_headers exists with the headers above (or a documented, justified
variation).
After make test, the file appears at public/_headers — confirm this, it
is the whole point.
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.
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.
make check passes and script/cibuild succeeds.
TODO.md updated in the same commit.
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.
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."
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:
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:
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.
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.
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 nostrict-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
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.
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.
make check, then script/cibuild with evidence the check layer actually ran
rather than reporting CACHED.
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.
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:
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.
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.
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.
X-Frame-Options: DENY and frame-ancestors 'none' agree, and framing from
another origin is refused in the browser.
make check passes; script/cibuild succeeds with the check layer genuinely
executed rather than served from cache.
TODO.md updated in the same commit.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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.