feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
CI / commits (push) Has been skipped
CI / scan (push) Successful in 2m10s
CI / check (push) Successful in 3m16s
CI / a11y (push) Successful in 1m6s
CI / perf (push) Successful in 4m16s

## 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
This commit was merged in pull request #122.
This commit is contained in:
2026-05-13 20:50:44 +02:00
parent a97be121e6
commit 5bbe2304ff
24 changed files with 902 additions and 23 deletions
@@ -0,0 +1,49 @@
import { csrfCookieName, csrfCookieOptions } from './csrf-cookie';
describe('csrf-cookie', () => {
const originalNodeEnv = process.env['NODE_ENV'];
afterEach(() => {
if (originalNodeEnv === undefined) {
delete process.env['NODE_ENV'];
} else {
process.env['NODE_ENV'] = originalNodeEnv;
}
});
describe('csrfCookieName', () => {
it('uses __Host-portal_csrf in production', () => {
process.env['NODE_ENV'] = 'production';
expect(csrfCookieName()).toBe('__Host-portal_csrf');
});
it('uses portal_csrf in development (HTTP, no Secure)', () => {
process.env['NODE_ENV'] = 'development';
expect(csrfCookieName()).toBe('portal_csrf');
});
});
describe('csrfCookieOptions', () => {
it('is NOT HttpOnly (SPA must read the value to echo it back)', () => {
expect(csrfCookieOptions(1800_000).httpOnly).toBe(false);
});
it('uses SameSite=Lax to travel with the session cookie', () => {
expect(csrfCookieOptions(1800_000).sameSite).toBe('lax');
});
it('sets Secure only in production', () => {
process.env['NODE_ENV'] = 'production';
expect(csrfCookieOptions(1800_000).secure).toBe(true);
process.env['NODE_ENV'] = 'development';
expect(csrfCookieOptions(1800_000).secure).toBe(false);
});
it('pins path=/', () => {
expect(csrfCookieOptions(1800_000).path).toBe('/');
});
it('passes maxAge through unchanged', () => {
expect(csrfCookieOptions(123456).maxAge).toBe(123456);
});
});
});
@@ -0,0 +1,53 @@
import type { CookieOptions } from 'express';
/**
* Name of the CSRF cookie that pairs with the `__Host-portal_session`
* cookie. Per ADR-0009 §"Double-submit CSRF": the BFF generates a
* random token at session creation and writes it both to the session
* payload (server-side) and to this cookie (readable by the SPA).
* Every mutating request must echo the value back in an
* `X-CSRF-Token` header; the middleware compares the header against
* the *session-stored* token, not the cookie — the cookie is just
* the SPA's UX way of reading the value it's expected to send back.
*
* Same env-conditional naming as the session cookie:
* - production: `__Host-portal_csrf` (prefix mandates Secure +
* Path=/ + no Domain — defeats subdomain cookie injection)
* - development: `portal_csrf` (no Secure available on HTTP)
*/
const PRODUCTION_NAME = '__Host-portal_csrf';
const DEVELOPMENT_NAME = 'portal_csrf';
export function csrfCookieName(): string {
return process.env['NODE_ENV'] === 'production' ? PRODUCTION_NAME : DEVELOPMENT_NAME;
}
/**
* Cookie options for the CSRF cookie.
*
* Crucially **NOT** `HttpOnly`: the SPA needs JavaScript access to
* read the token and echo it back in the `X-CSRF-Token` header. The
* tradeoff is acceptable because the cookie is _not_ the auth
* credential — the session id cookie (`__Host-portal_session`, which
* IS HttpOnly) is. An XSS that reads this CSRF cookie still cannot
* impersonate the user without also stealing the session cookie.
*
* `SameSite=Lax` (not Strict): matches the session cookie so the
* two travel together on the SPA→BFF cross-origin same-site fetch
* (different ports = different origin but same registrable domain).
* Strict would drop on top-level POST navigations originating from
* a different origin, which is correct for raw CSRF defense but
* loses parity with the session cookie's policy. The double-submit
* pattern itself is what gives the protection here; SameSite=Lax is
* a belt-and-braces layer.
*/
export function csrfCookieOptions(maxAgeMs: number): CookieOptions {
const isProduction = process.env['NODE_ENV'] === 'production';
return {
httpOnly: false,
sameSite: 'lax',
secure: isProduction,
path: '/',
maxAge: maxAgeMs,
};
}
@@ -0,0 +1,177 @@
import type { NextFunction, Request, Response } from 'express';
import type { Logger } from 'nestjs-pino';
import { createCsrfMiddleware } from './csrf.middleware';
interface SessionStub {
user?: { oid: string };
csrfToken?: string;
}
function makeReq(opts: {
method: string;
path: string;
headers?: Record<string, string>;
session?: SessionStub;
}): Request {
const headers = opts.headers ?? {};
return {
method: opts.method,
path: opts.path,
session: opts.session,
get: (name: string) => headers[name],
} as unknown as Request;
}
function makeRes() {
const res = {
status: jest.fn(),
json: jest.fn(),
};
res.status.mockReturnValue(res);
res.json.mockReturnValue(res);
return res as unknown as Response & { status: jest.Mock; json: jest.Mock };
}
function makeLogger() {
return {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as Logger & { warn: jest.Mock };
}
describe('csrf middleware', () => {
const next: NextFunction = jest.fn();
beforeEach(() => (next as jest.Mock).mockReset());
describe('skip cases', () => {
it.each(['GET', 'HEAD', 'OPTIONS'])('passes through safe method %s', (method) => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({ method, path: '/api/anything' });
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
it('passes through anonymous mutating requests (no req.session.user)', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({ method: 'POST', path: '/api/public', session: {} });
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('passes through /api/auth/login (CSRF-exempt: session is born here)', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/auth/login',
session: { user: { oid: 'u' }, csrfToken: 'irrelevant' },
});
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('passes through /api/auth/callback (CSRF-exempt)', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/auth/callback',
session: { user: { oid: 'u' }, csrfToken: 'irrelevant' },
});
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
});
describe('enforcement', () => {
it('rejects with 403 when an authenticated mutation lacks X-CSRF-Token', () => {
const logger = makeLogger();
const csrf = createCsrfMiddleware(logger);
const req = makeReq({
method: 'POST',
path: '/api/profile',
session: { user: { oid: 'u' }, csrfToken: 'expected-token' },
});
const res = makeRes();
csrf(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ error: 'csrf' });
expect(next).not.toHaveBeenCalled();
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ event: 'csrf.reject', hasHeaderToken: false }),
'Csrf',
);
});
it('rejects with 403 when the header value mismatches the session token', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/profile',
headers: { 'X-CSRF-Token': 'wrong-token' },
session: { user: { oid: 'u' }, csrfToken: 'expected-token' },
});
const res = makeRes();
csrf(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('rejects with 403 when the session has no csrfToken (auth without CSRF — should never happen)', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/profile',
headers: { 'X-CSRF-Token': 'anything' },
session: { user: { oid: 'u' } },
});
const res = makeRes();
csrf(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('passes when header matches the session token exactly', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/profile',
headers: { 'X-CSRF-Token': 'expected-token' },
session: { user: { oid: 'u' }, csrfToken: 'expected-token' },
});
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
expect(res.status).not.toHaveBeenCalled();
});
it.each(['PUT', 'PATCH', 'DELETE'])('enforces on mutation method %s like POST', (method) => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method,
path: '/api/profile',
headers: { 'X-CSRF-Token': 'tok' },
session: { user: { oid: 'u' }, csrfToken: 'tok' },
});
const res = makeRes();
csrf(req, res, next);
expect(next).toHaveBeenCalledTimes(1);
});
it('uses constant-time comparison (different lengths short-circuit as unequal)', () => {
const csrf = createCsrfMiddleware(makeLogger());
const req = makeReq({
method: 'POST',
path: '/api/profile',
headers: { 'X-CSRF-Token': 'short' },
session: { user: { oid: 'u' }, csrfToken: 'much-longer-token-value' },
});
const res = makeRes();
csrf(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
});
});
@@ -0,0 +1,102 @@
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);
}
@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { createCsrfMiddleware } from './csrf.middleware';
import { CSRF_MIDDLEWARE, type RequestHandler } from './security.token';
/**
* Phase-2 security primitives (per the `main.ts` placeholder note
* left in earlier PRs). For now: the CSRF middleware. Helmet and
* the CORS allowlist parser are wired directly in `main.ts` because
* they are pure stateless configuration — they don't need DI.
*
* Future phase-2 work that will fit here: rate limiting, structured
* error filter, request-size limits.
*/
@Module({
providers: [
{
provide: CSRF_MIDDLEWARE,
inject: [Logger],
useFactory: (logger: Logger): RequestHandler => createCsrfMiddleware(logger),
},
],
exports: [CSRF_MIDDLEWARE],
})
export class SecurityModule {}
@@ -0,0 +1,11 @@
import type { RequestHandler } from 'express';
/**
* DI token for the double-submit CSRF middleware (per ADR-0009).
* Resolved at bootstrap in `main.ts` and mounted with `app.use(...)`
* after the session middleware so `req.session.csrfToken` is
* available for comparison.
*/
export const CSRF_MIDDLEWARE = 'CSRF_MIDDLEWARE';
export type { RequestHandler };