Skip to content

wiki: security headers on Worker responses + report-only CSP - #47

Merged
Deicyde merged 4 commits into
mainfrom
claude/worker-security-headers
Oct 14, 2026
Merged

Deicyde merged 4 commits into
mainfrom
claude/worker-security-headers

Conversation

@Deicyde

@Deicyde Deicyde commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

Problem

The Worker sets no browser security headers. On 2026-10-13, curl -sD- https://wikilean.jackmccarthy.org/{,about,login,stats} returned no Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options/frame-ancestors, Referrer-Policy or Content-Security-Policy. The home page's headers were only date, content-type, server-timing, report-to, nel, server, cf-ray, alt-svc. git grep -i 'Content-Security-Policy\|X-Frame-Options\|Strict-Transport' wiki/src at 37b7036 finds nothing.

The site renders user-contributed annotations, so a single escaping bug in the renderer has no defense-in-depth behind it.

Change

New wiki/src/security.ts, registered first in index.ts so it wraps every route, notFound and onError. Every Worker-generated response now carries:

Header Value
Strict-Transport-Security max-age=31536000; includeSubDomains
X-Content-Type-Options nosniff
X-Frame-Options DENY
Content-Security-Policy (enforced) frame-ancestors 'none'
Content-Security-Policy-Report-Only the full policy below
Reporting-Endpoints csp="/api/csp-report"
Referrer-Policy strict-origin-when-cross-origin

The report-only policy was built from what live pages load today, checked against captures of /, /about, /stats, /login, an article, history, /recent-changes, /articles, /review, /queue, /wikifunctions and /mcp:

  • inline <script>/<style> blocks and style= attributes ('unsafe-inline'), plus same-origin assets and fetch calls;
  • the Cloudflare Web Analytics beacon that Cloudflare injects into HTML (static.cloudflareinsights.com script, cloudflareinsights.com connect);
  • the landing page's same-origin <iframe src="/brain?embed=1"> (frame-src 'self');
  • Wikipedia images and media in article bodies (*.wikimedia.org, wikimedia.org);
  • OAuth avatars on profiles (*.githubusercontent.com, *.googleusercontent.com);
  • on /review: GitHub comment media (github.com, plus *.githubusercontent.com in media-src, since uploaded videos render with a private-user-images.githubusercontent.com src) and the JuliaMono webfont (font-src https://cdn.jsdelivr.net);
  • object-src 'none', base-uri 'self', form-action 'self'.

The framing ban is enforced through its own header, because a report-only frame-ancestors only reports.

New POST /api/csp-report is a log-only sink. It accepts both report shapes (legacy report-uri and Reporting API batches) and writes one structured csp-report line per request to Workers Logs, holding at most 20 violations with fields truncated. The body is byte-capped at 64 KB while it streams, and nothing is stored. The path is under /api, which RESERVED already lists.

Render cache: headers are set at response time and never enter the RENDER_CACHE bytes, so no cache key changes. A test asserts the cached values contain no header text.

Not covered: files in wiki/public (including /brain, /concepts and the Brain shards) are served by Cloudflare before the Worker runs, so these headers don't reach them. Covering them needs a _headers asset emitted by build-public, which changes the release asset tree. That would be a follow-up, and /brain would need frame-ancestors 'self'/SAMEORIGIN, not DENY, because the landing page frames it.

How tested

npx vitest run test/security-headers.test.ts: 8 pass. It pins the exact headers on:

  • an HTML page, both freshly rendered and served from the KV cache;
  • an article, a JSON API and a 404;
  • a login redirect;
  • a raw Response.redirect (immutable headers, re-wrapped).

It also checks that the CSP directives cover the iframe, the beacon, the webfont, GitHub comment media and Wikimedia, that both report shapes are logged as one line, and that oversized, chunked and malformed report bodies are rejected. npx tsc --noEmit passes.

Risks

  • HSTS can't be withdrawn for a visitor until max-age (1 year) expires. It assumes the zone's HTTP-to-HTTPS redirect ("Always Use HTTPS") is enabled before a release ships this header. The wiki host has no subdomains for includeSubDomains to reach.
  • X-Frame-Options: DENY stops any third party from framing Worker pages. Nothing on the site frames a Worker route.
  • The CSP is report-only, so nothing is blocked. The cost is console noise and csp-report log lines for anything the policy misses.
  • The sink is unauthenticated (browsers send the reports), but each request writes at most one log line.

Release-affecting: ships only through the Brain release and promotion path.

What Jack must do

Before the release that ships this, make sure the zone's "Always Use HTTPS" redirect is enabled. After it ships, watch Workers Logs for "event":"csp-report", adjust the policy, then switch it to enforced.

Every Worker-generated response now carries HSTS, X-Content-Type-Options:
nosniff, an enforced frame-ancestors 'none' CSP plus X-Frame-Options: DENY,
Referrer-Policy, and a Content-Security-Policy-Report-Only policy that
matches what the pages load today (inline scripts/styles, the same-origin
/brain iframe, Wikipedia media, OAuth avatars, the Cloudflare Web Analytics
beacon). Violation reports go to a log-only POST /api/csp-report sink.

Headers are applied at response time and never enter RENDER_CACHE bytes,
so no render-cache key bump. Static assets (wiki/public, including /brain)
are served before the Worker runs and are not covered.
…e report sink

- font-src allows the JuliaMono webfont /review loads from jsDelivr;
  img-src/media-src allow github.com (GitHub-rendered comment attachments).
- /api/csp-report counts body bytes while streaming (a chunked body without
  Content-Length can no longer buffer unbounded), raises the cap to 64 KB so
  Reporting API batches fit, and logs one line per request.
- Correct the frame-ancestors comment: report-only reports, it does not block.
@Deicyde

Deicyde commented Oct 14, 2026 •

Copy link
Copy Markdown
Owner Author

Review status: merged in 8bec6b5e; follow-ups remain

Update 2026-10-14 (re-checked at f0eb453e):

  • Landed and verified on main: HSTS, nosniff, X-Frame-Options: DENY, an enforced frame-ancestors 'none' and Referrer-Policy on every Worker response. CI is green.
  • Fixed in fe8b9c57: the cache-hit test now matches any page:about: key (wiki/test/security-headers.test.ts:49), so it passes with wiki: /about describes the Human-at-boundaries moderation policy #55's v2 key on main. media-src now allows *.githubusercontent.com.
  • Departure: the report-only CSP and POST /api/csp-report were kept, with no rate limit (wiki/src/security.ts:161-177). Assessment: risky. The endpoint takes spoofable, unthrottled log writes, and with 'unsafe-inline' the reports cannot surface inline-script XSS.
  • Still open: wiki/src/security.ts:18 and the PR description still plan to enforce this policy after watching logs. Enforcing it would not block inline-script XSS.
  • Owner items: confirm Always Use HTTPS before the release; decide whether to remove the sink or rate-limit it; choose how static assets get headers; pin d3 with SRI in a separate PR.
Original review (2026-10-14)

Independent adversarial review of cb948490: a defect reviewer with skeptics, a design reviewer with a devil's advocate, and an arbiter.

Verdict: keep the Worker security headers; drop the report-only CSP and the /api/csp-report endpoint.

Required before merge

  • Remove the CSP reporting pipeline and its tests. With script-src 'unsafe-inline' the reports can't surface inline-script XSS, every policy tweak costs a Brain release, and the endpoint is a new unauthenticated, unrate-limited route.
  • The test at wiki/test/security-headers.test.ts:49 pins page:about:v1; wiki: /about describes the Human-at-boundaries moderation policy #55 bumps it to v2, so whichever merges second turns CI red. Check the prefix instead.
  • Make the header comment and this description say what ships: Worker responses only (static assets like /brain get none; HSTS still covers the host), and the only CSP is frame-ancestors.

Decisions for the maintainer

  • Confirm "Always Use HTTPS" is on in Cloudflare.
  • Build a script-blocking CSP at all? (nonces or hashes; recommended: not now)
  • Static assets: accept no headers (recommended), a Cloudflare Transform Rule, or _headers from build-public (/brain needs frame-ancestors 'self').

Merge order: release-affecting; merge with the other wiki/src PRs right before the next Brain release build.

Fixes are in progress; this comment will be updated.

Posted by the WikiLean PR swarm (lead); updated 2026-10-14.

@Deicyde

Deicyde commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Status review (2026-10-14): 🔧 One-line fix before merge

The headers are correct and break nothing live. Framing is denied, nosniff and HSTS are set, and the CSP is report-only and covers every origin seen on live pages. In a combined build with #46, #50, #54, #62 and #63, typecheck and the worker suite pass.

Fix before merge

Nits

  • POST /api/csp-report has no auth and no rate limit, so anyone can write forged csp-report lines. Treat that data with care when tuning the policy before enforcing it.
  • media-src allows https://github.com but not *.githubusercontent.com. GitHub-rendered comment HTML (which /review injects) gives uploaded videos a private-user-images.githubusercontent.com src, which img-src allows but media-src does not. Today that is only report noise; it becomes a block once enforced.

Conflicts: #54 conflicts in wiki/src/index.ts, on adjacent import lines. Keep both imports. registerSecurityHeaders(app) stays first, so it wraps #54's /api/version. Verified in the combined build.

To ship: Worker change, so it needs the next Brain release and promotion. HSTS cannot protect a visitor's first plain-HTTP request, so confirm the zone's HTTP→HTTPS redirect (Always Use HTTPS) is on before the release ships this header. The PR's "HTTPS-only" risk note depends on it.


🤖 Automated status pass by Claude Code over all 18 open PRs (#46–#63), posted for the repo owner. An independent verifier re-checked each finding. All 18 heads were also merged and tested together in a scratch tree; the only test failure that comes from combining them is #47 × #55 (three other pairs had mechanical text conflicts). Reviewed head: cb948490.

…bout cache test

- media-src adds https://*.githubusercontent.com: GitHub-rendered comment
  HTML on /review gives uploaded videos a private-user-images src (img-src
  already allowed the host); the test pins it.
- The security-headers cache test checks for any page:about:* key instead
  of page:about:v1, so an /about cache-key bump does not break it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Deicyde

Deicyde commented Oct 14, 2026

Copy link
Copy Markdown
Owner Author

Fixes pushed (2026-10-14)

This addresses the two earlier reviews on this PR (the "Status review (2026-10-14)" comment and the PR swarm's review): the page:about:v1 test pin both flagged, plus the status review's media-src nit and HSTS-wording note.

What changed

  • The /about cache test now accepts any page:about: key, so wiki: /about describes the Human-at-boundaries moderation policy #55's bump to v2 no longer breaks it (fe8b9c57).
  • media-src now allows https://*.githubusercontent.com, matching img-src, for uploaded videos in GitHub comment HTML on /review. A new assertion pins it (fe8b9c57).
  • PR description: the HSTS risk note is now a precondition ("Always Use HTTPS" enabled before the release that ships this header), also listed under "What Jack must do". The media-src and test-coverage lines were updated to match.

Verification

Not changed / still open

  • The report-only CSP and /api/csp-report are kept, with no rate limit added. The swarm review asked to remove them; the status review rated the endpoint a nit (report-only blocks nothing; one capped log line per request). Left to the owner.
  • Scope wording is unchanged: the security.ts header comment and the description's "Not covered" section already say static assets get none of these headers.
  • Owner decisions: whether to build a script-blocking CSP, and how to handle headers for static assets.

Merge notes


🤖 Posted by Claude Code for the repo owner. Each fix was checked by an independent verifier before it was pushed. Head: fe8b9c57.

@Deicyde
Deicyde merged commit 8bec6b5 into main Oct 14, 2026
4 checks passed
@Deicyde
Deicyde deleted the claude/worker-security-headers branch October 14, 2026 21:15
Deicyde added a commit that referenced this pull request Oct 15, 2026
Resolves the wiki/src/index.ts import conflict with #47 by keeping both
imports; registerVersionRoutes stays after registerSecurityHeaders (and
registerRepoRoutes), so the security headers wrap /api/version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deicyde added a commit that referenced this pull request Oct 17, 2026
Resolves the wiki/src/index.ts import conflict with #47 by keeping both
imports; registerVersionRoutes stays after registerSecurityHeaders (and
registerRepoRoutes), so the security headers wrap /api/version.
Deicyde added a commit that referenced this pull request Oct 17, 2026
wiki: security headers on Worker responses + report-only CSP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant