docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope) #124

Merged
julien merged 1 commits from docs/adr-0021-phase-2-security-baseline into main 2026-05-13 23:54:45 +02:00
Owner

Summary

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 for each choice lives in code comments and PR descriptions; the next contributor reaching for csurf, a cookie-only CSRF, or a hardcoded localhost CORS fallback won't have a single place to read why those are wrong here.

ADR-0021 covers:

  • Response envelope{ error: { code, message, traceId } }, single contract shared between Nest's StructuredErrorFilter and raw Express middlewares (CSRF, rate-limit) via the exported errorResponse() helper. Status code → code mapping documented. 500s never leak the underlying exception.
  • CSRF — session-bound double-submit, not pure cookie-vs-header. Rationale: a subdomain-takeover cookie injection can't bypass the comparison because the source of truth is the server-side session token, not the cookie. Cookie is the SPA's read-only mirror.
  • CORS allowlistCORS_ALLOWED_ORIGINS mandatory at boot, no fallback. "Works in dev, breaks in prod" is the exact trap the validator catches.
  • Rate limiting — buckets keyed by session id (auth) or IP (anonymous), 10/min on /auth/login + /auth/callback, 120/min general, /api/health skipped. In-memory v1 store; Redis-backed migration is one constructor arg.
  • Helmet config — defaults plus three overrides (HSTS prod-only, crossOriginResourcePolicy: cross-origin, CSP prod-only). Each override has a code-anchored justification.

Scope

This is the implementation-level security ADR. The strategic security baseline ADR — OWASP ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off — remains paused per the note in CLAUDE.md. When that ADR lands it'll either confirm 0021 or supersede pieces of it; 0021 is explicit about which choices are tactical and revisitable.

Notable structural choices in the ADR

  • "Considered Options" mirrors the actual debate. For CSRF: session-bound vs pure double-submit vs csurf vs synchronizer. For rate-limit bucket key: sessionID-then-IP vs IP-only vs Entra oid. Each rejected option has its "Bad, because …" so the reader sees why we didn't go that way.
  • Each "Decision Outcome" line points at the file / function that enforces it. Cross-references are absolute paths so they survive folder reorgs.
  • The "Consequences" section is brutally honest about trade-offs. In-memory rate-limit doesn't scale horizontally. The CSRF cookie is XSS-readable. No details field in the envelope for field-level validation errors yet. These aren't hidden in the prose.

Other doc touches

Noted for a separate PR (out of scope here)

CLAUDE.md §"Repository status" still says "The Nx workspace is not yet bootstrapped" and refs ADRs up to 0020. The whole paragraph is stale (the project is fully scaffolded, 22 ADRs in place). Worth a small dedicated docs PR.

Test plan

  • pnpm exec prettier --check docs/decisions/ → clean.
  • ADR follows the MADR 4.0.0 template (frontmatter with status, date, decision-makers, tags; sections in the canonical order).
  • Tags drawn from the vocabulary in docs/decisions/README.md: security, backend.
  • Index in docs/decisions/README.md updated in the same change.
  • Cross-references to ADRs 0009, 0010, 0012, 0015 verified.
  • Renders in Gitea / IDE markdown preview without parser warnings.
## Summary 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 for each choice lives in code comments and PR descriptions; the next contributor reaching for `csurf`, a cookie-only CSRF, or a hardcoded localhost CORS fallback won't have a single place to read why those are wrong here. ADR-0021 covers: - **Response envelope** — `{ error: { code, message, traceId } }`, single contract shared between Nest's `StructuredErrorFilter` and raw Express middlewares (CSRF, rate-limit) via the exported `errorResponse()` helper. Status code → code mapping documented. 500s never leak the underlying exception. - **CSRF — session-bound double-submit**, not pure cookie-vs-header. Rationale: a subdomain-takeover cookie injection can't bypass the comparison because the source of truth is the server-side session token, not the cookie. Cookie is the SPA's read-only mirror. - **CORS allowlist** — `CORS_ALLOWED_ORIGINS` mandatory at boot, no fallback. "Works in dev, breaks in prod" is the exact trap the validator catches. - **Rate limiting** — buckets keyed by session id (auth) or IP (anonymous), 10/min on `/auth/login` + `/auth/callback`, 120/min general, `/api/health` skipped. In-memory v1 store; Redis-backed migration is one constructor arg. - **Helmet config** — defaults plus three overrides (HSTS prod-only, `crossOriginResourcePolicy: cross-origin`, CSP prod-only). Each override has a code-anchored justification. ## Scope This is the **implementation-level** security ADR. The **strategic** security baseline ADR — OWASP ASVS reference level, HDS / GDPR / NIS 2 framing, RSSI sign-off — remains paused per the note in [CLAUDE.md](CLAUDE.md). When that ADR lands it'll either confirm 0021 or supersede pieces of it; 0021 is explicit about which choices are tactical and revisitable. ## Notable structural choices in the ADR - **"Considered Options" mirrors the actual debate.** For CSRF: session-bound vs pure double-submit vs `csurf` vs synchronizer. For rate-limit bucket key: sessionID-then-IP vs IP-only vs Entra `oid`. Each rejected option has its "Bad, because …" so the reader sees why we didn't go that way. - **Each "Decision Outcome" line points at the file / function that enforces it.** Cross-references are absolute paths so they survive folder reorgs. - **The "Consequences" section is brutally honest about trade-offs.** In-memory rate-limit doesn't scale horizontally. The CSRF cookie is XSS-readable. No `details` field in the envelope for field-level validation errors yet. These aren't hidden in the prose. ## Other doc touches - [docs/decisions/README.md](docs/decisions/README.md) index entry added. - [notes/handoff.md](notes/handoff.md) refreshed (gitignored; not part of the commit but useful for the next session). ## Noted for a separate PR (out of scope here) [CLAUDE.md](CLAUDE.md) §"Repository status" still says "The Nx workspace is **not yet bootstrapped**" and refs ADRs up to 0020. The whole paragraph is stale (the project is fully scaffolded, 22 ADRs in place). Worth a small dedicated docs PR. ## Test plan - [x] `pnpm exec prettier --check docs/decisions/` → clean. - [x] ADR follows the MADR 4.0.0 template (frontmatter with `status`, `date`, `decision-makers`, `tags`; sections in the canonical order). - [x] Tags drawn from the vocabulary in [docs/decisions/README.md](docs/decisions/README.md#tag-vocabulary): `security`, `backend`. - [x] Index in `docs/decisions/README.md` updated in the same change. - [x] Cross-references to ADRs 0009, 0010, 0012, 0015 verified. - [x] Renders in Gitea / IDE markdown preview without parser warnings.
julien added 1 commit 2026-05-13 23:45:43 +02:00
docs(adr-0021): phase-2 security baseline (helmet, CORS, CSRF, rate-limit, error envelope)
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
e2fae02731
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.
julien merged commit da2bd6d481 into main 2026-05-13 23:54:45 +02:00
julien deleted branch docs/adr-0021-phase-2-security-baseline 2026-05-13 23:54: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#124