feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF #122

Merged
julien merged 1 commits from feat/portal-bff/security-helmet-cors-csrf into main 2026-05-13 20:50:45 +02:00
Owner

Summary

Phase-2 security baseline that the main.ts placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together.

Helmet on the BFF

helmet() with three overrides matching our specific shape:

  • HSTS only in production — dev runs on plain HTTP, HSTS is just noise.
  • crossOriginResourcePolicy: 'cross-origin' — the SPA on its own origin reads JSON from the BFF; the default same-origin would block it.
  • CSP disabled in non-production — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy connect-src violations in browser devtools that we don't need.

Everything else is Helmet defaults: X-Frame-Options=SAMEORIGIN, X-Content-Type-Options=nosniff, Referrer-Policy=no-referrer, X-Powered-By removed, etc.

CORS allowlist, env-driven

CORS_ALLOWED_ORIGINS env (comma-separated) is now mandatory at boot. The BFF refuses to start without it via readCorsAllowlist() — same boot-time validator family as assertSessionSecret etc. The previous hardcoded http://localhost:4200 fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. X-CSRF-Token is now in the allowed headers.

Double-submit CSRF

  • BFF mints a 256-bit csrfToken at session creation (/auth/callback), stored on req.session.csrfToken and mirrored to a JS-readable cookie (__Host-portal_csrf prod / portal_csrf dev). The cookie is the SPA's read-only view; the server-side session is the source of truth.
  • createCsrfMiddleware (mounted after the session middleware in main.ts) compares the X-CSRF-Token header with req.session.csrfToken using crypto.timingSafeEqual. Skips:
    • safe methods (GET / HEAD / OPTIONS),
    • anonymous requests (no req.session.user),
    • /api/auth/login and /api/auth/callback (those mint the token themselves).
  • Mismatch → 403 {"error":"csrf"} with a structured Pino warn.
  • SPA's csrfInterceptor reads the cookie via document.cookie and copies its value into X-CSRF-Token on every mutating BFF request. The header is omitted on GET / HEAD / OPTIONS (BFF skips them anyway) and on non-BFF origins.
  • Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie.

Notable choices

Session-bound double-submit, not pure cookie-vs-header. A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place.

No CSRF for anonymous mutating routes (v1). None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with saveUninitialized: false on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship.

SameSite=Lax, not Strict, on the CSRF cookie. Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; SameSite=Lax is a belt-and-braces layer.

csrfInterceptor runs after bffCredentialsInterceptor and before bffUnauthorizedInterceptor in the chain. Order: credentials first (set withCredentials), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises.

CORS_ALLOWED_ORIGINS has no localhost fallback. I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit.

Out of scope (next PRs)

  • Rate limiting + structured error filter (still in the phase-2 to-do).
  • CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving).
  • CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations).

Test plan

  • pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell clean env → 177 + 28 + 34 = 239/239 pass (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage).
  • pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell → clean.
  • CI clean-env repro (lesson from prior PRs): every env var unset (including new CORS_ALLOWED_ORIGINS) → tests still pass. The BFF refuses to boot without CORS_ALLOWED_ORIGINS, which is the intended behaviour.
  • Prettier-clean.
  • Manual smoke against running BFF:
    • Sign in → __Host-portal_csrf (prod) / portal_csrf (dev) cookie set, value matches audit.events.payload->>actorIdHash-style traceability via req.session.csrfToken in Redis.
    • Hit a future POST route from the SPA → request carries X-CSRF-Token, BFF accepts.
    • Forge a POST without the header (curl) → 403 {"error":"csrf"}.
    • Sign out → both cookies cleared.
## Summary Phase-2 security baseline that the `main.ts` placeholder note has been advertising since the auth/session work began. Three independent middlewares + their SPA counterparts, all mounted in a single PR because they only become meaningful together. ### Helmet on the BFF `helmet()` with three overrides matching our specific shape: - **HSTS only in production** — dev runs on plain HTTP, HSTS is just noise. - **`crossOriginResourcePolicy: 'cross-origin'`** — the SPA on its own origin reads JSON from the BFF; the default `same-origin` would block it. - **CSP disabled in non-production** — the BFF doesn't render HTML, so CSP on JSON responses is mostly inert, but Helmet's default CSP triggers noisy `connect-src` violations in browser devtools that we don't need. Everything else is Helmet defaults: `X-Frame-Options=SAMEORIGIN`, `X-Content-Type-Options=nosniff`, `Referrer-Policy=no-referrer`, `X-Powered-By` removed, etc. ### CORS allowlist, env-driven `CORS_ALLOWED_ORIGINS` env (comma-separated) is now **mandatory** at boot. The BFF refuses to start without it via `readCorsAllowlist()` — same boot-time validator family as `assertSessionSecret` etc. The previous hardcoded `http://localhost:4200` fallback is gone; getting CORS wrong silently is the kind of "works in dev, breaks in prod" trap the validator is specifically designed to catch. `X-CSRF-Token` is now in the allowed headers. ### Double-submit CSRF - BFF mints a 256-bit `csrfToken` at session creation (`/auth/callback`), stored on `req.session.csrfToken` and mirrored to a JS-readable cookie (`__Host-portal_csrf` prod / `portal_csrf` dev). The cookie is the SPA's read-only view; the server-side session is the source of truth. - `createCsrfMiddleware` (mounted after the session middleware in `main.ts`) compares the `X-CSRF-Token` header with `req.session.csrfToken` using `crypto.timingSafeEqual`. Skips: - safe methods (`GET / HEAD / OPTIONS`), - anonymous requests (no `req.session.user`), - `/api/auth/login` and `/api/auth/callback` (those mint the token themselves). - Mismatch → `403 {"error":"csrf"}` with a structured Pino warn. - SPA's `csrfInterceptor` reads the cookie via `document.cookie` and copies its value into `X-CSRF-Token` on every mutating BFF request. The header is omitted on `GET / HEAD / OPTIONS` (BFF skips them anyway) and on non-BFF origins. - Logout and the absolute-timeout middleware both clear the CSRF cookie alongside the session cookie. ## Notable choices **Session-bound double-submit, not pure cookie-vs-header.** A naive "compare cookie with header" check is defeated when an attacker can plant a cookie (subdomain takeover, etc.). Comparing the header to the server-side session-stored token instead means the attacker would also need to be the authenticated user — which is what CSRF defense is supposed to prevent in the first place. **No CSRF for anonymous mutating routes (v1).** None exist today; we don't have an unauthenticated POST endpoint anywhere. Generating a CSRF token for anonymous sessions would conflict with `saveUninitialized: false` on express-session and add complexity we don't need yet. Anonymous public-form CSRF defenses (site-key, captcha) land if and when those routes ship. **`SameSite=Lax`, not `Strict`, on the CSRF cookie.** Matches the session cookie's policy so the two travel together on the SPA→BFF cross-origin same-site fetch (different ports = different origin, same registrable domain). The double-submit pattern is what gives the protection; `SameSite=Lax` is a belt-and-braces layer. **`csrfInterceptor` runs after `bffCredentialsInterceptor` and before `bffUnauthorizedInterceptor` in the chain.** Order: credentials first (set `withCredentials`), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises. **`CORS_ALLOWED_ORIGINS` has no localhost fallback.** I considered keeping the fallback for ergonomics but it makes the BFF silently misconfigured if someone forgets the env. The error message points straight at the file to edit. ## Out of scope (next PRs) - Rate limiting + structured error filter (still in the phase-2 to-do). - CSP fine-tuning when we have actual HTML pages (portal-shell + portal-admin static serving). - CSRF token rotation on idle-extension (today the token lives the session's lifetime; refreshing on each request would invalidate in-flight mutations). ## Test plan - [x] `pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shell` clean env → **177 + 28 + 34 = 239/239 pass** (was 144 + 19 + 34 = 197 before; +42 specs across CSRF middleware, CSRF cookie helpers, CORS allowlist parser, csrfInterceptor, and extended auth.controller / absolute-timeout coverage). - [x] `pnpm nx run-many -t lint build --projects=portal-bff,feature-auth,portal-shell` → clean. - [x] **CI clean-env repro** (lesson from prior PRs): every env var unset (including new `CORS_ALLOWED_ORIGINS`) → tests still pass. The BFF refuses to boot without `CORS_ALLOWED_ORIGINS`, which is the intended behaviour. - [x] Prettier-clean. - [ ] Manual smoke against running BFF: - [x] Sign in → `__Host-portal_csrf` (prod) / `portal_csrf` (dev) cookie set, value matches `audit.events.payload->>actorIdHash`-style traceability via `req.session.csrfToken` in Redis. - [ ] Hit a future POST route from the SPA → request carries `X-CSRF-Token`, BFF accepts. - [x] Forge a POST without the header (curl) → 403 `{"error":"csrf"}`. - [x] Sign out → both cookies cleared.
julien added 1 commit 2026-05-13 20:45:18 +02:00
feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF
CI / commits (pull_request) Successful in 2m0s
CI / scan (pull_request) Successful in 2m12s
CI / check (pull_request) Successful in 3m40s
CI / a11y (pull_request) Successful in 1m22s
CI / perf (pull_request) Successful in 2m50s
116e0468b0
phase-2 security baseline the main.ts placeholder has been
advertising since the auth track started. three independent
middlewares + their spa counterparts shipped together because
they only become meaningful as a set.

helmet on the bff:
  helmet() with three overrides matching our specific shape:
   - HSTS only in production (dev runs on plain http)
   - crossOriginResourcePolicy: 'cross-origin' (SPA needs to read
     bff json from a different origin)
   - CSP disabled in non-prod (bff doesn't render html; default CSP
     just adds devtools noise)

cors allowlist env-driven:
  CORS_ALLOWED_ORIGINS is now mandatory at boot. readCorsAllowlist()
  parses + validates (http(s) only, bare origins, no path/query),
  same boot-time validator family as assertSessionSecret etc. the
  previous hardcoded localhost fallback is removed — silent
  cors misconfiguration is exactly the "works in dev breaks in
  prod" trap this guard catches.

double-submit CSRF (session-bound):
  - bff mints a 256-bit csrfToken at /auth/callback, stored on
    req.session.csrfToken AND mirrored to a js-readable cookie
    (__Host-portal_csrf prod / portal_csrf dev). cookie is the
    spa's read-only view; session is the source of truth.
  - createCsrfMiddleware compares X-CSRF-Token header to the
    session token via crypto.timingSafeEqual. skips: safe methods,
    anonymous requests, /auth/login + /auth/callback.
  - mismatch → 403 {"error":"csrf"} + structured pino warn.
  - spa csrfInterceptor reads the cookie via document.cookie and
    copies into X-CSRF-Token on mutating bff requests. omitted on
    GET/HEAD/OPTIONS and on non-bff origins.
  - logout + absolute-timeout middleware now clear the csrf cookie
    alongside the session cookie.

notable choices:
  - session-bound double-submit, not pure cookie-vs-header. an
    attacker who plants a cookie via subdomain takeover would
    still need to know the server-side session token. tying the
    check to req.session.csrfToken instead of the cookie itself
    is what makes this stronger than the canonical recipe.
  - csrfInterceptor sits between bffCredentialsInterceptor (which
    sets withCredentials) and bffUnauthorizedInterceptor (which
    handles 401s). forward order, no surprises.
  - no CSRF for anonymous mutating routes in v1 — none exist today
    and generating tokens for anonymous sessions conflicts with
    express-session's saveUninitialized: false.

specs:
  - 239 / 239 pass under the clean-env repro (env -u every config
    var including the new CORS_ALLOWED_ORIGINS).
  - +42 specs across check-cors-allowlist, csrf-cookie, csrf
    middleware, csrf interceptor, and extended auth.controller /
    absolute-timeout coverage for the csrf cookie mirror/clear.

out of scope, landing in follow-ups:
  - rate limiting + structured error filter (remaining phase-2
    todos)
  - CSP fine-tuning for the portal-shell + portal-admin static
    bundles
  - csrf token rotation on idle-extension (current token lives
    the session lifetime)
julien merged commit 5bbe2304ff into main 2026-05-13 20:50:45 +02:00
julien deleted branch feat/portal-bff/security-helmet-cors-csrf 2026-05-13 20:50:46 +02:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: julien/apf_portal#122