5bbe2304ff
## 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: - [ ] 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. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #122
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
import { timingSafeEqual } from 'node:crypto';
|
|
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
import { Logger } from 'nestjs-pino';
|
|
|
|
/**
|
|
* Methods that, per RFC 7231 / 9110, MUST NOT alter server state.
|
|
* The CSRF check skips them — there is nothing to forge for routes
|
|
* that don't change anything.
|
|
*/
|
|
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
|
|
|
/**
|
|
* Paths the CSRF middleware lets through unconditionally. These are
|
|
* the OIDC entry points that *create* the session (and the token
|
|
* along with it) — there is no token to verify yet.
|
|
*
|
|
* `GET /auth/logout` is a safe method so it's already covered by
|
|
* `SAFE_METHODS`; if a future PR moves logout to POST, the bypass
|
|
* here would need extending.
|
|
*/
|
|
const CSRF_EXEMPT_PREFIXES = ['/api/auth/login', '/api/auth/callback'];
|
|
|
|
/**
|
|
* Double-submit CSRF middleware (per ADR-0009 §"CSRF defense").
|
|
*
|
|
* Contract:
|
|
* - GET / HEAD / OPTIONS pass through.
|
|
* - Unauthenticated requests pass through. CSRF protects an
|
|
* authenticated identity from being abused by another origin;
|
|
* there is no identity to abuse on anonymous routes. (Anonymous
|
|
* POST endpoints, if they ever exist, will need a different
|
|
* defense — site-key, captcha, etc.)
|
|
* - `/api/auth/login` and `/api/auth/callback` are exempt: they
|
|
* are the routes that *establish* the session and thereby
|
|
* mint the CSRF token.
|
|
* - Any other request must carry `X-CSRF-Token` whose value
|
|
* equals `req.session.csrfToken`. Comparison is
|
|
* `crypto.timingSafeEqual` to avoid leaking the token via
|
|
* response-time differences.
|
|
* - Mismatch / missing → 403 `{ error: 'csrf' }`. No further
|
|
* processing.
|
|
*
|
|
* The middleware reads the token from the **session**, not the
|
|
* cookie. The cookie is the SPA's read-only mirror; an attacker
|
|
* who can plant a cookie (subdomain takeover, etc.) cannot also
|
|
* plant a matching session-stored token without already being the
|
|
* authenticated user. Tying the check to the server-side session
|
|
* is what distinguishes "session-bound double-submit" from the
|
|
* weaker pure cookie-vs-header comparison.
|
|
*/
|
|
export function createCsrfMiddleware(logger: Logger): RequestHandler {
|
|
return function csrf(req: Request, res: Response, next: NextFunction): void {
|
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const session = req.session;
|
|
if (!session?.user) {
|
|
// Anonymous mutating request — out of scope for CSRF v1.
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (CSRF_EXEMPT_PREFIXES.some((p) => req.path.startsWith(p))) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const expected = session.csrfToken;
|
|
const headerToken = req.get('X-CSRF-Token');
|
|
|
|
if (!expected || !headerToken || !equalsTimingSafe(headerToken, expected)) {
|
|
logger.warn(
|
|
{
|
|
event: 'csrf.reject',
|
|
path: req.path,
|
|
method: req.method,
|
|
hasHeaderToken: Boolean(headerToken),
|
|
hasSessionToken: Boolean(expected),
|
|
},
|
|
'Csrf',
|
|
);
|
|
res.status(403).json({ error: 'csrf' });
|
|
return;
|
|
}
|
|
|
|
next();
|
|
};
|
|
}
|
|
|
|
function equalsTimingSafe(a: string, b: string): boolean {
|
|
// `timingSafeEqual` requires equal-length buffers. Different
|
|
// lengths short-circuit as unequal — we accept the early return
|
|
// here because the length itself is not a secret.
|
|
const ba = Buffer.from(a, 'utf8');
|
|
const bb = Buffer.from(b, 'utf8');
|
|
if (ba.length !== bb.length) {
|
|
return false;
|
|
}
|
|
return timingSafeEqual(ba, bb);
|
|
}
|