Files
apf_portal/docs/decisions/0021-phase-2-security-baseline.md
T
Julien Gautier e2fae02731
CI / scan (pull_request) Successful in 2m11s
CI / commits (pull_request) Successful in 2m11s
CI / check (pull_request) Successful in 2m20s
CI / a11y (pull_request) Successful in 1m5s
CI / perf (pull_request) Successful in 2m32s
docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope)
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.
2026-05-13 23:41:51 +02:00

16 KiB

status, date, decision-makers, tags
status date decision-makers tags
accepted 2026-05-13 R&D Lead
security
backend

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-Token header, 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).
  • csurf package.

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 by credentials: false.

Rate limiting bucket key

  • sessionID when authenticated, remote IP otherwise (chosen).
  • IP only.
  • User id (Entra oid) when authenticated.

Rate limiting tiers

  • Two tiers via dynamic max per 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: false in 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:

  1. StructuredErrorFilter (registered via app.useGlobalFilters at the top of bootstrap()) — normalises every NestJS exception to { error: { code, message, traceId } }. Source of truth for the response envelope.
  2. helmet() — security headers. Three overrides:
    • hsts only in production (dev runs on plain HTTP),
    • crossOriginResourcePolicy: 'cross-origin' (SPA on its own origin reads JSON from the BFF),
    • contentSecurityPolicy: false outside production (the BFF returns JSON; the default CSP triggers noisy connect-src violations in devtools without protecting anything we care about on JSON responses).
  3. CORS allowlistCORS_ALLOWED_ORIGINS env-driven, mandatory at boot. Parsed + validated (http(s): only, bare origins, no path/query). No localhost fallback. X-CSRF-Token declared in allowedHeaders alongside Content-Type / Accept / Authorization / traceparent / tracestate.
  4. Rate limiting (express-rate-limit) — dynamic max per request: 10/min on the auth-flow entry routes, 120/min everywhere else, /api/health skipped. Bucket key = session id when req.session.user is set, else remote IP. In-memory store; v2 swaps for rate-limit-redis when we scale BFF horizontally. Response = same envelope as the filter (code: 'rate_limited').
  5. CSRF middleware (custom; not csurf, which is unmaintained) — double-submit, session-bound: token minted at session creation in /auth/callback, stored on req.session.csrfToken AND mirrored to a JS-readable cookie (__Host-portal_csrf prod / portal_csrf dev). Request middleware compares the X-CSRF-Token header against the session-stored token with crypto.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 by StructuredErrorFilter so 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…"
  }
}
  • code is the SPA's switch-on key. Stable strings: unauthenticated, forbidden, not_found, csrf, rate_limited, bad_request, internal, service_unavailable, and any custom code an HttpException declares via its response object (new UnauthorizedException({ code: 'unauthenticated', message: '…' })).
  • message is safe, human-readable text. 5xx exceptions never put their internal message in the response — the full exception goes to the Pino error log line via the err: exception field; the response body says "Internal server error".
  • traceId is the active OTel trace id (or null). 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_ORIGINS catch 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 reads document.cookie can lift it. Mitigated by: (a) the session cookie remains HttpOnly, 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 message is a semicolon-joined string). A details field 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.ts mounts the five middleware in the declared order; the comment block in bootstrap() lists their roles and rationale.
  • apps/portal-bff/src/security/structured-error.filter.ts carries the ErrorResponseBody interface + errorResponse() helper. Used by csrf.middleware.ts (line: res.status(403).json(errorResponse('csrf', '…'))) and by rate-limit.middleware.ts (handler line).
  • apps/portal-bff/src/security/csrf.middleware.ts reads from req.session.csrfToken (set in apps/portal-bff/src/auth/auth.controller.ts at the end of /auth/callback); the spec pins constant-time comparison + the exempt-paths list.
  • apps/portal-bff/src/security/rate-limit.middleware.ts exports readRateLimitConfig() (env parsing + validation) and createRateLimitMiddleware() (factory); specs cover the bucket key strategy, the strict-vs-general tiers, the /api/health skip, and the 429 envelope shape.
  • apps/portal-bff/src/config/check-cors-allowlist.ts enforces the boot-time validation; spec covers every rejected case (empty, non-URL, non-http(s), path/query present).
  • The SPA csrfInterceptor in libs/feature/auth/src/lib/csrf.interceptor.ts reads 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 -u every 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 traceId makes any reported error joinable to the full request trace.
  • Good, because the code field lets the SPA write a switch instead of pattern-matching on free-form messages.
  • Bad, because no machine-readable details for 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 message may 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).
  • Good, because completely stateless on the server.
  • Bad, because vulnerable to subdomain cookie injection: an attacker who can write __Host-portal_csrf cookie 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, and credentials: false defeats 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 oid is only available post-resolution. sessionID is available immediately and resolves to the same user via the session payload. Equivalent in practice; sessionID is what express-rate-limit already has on req.

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