feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF #122
Reference in New Issue
Block a user
Delete Branch "feat/portal-bff/security-helmet-cors-csrf"
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?
Summary
Phase-2 security baseline that the
main.tsplaceholder 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:crossOriginResourcePolicy: 'cross-origin'— the SPA on its own origin reads JSON from the BFF; the defaultsame-originwould block it.connect-srcviolations 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-Byremoved, etc.CORS allowlist, env-driven
CORS_ALLOWED_ORIGINSenv (comma-separated) is now mandatory at boot. The BFF refuses to start without it viareadCorsAllowlist()— same boot-time validator family asassertSessionSecretetc. The previous hardcodedhttp://localhost:4200fallback 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-Tokenis now in the allowed headers.Double-submit CSRF
csrfTokenat session creation (/auth/callback), stored onreq.session.csrfTokenand mirrored to a JS-readable cookie (__Host-portal_csrfprod /portal_csrfdev). The cookie is the SPA's read-only view; the server-side session is the source of truth.createCsrfMiddleware(mounted after the session middleware inmain.ts) compares theX-CSRF-Tokenheader withreq.session.csrfTokenusingcrypto.timingSafeEqual. Skips:GET / HEAD / OPTIONS),req.session.user),/api/auth/loginand/api/auth/callback(those mint the token themselves).403 {"error":"csrf"}with a structured Pino warn.csrfInterceptorreads the cookie viadocument.cookieand copies its value intoX-CSRF-Tokenon every mutating BFF request. The header is omitted onGET / HEAD / OPTIONS(BFF skips them anyway) and on non-BFF origins.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: falseon 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, notStrict, 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=Laxis a belt-and-braces layer.csrfInterceptorruns afterbffCredentialsInterceptorand beforebffUnauthorizedInterceptorin the chain. Order: credentials first (setwithCredentials), then CSRF (set the header), then unauthorized handling (catch 401s). Forward order, no surprises.CORS_ALLOWED_ORIGINShas 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)
Test plan
pnpm nx run-many -t test --projects=portal-bff,feature-auth,portal-shellclean 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.CORS_ALLOWED_ORIGINS) → tests still pass. The BFF refuses to boot withoutCORS_ALLOWED_ORIGINS, which is the intended behaviour.__Host-portal_csrf(prod) /portal_csrf(dev) cookie set, value matchesaudit.events.payload->>actorIdHash-style traceability viareq.session.csrfTokenin Redis.X-CSRF-Token, BFF accepts.{"error":"csrf"}.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)