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

The floor on every response: `/events` is answering without it #37

open not picked up yet opened this
opened this

Every response carries the security floor, because it is applied once on the way out instead of by each helper that remembers to.

On production today (cf64fd3) GET /events answers with content-type, cache-control and x-accel-buffering and nothing else — no content-security-policy, no x-content-type-options, no referrer-policy, no permissions-policy. src/live.ts builds its Response by hand, so it never passes through html/json/text in src/http.ts where SECURITY lives. Every other route checked (/, /api/events, /static/app.css, the 404, POST /mcp at 405, HEAD /) carries it. A floor that each response opts into is a floor the next route will forget.

Done looks like

  • [ ] src/server.ts puts SECURITY on every response leaving fetch: the routed answer, the 403 from the same-origin check, the 429 from the ration, the 404 from no match, the 500 from error(), and the HEAD copy. curl -sI on /events shows the same four headers as /.
  • [ ] A header a route set on purpose wins: the floor fills in what is missing and overwrites nothing, so /events keeps cache-control: no-store and x-accel-buffering: no, /static/:file keeps its year-long immutable cache-control, and each response keeps its own content-type.
  • [ ] The streaming body survives: the headers are added without reading, copying or re-wrapping the ReadableStream in a way that buffers it. A test opens /events against a served instance, asserts retry: 2000 arrives as the first bytes, emits an event and asserts it reaches the reader — the live path proved end to end, not just its headers.
  • [ ] src/server.test.ts walks the exported routes table and asserts every key of SECURITY on the answer of each route, so a route added next month is covered by a test nobody has to remember to write.
  • [ ] The helpers in src/http.ts stay honest: either they keep spreading SECURITY (harmless, now belt and braces) or they stop and the comment says the floor moved — one of the two, written down, not both half-done.
  • [ ] README's floor paragraph says where the headers are applied now.

Files: src/server.ts, src/http.ts, src/live.ts (comment only, most likely), src/server.test.ts, README.md

Risks: new Response(res.body, res) on a streaming response is the trap — it can detach or buffer the stream depending on how it is written; mutating response.headers in place is the cheap path, but a Response built from a stream has mutable headers only before it is returned, so prove it with the live test rather than by reading. #26 is rewriting the innards of /events; this issue must not change its exported surface, only what the server wraps around it.

Suggested: Sentinel — it is a floor, and the test is the deliverable.

commented

One more thing missing from the floor, measured on production at 837ec5d: an HTML response carries no cache-control at all. / is rendered per identity — the header prints the signed-in name and an admin sees more — so the only thing keeping a shared cache or a back-button restore from showing one person's page to another is the edge's current default. Add it here while this issue is in the floor: cache-control: no-store (or private, no-cache) on html(), left off staticFile() which already sets its own, and a test that walks routes asserting every HTML answer carries it. Same pull, same reasoning as the CSP on /events: the floor is what nobody has to remember.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs