Public: anyone can read this; the crew can change it.

The security floor is on every response, not only the HTML ones #165

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

Pushed. Live check against a booted forge confirms the floor on `/`, `/api/v1/repos`, `/assets/forge.css`, `/healthz` and a 404.

shipped production is serving it deploy caaa084

opened this

Pushed. Live check against a booted forge confirms the floor on /, /api/v1/repos, /assets/forge.css, /healthz and a 404.

---

Every response the forge writes now leaves with x-content-type-options: nosniff and referrer-policy: same-origin, and every non-document one with default-src 'none'; frame-ancestors 'none'; base-uri 'none' — so a JSON body carrying repository names, issue titles and comment text is inert even if a browser is talked into treating it as markup. The decision lives in one function, securityFloor (src/http/respond.ts), applied at one door: the fetch boundary in src/http/server.ts. To give the wrapper a single expression to wrap, the router half of fetch moved out verbatim into answer(app, router, limiter, request) — the diff is a move plus indentation, no logic changed, and the #137 comment about the viewer-less Ctx moved with it; nothing in the wrapper resolves auth.

A responder that already set one of these headers keeps its value, so page() keeps its nonce'd policy and fragment() its sandbox one. Which policy a response gets is decided by the content-type that actually left, not by prefersHtml as suggested on the issue: the question at this seam is "is this body a document?", and Accept answers a different one — a browser navigating to a JSON route sends Accept: text/html and would have been handed the exemption. prefersHtml still decides the 404 shape, one call, unchanged. Headers are set on the response that exists rather than on a copy, the same trade withRateHeaders makes one line up, so the SSE log follow is never re-read; an immutable header set is caught and left alone. The wrapper sits after proxyApp returns, so a preview's headers are still entirely the app's — that is true by construction, not by care.

Git smart-HTTP goes through the router and so now carries the floor; git ignores all three. Checked by running test/app.test.ts and test/web.test.ts, which clone, commit and push over HTTPS against a live forge — both green (in this sandbox they need GIT_CONFIG_COUNT unset, since the agent environment injects an http.extraHeader Authorization that overrides the test's own credentials; they fail identically without my change). Cost measured at ~1.3 µs per response for three has and three set — about 0.03% of a 5 ms page.

To try it: curl -sI http://…/api/v1/repos and /assets/forge.css and /healthz, then curl -sI / to see the nonce'd page policy survive. The new test in test/web.test.ts walks an HTML page, JSON, error JSON, no_route JSON, the stylesheet, the built asset, a redirect and /healthz; with the boundary reverted it fails on the first JSON response. bun x tsc --noEmit and the check command in forge.json are green (322 pass, 0 fail). I did not touch the per-responder headers themselves, the internal unix socket, or app-side responses.

🤖 Generated with Claude Code

Closes #136

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs