feat: API hardening — CSRF guard, rate limit, security headers, opt-in basic auth #10
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/api-hardening"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes the remaining security findings from the repo-wide review:
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". AnonRequesthook now 403s any non-GET/HEAD/OPTIONS request missingx-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).Rate limiting.
@fastify/rate-limit@11at 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-awaitedregister(rateLimit)doesn't cover routes declared synchronously after it (the 429 test caught this) — routes now register in a child plugin.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 runnginx -tlocally (no docker daemon) — worth a smoke check on first deploy.Opt-in basic auth overlay.
docker compose -f docker-compose.deploy.yml -f docker-compose.auth.yml upmounts./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;htpasswdgitignored. Combined withDEPLOY_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