feat: API hardening — CSRF guard, rate limit, security headers, opt-in basic auth #10

Merged
pregno merged 5 commits from feat/api-hardening into main 2026-06-13 01:36:11 +02:00
Owner

Closes the remaining security findings from the repo-wide review:

  1. CSRF guard. Body-less mutating routes (position close, alert dismiss) were CORS "simple requests" — any web page could fire them cross-origin with mode: "no-cors". An onRequest hook now 403s any non-GET/HEAD/OPTIONS request missing x-requested-with, forcing a preflight that cross-origin pages fail. The web client sends the header from a shared const on all 11 mutating fetches (all mutations go through client.ts — verified).

  2. Rate limiting. @fastify/rate-limit@11 at 300 req/min per IP — actual dashboard load is ~10 req/min plus one long-lived SSE request, so ~30x headroom. Found and fixed a registration gotcha: a non-awaited register(rateLimit) doesn't cover routes declared synchronously after it (the 429 test caught this) — routes now register in a child plugin.

  3. Security headers at nginx: X-Frame-Options DENY, X-Content-Type-Options nosniff, Referrer-Policy no-referrer, and a CSP verified against the production build (no inline scripts; 'unsafe-inline' only for style-src, required by React/recharts inline style attributes; everything else 'self'). Could not run nginx -t locally (no docker daemon) — worth a smoke check on first deploy.

  4. Opt-in basic auth overlay. docker compose -f docker-compose.deploy.yml -f docker-compose.auth.yml up mounts ./htpasswd + an auth-enabled nginx config over the baked one, fronting both the SPA and /api/. The default deploy without the overlay is byte-for-byte unaffected. htpasswd creation documented in the overlay header; htpasswd gitignored. Combined with DEPLOY_BIND, this closes the unauthenticated-API finding.

Tests: 58 files / 314 passing (new: 403-without-header, GET-unaffected, 429-past-cap), typecheck clean. Note: merge after the open client.ts PRs if possible — edits there were kept one-line-per-call to minimize conflicts.

Closes the remaining security findings from the repo-wide review: 1. **CSRF guard.** Body-less mutating routes (position close, alert dismiss) were CORS "simple requests" — any web page could fire them cross-origin with `mode: "no-cors"`. An `onRequest` hook now 403s any non-GET/HEAD/OPTIONS request missing `x-requested-with`, forcing a preflight that cross-origin pages fail. The web client sends the header from a shared const on all 11 mutating fetches (all mutations go through client.ts — verified). 2. **Rate limiting.** `@fastify/rate-limit@11` at 300 req/min per IP — actual dashboard load is ~10 req/min plus one long-lived SSE request, so ~30x headroom. Found and fixed a registration gotcha: a non-awaited `register(rateLimit)` doesn't cover routes declared synchronously after it (the 429 test caught this) — routes now register in a child plugin. 3. **Security headers at nginx:** `X-Frame-Options DENY`, `X-Content-Type-Options nosniff`, `Referrer-Policy no-referrer`, and a CSP verified against the production build (no inline scripts; `'unsafe-inline'` only for style-src, required by React/recharts inline style attributes; everything else `'self'`). Could not run `nginx -t` locally (no docker daemon) — worth a smoke check on first deploy. 4. **Opt-in basic auth overlay.** `docker compose -f docker-compose.deploy.yml -f docker-compose.auth.yml up` mounts `./htpasswd` + an auth-enabled nginx config over the baked one, fronting both the SPA and `/api/`. The default deploy without the overlay is byte-for-byte unaffected. htpasswd creation documented in the overlay header; `htpasswd` gitignored. Combined with `DEPLOY_BIND`, this closes the unauthenticated-API finding. Tests: 58 files / 314 passing (new: 403-without-header, GET-unaffected, 429-past-cap), typecheck clean. Note: merge after the open client.ts PRs if possible — edits there were kept one-line-per-call to minimize conflicts.
Body-less POSTs (position close, alert dismiss/dismiss-all) are CORS
"simple requests": any web page can fire them cross-origin and the
side effect lands even though the response is opaque. Requiring a
custom header on every non-GET/HEAD/OPTIONS request forces a CORS
preflight, which a cross-origin page fails against our allowlist.

- onRequest hook in buildApp(): 403 { error } when the header is absent
- web client sends x-requested-with on all mutating fetches (shared XRW const)
- tests: 403 without header, GET unaffected; existing mutating injects updated
Register @fastify/rate-limit@11 (Fastify v5 compatible) globally in
buildApp(). 300/min is far above normal usage: the dashboard polls
alerts (2x/30s) and positions (1x/15s) for ~10 req/min, and
/api/stream is a single long-lived SSE request, so only abusive
clients are throttled.

Routes move into a child plugin so they register after the limiter's
onRoute hook is installed; without that the non-awaited register would
leave every route uncovered. Test hammers /health past the cap and
asserts 429.
X-Frame-Options DENY, X-Content-Type-Options nosniff,
Referrer-Policy no-referrer, and a CSP scoped to what the built SPA
actually needs.

Verified against the production build (pnpm --filter @pmsd/web build):
- index.html has no inline scripts or styles, only external
  /assets/*.js and /assets/*.css -> script-src 'self', style-src 'self'
- React/recharts set inline style attributes at runtime ->
  'unsafe-inline' added to style-src (covers attributes and any
  runtime-injected style elements)
- fonts are self-hosted woff2 under /assets -> font-src 'self'
- no external origins referenced by the built JS/CSS at runtime
- fetch + the /api/stream EventSource are same-origin -> connect-src 'self'
- img-src 'self' data: as a conservative allowance for data-URI images
- object-src 'none', base-uri 'self', frame-ancestors 'none' lock down
  legacy embedding vectors

Headers use 'always' and sit at server level; neither location block
declares its own add_header, so nginx inheritance applies them to both
the SPA and proxied /api/ responses. Could not run nginx -t locally
(no docker daemon); the directives are stock add_header syntax.
docker-compose.auth.yml, composed on top of docker-compose.deploy.yml,
mounts ./htpasswd and an auth-enabled nginx config
(apps/web/nginx-auth.conf, a copy of nginx.conf plus
auth_basic/auth_basic_user_file on the server block) over the baked
config. Auth therefore fronts both the SPA and the proxied /api/.

The default deploy is byte-for-byte unaffected: docker-compose.deploy.yml
is untouched and the overlay only adds volumes to the web service when
explicitly composed. htpasswd creation is documented in the overlay
header; the file is git-ignored. Both compose invocations validated
with 'docker compose ... config'.
pregno merged commit 2dfc5d921f into main 2026-06-13 01:36:11 +02:00
pregno deleted branch feat/api-hardening 2026-06-13 01:36:11 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pregno/polymarket-screener!10
No description provided.