Documents the security middleware stack shipped across the five most recent BFF PRs (#115, #117, #120, #122, #123) as a single MADR ADR. Today the rationale lives in code comments and PR descriptions; the next contributor reaching for `csurf`, cookie-only CSRF, or a hardcoded localhost CORS fallback didn't have one place to read why those are wrong here. Contents: - Response envelope: { error: { code, message, traceId } }, single contract across Nest's filter and raw middlewares via the errorResponse() helper. Status-code mapping documented. 500s never leak the underlying exception. - CSRF session-bound double-submit, not cookie-vs-header. The cookie is the SPA's read-only mirror; the source of truth is req.session.csrfToken — subdomain-takeover injection can't bypass. - CORS allowlist env-driven, mandatory, no fallback. Catches the "works in dev breaks in prod" misconfiguration at boot. - Rate limit: sessionID-then-IP bucket key, 10/min auth + 120/min general, /api/health skipped, in-memory v1 store. - Helmet defaults + three overrides (HSTS prod-only, COR cross-origin, CSP prod-only) with code-anchored justifications. Considered options mirror the actual debate — each rejected alternative has its "Bad, because" line so a reader sees why we didn't go that way. Each Decision Outcome line cross-references the file that enforces it. Scope is the IMPLEMENTATION-level baseline. The STRATEGIC baseline (ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off) remains paused per CLAUDE.md. When it lands it confirms or supersedes pieces of 0021; the ADR is explicit about which choices are tactical. Index entry in docs/decisions/README.md updated in the same commit per the project convention. CLAUDE.md §"Repository status" still says the workspace is "not yet bootstrapped" and refs ADRs up to 0020 — that paragraph is stale (22 ADRs in place, project fully scaffolded). Worth a small dedicated docs PR; out of scope here.
16 KiB
status, date, decision-makers, tags
| status | date | decision-makers | tags | ||
|---|---|---|---|---|---|
| accepted | 2026-05-13 | R&D Lead |
|
Phase-2 security baseline — helmet, CORS allowlist, double-submit CSRF, rate limiting, structured error envelope
Context and Problem Statement
ADR-0009 noted that the BFF in v1 ships with a minimal CORS allowlist and that the security-hardening stack — helmet, real allowlist, CSRF, rate limiting, structured error filter — would land "with the phase-2 security ADR". That phase-2 security ADR (the baseline, in the sense of "which ASVS level does the project target, which adjacent frameworks apply") is paused until the RSSI clarifies the reference level and the regulatory perimeter (HDS, GDPR, possibly PCI DSS / NIS 2).
In the meantime, the BFF accumulated five PRs that landed concrete security middleware (#115 absolute-timeout, #117 SPA route guard + credentials interceptor, #122 helmet + CORS + CSRF, #123 rate-limit + structured error filter). Each PR captured its own rationale, but the choices live in code comments and PR descriptions — there is no single piece of documentation that says "this is why CSRF is session-bound", "this is the response envelope contract", "this is the rate-limit bucket strategy". A future contributor reaching for csurf or cookie-only CSRF because they don't know better is the kind of slip this ADR is meant to prevent.
This ADR records the concrete implementation choices made in those five PRs. It is deliberately one notch below the strategic baseline (ASVS level, regulatory framing, RSSI sign-off) — that one lands separately when the RSSI input arrives, and this one becomes its v1 reference for "what the BFF actually does today".
Decision Drivers
- Capture rationale while the memory is fresh — most of these choices are non-obvious without their context.
- Provide a single landing point for a security review (internal or external).
- Single response contract for every error so the SPA has one shape to parse and so 5xx never leak internals.
- Defense in depth: each middleware addresses a distinct attack class, and removing one shouldn't open the others.
- Sensible-by-default in production, fail-loudly-on-misconfiguration at boot.
Considered Options
Response envelope on errors
{ error: { code, message, traceId } }shared by Nest's exception filter and raw middleware (chosen).- Per-route ad-hoc shapes (the pre-PR baseline).
- Nest's default
{ statusCode, message, error }at the top level.
CSRF strategy
- Session-bound double-submit (server-side session token + JS-readable cookie +
X-CSRF-Tokenheader, comparison against the session token, not the cookie) (chosen). - Pure cookie-vs-header double-submit (no server-side storage).
- Synchronizer token pattern (server-side token, rendered into HTML — N/A, the BFF doesn't render HTML).
csurfpackage.
CORS allowlist
- Mandatory env-driven allowlist via
CORS_ALLOWED_ORIGINS, parsed and validated at boot, no fallback (chosen). - Hardcoded localhost fallback with env override.
- Open
*policy gated bycredentials: false.
Rate limiting bucket key
sessionIDwhen authenticated, remote IP otherwise (chosen).- IP only.
- User id (Entra
oid) when authenticated.
Rate limiting tiers
- Two tiers via dynamic
maxper request: 10/min strict on/auth/login+/auth/callback, 120/min general (chosen). - One uniform limit.
- Per-route limits via separate
rateLimit()instances.
Rate limiting store
- In-memory (single-instance default of
express-rate-limit) (chosen for v1). - Redis-backed (
rate-limit-redis).
Helmet CSP
helmet()defaults in production,contentSecurityPolicy: falsein development (chosen).- Custom CSP tailored to the BFF.
- Disabled everywhere (Nest's default has no CSP either).
Decision Outcome
The BFF runs five middleware in this order, mounted in apps/portal-bff/src/main.ts:
StructuredErrorFilter(registered viaapp.useGlobalFiltersat the top ofbootstrap()) — normalises every NestJS exception to{ error: { code, message, traceId } }. Source of truth for the response envelope.helmet()— security headers. Three overrides:hstsonly in production (dev runs on plain HTTP),crossOriginResourcePolicy: 'cross-origin'(SPA on its own origin reads JSON from the BFF),contentSecurityPolicy: falseoutside production (the BFF returns JSON; the default CSP triggers noisyconnect-srcviolations in devtools without protecting anything we care about on JSON responses).
- CORS allowlist —
CORS_ALLOWED_ORIGINSenv-driven, mandatory at boot. Parsed + validated (http(s):only, bare origins, no path/query). No localhost fallback.X-CSRF-Tokendeclared inallowedHeadersalongsideContent-Type / Accept / Authorization / traceparent / tracestate. - Rate limiting (
express-rate-limit) — dynamicmaxper request: 10/min on the auth-flow entry routes, 120/min everywhere else,/api/healthskipped. Bucket key = session id whenreq.session.useris set, else remote IP. In-memory store; v2 swaps forrate-limit-rediswhen we scale BFF horizontally. Response = same envelope as the filter (code: 'rate_limited'). - CSRF middleware (custom; not
csurf, which is unmaintained) — double-submit, session-bound: token minted at session creation in/auth/callback, stored onreq.session.csrfTokenAND mirrored to a JS-readable cookie (__Host-portal_csrfprod /portal_csrfdev). Request middleware compares theX-CSRF-Tokenheader against the session-stored token withcrypto.timingSafeEqual. Skips safe methods (GET / HEAD / OPTIONS), unauthenticated requests, and the auth entry routes that mint the token (/api/auth/login,/api/auth/callback).
The order is intentional:
- The filter is registered first so it catches errors from any subsequent middleware initialisation.
- helmet → CORS → … so headers are set on every response, including pre-flight
OPTIONS. - Rate limit → CSRF: rate-limit rejects without paying the CSRF comparison cost; on an already-blocked client we don't burn cycles.
- Both raw middlewares (rate-limit, CSRF) use the shared
errorResponse()helper exported byStructuredErrorFilterso the wire format is identical whether the response came through the filter or through a middleware short-circuit.
Response envelope contract
{
"error": {
"code": "csrf",
"message": "CSRF token missing or invalid",
"traceId": "abc123…"
}
}
codeis the SPA's switch-on key. Stable strings:unauthenticated,forbidden,not_found,csrf,rate_limited,bad_request,internal,service_unavailable, and any custom code anHttpExceptiondeclares via its response object (new UnauthorizedException({ code: 'unauthenticated', message: '…' })).messageis safe, human-readable text. 5xx exceptions never put their internal message in the response — the full exception goes to the Pinoerrorlog line via theerr: exceptionfield; the response body says "Internal server error".traceIdis the active OTel trace id (ornull). Lets the user paste it into a support ticket and have ops correlate against Jaeger + audit rows + Pino lines in one query.
Configuration (env-driven)
| Variable | Required | Purpose |
|---|---|---|
CORS_ALLOWED_ORIGINS |
yes | Comma-separated scheme://host[:port] list. BFF refuses to boot if absent / malformed. |
RATE_LIMIT_PER_MINUTE |
no — default 120 | General bucket ceiling. |
RATE_LIMIT_AUTH_PER_MINUTE |
no — default 10 | Stricter bucket for /auth/login + /auth/callback. |
LOG_USER_ID_SALT, SESSION_SECRET, SESSION_ENCRYPTION_KEY were promoted to mandatory at boot in earlier PRs; this ADR doesn't re-state them.
Consequences
- Good, because every error response has the same shape; the SPA writes one branch to parse it.
- Good, because 5xx exception details never leak — the secure-by-default for unhandled errors.
- Good, because the CSRF cookie isn't trusted: an attacker who plants a cookie via subdomain takeover would still need the session-stored token, which is also the authenticated identity. Stronger than the canonical double-submit recipe.
- Good, because rate-limit buckets are per-account when authenticated; rotating sessions doesn't dodge the limit.
- Good, because boot-time validators on
CORS_ALLOWED_ORIGINScatch the misconfiguration in dev / staging instead of in production. - Bad, because in-memory rate-limit store means a multi-instance BFF would have per-instance counters. Mitigated by single-instance v1; the Redis-backed migration is one constructor arg.
- Bad, because the CSRF cookie must be readable by JS (
HttpOnly: false), so an XSS that readsdocument.cookiecan lift it. Mitigated by: (a) the session cookie remainsHttpOnly, so an XSS still needs to call the BFF from the victim's browser, which means the user has to be present; (b) CSP and helmet defaults harden the SPA delivery; (c) the SPA bundle is small + audited; (d) tighten further by introducing a CSP nonce when admin SPA renders. - Bad, because the structured envelope doesn't include a stable per-error machine-readable detail object yet (e.g. for ValidationPipe rejections, the
messageis a semicolon-joined string). Adetailsfield can be added without breaking the contract. - Neutral, because no rate limiting on anonymous mutating routes today — none exist yet. The check will move to "per-IP for unauthenticated requests" if and when those routes appear.
Confirmation
apps/portal-bff/src/main.tsmounts the five middleware in the declared order; the comment block inbootstrap()lists their roles and rationale.apps/portal-bff/src/security/structured-error.filter.tscarries theErrorResponseBodyinterface +errorResponse()helper. Used bycsrf.middleware.ts(line:res.status(403).json(errorResponse('csrf', '…'))) and byrate-limit.middleware.ts(handler line).apps/portal-bff/src/security/csrf.middleware.tsreads fromreq.session.csrfToken(set inapps/portal-bff/src/auth/auth.controller.tsat the end of/auth/callback); the spec pins constant-time comparison + the exempt-paths list.apps/portal-bff/src/security/rate-limit.middleware.tsexportsreadRateLimitConfig()(env parsing + validation) andcreateRateLimitMiddleware()(factory); specs cover the bucket key strategy, the strict-vs-general tiers, the/api/healthskip, and the 429 envelope shape.apps/portal-bff/src/config/check-cors-allowlist.tsenforces the boot-time validation; spec covers every rejected case (empty, non-URL, non-http(s), path/query present).- The SPA
csrfInterceptorinlibs/feature/auth/src/lib/csrf.interceptor.tsreads the cookie and copies it into the header on every mutating BFF request; the spec asserts the interceptor never touches non-BFF origins or safe methods. - Integration tests sit in the next phase: today the contract is held by unit-level specs (199 / 199 BFF, 28 / 28 feature-auth) under a clean-env repro (
env -uevery config var). A future "BFF smoke" test will black-box-verify all five middleware on a running stack.
Pros and Cons of the Options
Response envelope
{ error: { code, message, traceId } } (chosen)
- Good, because single shape across status codes and across Nest-vs-middleware origins.
- Good, because
traceIdmakes any reported error joinable to the full request trace. - Good, because the
codefield lets the SPA write aswitchinstead of pattern-matching on free-form messages. - Bad, because no machine-readable
detailsfor field-level validation errors yet (mitigated by adding it later without changing the envelope).
Nest default { statusCode, message, error }
- Good, because zero work.
- Bad, because
messagemay be an array (ValidationPipe), a string, or an object depending on the exception path. Inconsistent for the SPA. - Bad, because 500s leak the exception class name in
error.
Per-route ad-hoc shapes
- Bad, because every route invents its own contract; the SPA has to special-case each path.
CSRF strategy
Session-bound double-submit (chosen)
- Good, because resistant to subdomain cookie injection: a planted cookie doesn't match the session-stored token.
- Good, because no shared-state needed beyond the session payload we already store in Redis.
- Bad, because requires server-side storage (we have it anyway).
Pure cookie-vs-header double-submit
- Good, because completely stateless on the server.
- Bad, because vulnerable to subdomain cookie injection: an attacker who can write
__Host-portal_csrfcookie value (e.g. via subdomain takeover) can also generate the matching header.
csurf package
- Bad, because deprecated / unmaintained since 2022. Not an option.
Synchronizer token rendered in HTML
- Bad, because the BFF doesn't render HTML — the SPA is a separate static bundle.
CORS allowlist
Mandatory env-driven, no fallback (chosen)
- Good, because forces explicit configuration in every environment.
- Good, because boot-time validator catches the misconfiguration before request handling.
- Bad, because slightly less ergonomic in fresh-clone setup (developers need to read
.env.example; mitigated by the validator's error message pointing straight at the file to edit).
Hardcoded localhost fallback
- Good, because zero-config dev.
- Bad, because "works in dev, breaks in prod" trap — production silently allows nothing if the env is forgotten.
* policy
- Bad, because
credentials: true+*is incompatible per the CORS spec, andcredentials: falsedefeats the session cookie.
Rate limiting bucket key
sessionID if auth, else IP (chosen)
- Good, because per-account fairness for authenticated users (one user can't drown other users from the same NAT'd IP).
- Good, because IP fallback protects against unauthenticated brute force on
/auth/login.
IP only
- Bad, because a single NAT-shared IP (corporate proxy, mobile carrier) shares its quota across users — one noisy user blocks everyone.
Entra oid
- Good, because tied to the user identity rather than the session, so logout + login doesn't reset the quota.
- Bad, because
oidis only available post-resolution.sessionIDis available immediately and resolves to the same user via the session payload. Equivalent in practice; sessionID is whatexpress-rate-limitalready has onreq.
Rate limiting store
In-memory (chosen for v1)
- Good, because zero infrastructure.
- Bad, because per-instance counters when we scale out (mitigated by single-instance v1).
Redis-backed
- Good, because shared counters across BFF instances.
- Bad, because new infra dep + each request adds a Redis hop.
Helmet CSP
Defaults in prod, disabled in dev (chosen)
- Good, because production responses carry a sensible CSP without bespoke tuning.
- Good, because dev devtools stay clean (Helmet's default
connect-src 'self'triggers cosmetic violations on JSON responses). - Bad, because dev / prod differ — a CSP misconfiguration won't be caught locally.
Custom CSP
- Good, because matches the actual BFF response shape (JSON, no scripts, no images).
- Bad, because more surface to maintain; revisited when admin SPA is served by Caddy with a real CSP nonce.
More Information
- OWASP CSRF Cheat Sheet (double-submit + synchronizer token comparison): https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
- OWASP Rate Limiting Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Denial_of_Service_Cheat_Sheet.html
express-rate-limitdocs: https://express-rate-limit.mintlify.app/helmetdefaults reference: https://helmetjs.github.io/- Related ADRs: ADR-0009 (session cookie + CSRF design), ADR-0010 (session storage), ADR-0012 (trace id propagation, used here as the error envelope's correlation key), ADR-0015 (DoS mitigation reference); future: the paused strategic security baseline ADR (ASVS level + regulatory framing), the infrastructure ADR (Redis-backed rate-limit store, CSP for admin SPA).