Skip to content

fix(share): response-header discipline — default no-store + HSTS/XFO#370

Merged
graydawnc merged 1 commit into
mainfrom
feat/share-headers-discipline
Jun 8, 2026
Merged

fix(share): response-header discipline — default no-store + HSTS/XFO#370
graydawnc merged 1 commit into
mainfrom
feat/share-headers-discipline

Conversation

@graydawnc

@graydawnc graydawnc commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

What

Two header-layer hardening fixes from the post-launch audit pass.

Backend middleware default Cache-Control

Previously only mutation responses (POST/PUT/PATCH/DELETE) got Cache-Control: no-store when the handler set nothing. Authenticated GET endpoints (/api/me, /api/me/shares, /api/admin/audit) each set their own header — but if ANY future GET forgot the line, the response would be served without cache protection and a shared proxy could fan out one user's private body to other readers.

Default ALL /api/* responses (regardless of method) to no-store when the handler doesn't set its own value.

flowchart TD
    REQ["Request to /api/*"] --> H{"Handler set Cache-Control?"}
    H -->|yes| KEEP["Keep handler value<br/>e.g. public + max-age=30"]
    H -->|no| METHOD{"Method?"}

    METHOD -->|"POST/PUT/PATCH/DELETE (before + after)"| MUT["Set no-store"]
    METHOD -->|"GET — before"| RAW["Pass through<br/>no Cache-Control<br/>relies on every handler"]
    METHOD -->|"GET — after"| ALL["Set no-store as default"]

    style RAW fill:#fee,stroke:#c00
    style ALL fill:#efe,stroke:#0a0
    style MUT fill:#efe,stroke:#0a0
Loading

The three public-read endpoints (/api/snapshots/:id, /api/og/:id.png, /api/profiles/:handle) already set their own public, max-age=30, must-revalidate on success; tombstone branches set no-store explicitly. All override the new default cleanly.

share-web global headers

public/_headers had per-route CSP with frame-ancestors 'none' on /s/*, /@*, /me, /sign-in — but the root /, /404, and any future route had NO clickjacking protection. HSTS was entirely absent so a first-visit downgrade attack was viable on a fresh visitor.

Add globally:

Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Frame-Options: DENY

X-Frame-Options is belt-and-braces with the per-route frame-ancestors 'none' (some older clients honour XFO but not CSP); HSTS is the real upgrade — 2-year window, includes subdomains, eligible for the browser preload list.

Verification

  • middleware: regression test asserting no-store default on /api/* GET when handler sets nothing

pnpm --filter @spool/share-backend test → 175/175 green.

Risk

  • The no-store default is additive — existing handlers that set their own value override it. No behavior change for /api/snapshots/*, /api/og/*, /api/profiles/*, /api/me*, /api/admin/*, /api/health.
  • HSTS: 2-year window applies to spool.pro and subdomains. Cloudflare Pages enforces HTTPS already. No HTTP-only subdomain exists.
  • XFO: spool.pro has no iframe-embedding consumers at v0.5; per-route frame-ancestors 'none' already in force.

Submitted by @graydawnc.

Two header-layer hardening fixes from the post-launch audit pass.

Backend middleware default Cache-Control:
  Previously only mutation responses (POST/PUT/PATCH/DELETE) got
  Cache-Control: no-store when the handler set nothing. Authenticated
  GET endpoints (/api/me, /api/me/shares, /api/admin/audit) each set
  their own header — but if ANY future GET forgot the line, the
  response would be served without cache protection and a shared
  proxy could fan out one user's private body to other readers.

  Default ALL /api/* responses (regardless of method) to no-store
  when the handler doesn't set its own value. The three public-read
  endpoints (/api/snapshots/:id, /api/og/:id.png, /api/profiles/:handle)
  already set their own "public, max-age=30, must-revalidate" on
  success and the tombstone branch sets no-store — both override
  the new default.

share-web global headers:
  public/_headers had per-route CSP with frame-ancestors 'none' on
  /s/*, /@*, /me, /sign-in — but the root /, 404, and any future
  route had NO clickjacking protection. HSTS was entirely absent so
  a first-visit downgrade attack was viable on a fresh visitor.

  Add globally:
    Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
    X-Frame-Options: DENY

  X-Frame-Options is belt-and-braces with the per-route
  frame-ancestors 'none' (some older clients honour XFO but not CSP);
  HSTS is the real upgrade.

Tests:
  - middleware: assert no-store default on /api/* GET when handler
    sets nothing (the regression test for the prior gap)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@graydawnc
graydawnc added this pull request to the merge queue Jun 8, 2026
Merged via the queue into main with commit 2b59e40 Jun 8, 2026
6 checks passed
@graydawnc
graydawnc deleted the feat/share-headers-discipline branch June 8, 2026 08:11
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