9.8 KiB
Plan 002: Add CSP + framing headers to web pages; gate the uploads CORS header
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving on. If anything in "STOP conditions" occurs, stop and report. When done, update the status row for this plan in
plans/README.md.Drift check (run first):
git diff --stat 73daf22b2..HEAD -- apps/server/src/static/web.ts apps/server/src/static/uploads.tsIf either file changed since this plan was written, compare the "Current state" excerpts against the live code before proceeding; on mismatch, STOP.
Status
- Priority: P1
- Effort: S
- Risk: LOW
- Depends on: none
- Category: security
- Planned at: commit
73daf22b2, 2026-07-08
Why this matters
The Hono server serves every HTML page (public resumes, the auth/dashboard/
builder shells) through handleWebApp in apps/server/src/static/web.ts, and
that response sets only Content-Type and (sometimes) X-Robots-Tag. There is
no Content-Security-Policy and no X-Frame-Options. Consequences:
- Any page can be framed by an attacker's site → clickjacking against authenticated actions and public resume views.
- No script-source restriction → a single injected-content bug becomes a much larger XSS blast radius than it needs to be.
Separately, the file-serving endpoint in apps/server/src/static/uploads.ts
sets Access-Control-Allow-Origin: env.APP_URL unconditionally on a GET
endpoint whose only real consumers are same-origin. Cross-Origin-Resource-Policy: same-site is already set, so the ACAO header adds cross-origin exposure for no
functional benefit.
This plan adds the missing headers to web responses and removes the unnecessary ACAO header. It is deliberately conservative: CSP ships in report-only mode first so it cannot break the app on rollout.
Current state
-
apps/server/src/static/web.ts:55-65—getFallbackResponseHeadersreturns a plain object of headers per path (ornullfor a 404):function getFallbackResponseHeaders(pathname: string) { if (pathname === "/") return { "Content-Type": "text/html; charset=UTF-8" }; if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) { return { "Content-Type": "text/html; charset=UTF-8", "X-Robots-Tag": "noindex, follow", }; } return null; }These objects are spread into the
Responseatweb.ts:86-92(both the HEADnew Response(null, { status: 200, headers })path and the GETnew Response(html, { headers })path). -
apps/server/src/static/uploads.ts:28-46— the file response already sets a strong header set as a model to follow, and ends with the ACAO line to remove:headers.set("Cache-Control", "public, max-age=31536000, immutable"); headers.set("ETag", etag); headers.set("X-Content-Type-Options", "nosniff"); headers.set("X-Robots-Tag", "noindex, nofollow"); headers.set("Cross-Origin-Resource-Policy", "same-site"); headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); headers.set("X-Frame-Options", "DENY"); headers.set("X-Download-Options", "noopen"); headers.set("Access-Control-Allow-Origin", env.APP_URL); // <-- remove this line -
Existing test files that assert headers (follow their style, they are your regression net):
apps/server/src/static/web.test.tsapps/server/src/static/uploads.test.ts
-
Design constraint — the app has no known legitimate need to be iframed, and the builder preview is a same-origin pdf.js canvas (not a cross-origin frame). So
X-Frame-Options: DENYis safe. The app does load web fonts and images from same origin and inline styles/scripts from the Vite bundle, which is why CSP starts report-only: do not enforce a policy you have not observed the app satisfy.
Commands you will need
| Purpose | Command | Expected on success |
|---|---|---|
| Typecheck | pnpm --filter server typecheck |
exit 0 |
| Test | pnpm --filter server test -- static |
all pass |
(The server package is named server in apps/server/package.json, not
@reactive-resume/server.)
(Do NOT run pnpm check.)
Scope
In scope:
apps/server/src/static/web.tsapps/server/src/static/web.test.tsapps/server/src/static/uploads.tsapps/server/src/static/uploads.test.tsplans/README.md(status row)
Out of scope (do NOT touch):
- The oRPC / auth / MCP / OpenAPI handlers — they return API responses, not HTML pages; header policy for those is a separate concern.
- Enforcing (non-report-only) CSP — an enforced policy requires collecting violation reports first; that is explicit follow-up, not this plan.
- Any web-app (
apps/web) source — headers are set at the server layer.
Git workflow
- Branch:
advisor/002-security-headers - Commit style: conventional commits, e.g.
feat(server): add CSP report-only and framing headers to web responses. - Do NOT push or open a PR unless instructed.
Steps
Step 1: Add framing + hardening headers to web responses
In apps/server/src/static/web.ts, extend the header objects returned by
getFallbackResponseHeaders so that every non-null branch (the / branch and
the noindex/public branch) also includes:
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
Keep the existing Content-Type and X-Robots-Tag values unchanged. Prefer
adding a small shared constant (e.g. const BASE_SECURITY_HEADERS = { ... })
and spreading it into both branches, so the two paths cannot drift.
Verify: pnpm --filter @reactive-resume/server typecheck → exit 0.
Step 2: Add a report-only CSP header to web responses
Add to the same shared header set:
"Content-Security-Policy-Report-Only":
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
Use Report-Only (not the enforcing header) so nothing breaks on rollout.
'unsafe-inline' for style/script is intentional for the first pass — the Vite
bundle and inline theme script need it; tightening to nonces is future work.
frame-ancestors 'none' is the CSP-level equivalent of X-Frame-Options: DENY.
Verify: pnpm --filter @reactive-resume/server typecheck → exit 0.
Step 3: Remove the unconditional CORS header from uploads
In apps/server/src/static/uploads.ts, delete the line:
headers.set("Access-Control-Allow-Origin", env.APP_URL);
Leave every other header untouched. If, after removal, env is no longer
referenced anywhere in the file, remove its now-unused import (check with the
grep in Done criteria); if env is still used, keep the import.
Verify: pnpm --filter @reactive-resume/server typecheck → exit 0
(no unused-import or undefined-symbol errors).
Step 4: Update/extend tests
- In
web.test.ts: add assertions that a GET to/and to a public resume path returnsX-Frame-Options: DENYand aContent-Security-Policy-Report-Onlyheader. Follow the existing test's request/response style in that file. - In
uploads.test.ts: if an existing test asserts the presence ofAccess-Control-Allow-Origin, change it to assert the header is absent (response.headers.get("Access-Control-Allow-Origin")isnull). Keep the assertions forCross-Origin-Resource-Policyand the others.
Verify: pnpm --filter @reactive-resume/server test -- static → all pass.
Test plan
- Extend
web.test.tswith two cases:/and a public-resume path each carry the new framing + CSP-report-only headers. - Update
uploads.test.tsso the ACAO header is asserted absent (and the other security headers still present). - Verification:
pnpm --filter @reactive-resume/server test -- static→ all pass, including the new/updated assertions.
Done criteria
Machine-checkable. ALL must hold:
pnpm --filter @reactive-resume/server typecheckexits 0pnpm --filter @reactive-resume/server test -- staticexits 0grep -n "X-Frame-Options" apps/server/src/static/web.tsreturns a matchgrep -n "Content-Security-Policy-Report-Only" apps/server/src/static/web.tsreturns a matchgrep -n "Access-Control-Allow-Origin" apps/server/src/static/uploads.tsreturns no matchesgit status --porcelainlists only the four in-scope source/test files andplans/README.mdplans/README.mdstatus row for 002 updated
STOP conditions
Stop and report back if:
web.ts/uploads.tshave drifted from the excerpts above.- Removing the ACAO header breaks an existing test that documents a legitimate
cross-origin consumer of
/uploads/*you were unaware of (read the test's intent before assuming it's stale) — report it instead of forcing the change. - You are tempted to ship an enforcing CSP (not report-only) — that is out of scope and can break the app; stop and confirm.
Maintenance notes
- The CSP is report-only. Follow-up (separate plan): wire a report endpoint or
read browser console CSP reports, confirm the app fully satisfies the policy,
then promote
Content-Security-Policy-Report-Only→Content-Security-Policyand drop'unsafe-inline'in favor of nonces where feasible. - If a future feature legitimately needs the app embeddable (e.g. an official
embed widget),
frame-ancestors/X-Frame-Optionsmust be relaxed for that route only, not globally. - Reviewer should confirm the header set is shared between both
web.tsbranches (no drift) and that no API/JSON responses accidentally inherit the HTML CSP.