feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF
CI / commits (pull_request) Successful in 2m0s
CI / scan (pull_request) Successful in 2m12s
CI / check (pull_request) Successful in 3m40s
CI / a11y (pull_request) Successful in 1m22s
CI / perf (pull_request) Successful in 2m50s

phase-2 security baseline the main.ts placeholder has been
advertising since the auth track started. three independent
middlewares + their spa counterparts shipped together because
they only become meaningful as a set.

helmet on the bff:
  helmet() with three overrides matching our specific shape:
   - HSTS only in production (dev runs on plain http)
   - crossOriginResourcePolicy: 'cross-origin' (SPA needs to read
     bff json from a different origin)
   - CSP disabled in non-prod (bff doesn't render html; default CSP
     just adds devtools noise)

cors allowlist env-driven:
  CORS_ALLOWED_ORIGINS is now mandatory at boot. readCorsAllowlist()
  parses + validates (http(s) only, bare origins, no path/query),
  same boot-time validator family as assertSessionSecret etc. the
  previous hardcoded localhost fallback is removed — silent
  cors misconfiguration is exactly the "works in dev breaks in
  prod" trap this guard catches.

double-submit CSRF (session-bound):
  - bff mints a 256-bit csrfToken at /auth/callback, stored on
    req.session.csrfToken AND mirrored to a js-readable cookie
    (__Host-portal_csrf prod / portal_csrf dev). cookie is the
    spa's read-only view; session is the source of truth.
  - createCsrfMiddleware compares X-CSRF-Token header to the
    session token via crypto.timingSafeEqual. skips: safe methods,
    anonymous requests, /auth/login + /auth/callback.
  - mismatch → 403 {"error":"csrf"} + structured pino warn.
  - spa csrfInterceptor reads the cookie via document.cookie and
    copies into X-CSRF-Token on mutating bff requests. omitted on
    GET/HEAD/OPTIONS and on non-bff origins.
  - logout + absolute-timeout middleware now clear the csrf cookie
    alongside the session cookie.

notable choices:
  - session-bound double-submit, not pure cookie-vs-header. an
    attacker who plants a cookie via subdomain takeover would
    still need to know the server-side session token. tying the
    check to req.session.csrfToken instead of the cookie itself
    is what makes this stronger than the canonical recipe.
  - csrfInterceptor sits between bffCredentialsInterceptor (which
    sets withCredentials) and bffUnauthorizedInterceptor (which
    handles 401s). forward order, no surprises.
  - no CSRF for anonymous mutating routes in v1 — none exist today
    and generating tokens for anonymous sessions conflicts with
    express-session's saveUninitialized: false.

specs:
  - 239 / 239 pass under the clean-env repro (env -u every config
    var including the new CORS_ALLOWED_ORIGINS).
  - +42 specs across check-cors-allowlist, csrf-cookie, csrf
    middleware, csrf interceptor, and extended auth.controller /
    absolute-timeout coverage for the csrf cookie mirror/clear.

out of scope, landing in follow-ups:
  - rate limiting + structured error filter (remaining phase-2
    todos)
  - CSP fine-tuning for the portal-shell + portal-admin static
    bundles
  - csrf token rotation on idle-extension (current token lives
    the session lifetime)
This commit is contained in:
Julien Gautier
2026-05-13 20:40:40 +02:00
parent a97be121e6
commit 116e0468b0
24 changed files with 902 additions and 23 deletions
+10
View File
@@ -117,6 +117,16 @@ SESSION_ENCRYPTION_KEY=replace_with_32_random_bytes_base64url
# node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
LOG_USER_ID_SALT=replace_with_32_random_bytes_base64url
# CORS allowlist (per ADR-0009 §"CORS"). Comma-separated list of
# origins (scheme://host[:port]) allowed to call the BFF with
# credentials. The BFF refuses to start without this — silently
# defaulting to localhost is the classic "works in dev, breaks in
# prod" trap.
#
# Local dev: the portal-shell dev server on :4200. Add :4201 once
# portal-admin grows its own dev server.
CORS_ALLOWED_ORIGINS=http://localhost:4200
# Future env vars introduced by upcoming phases / ADRs:
#
# Auth flow (ADR-0009) — additional keys wired as the routes land:
+2
View File
@@ -7,6 +7,7 @@ import { HealthModule } from '../health/health.module';
import { AuditModule } from '../audit/audit.module';
import { AuthModule } from '../auth/auth.module';
import { RedisModule } from '../redis/redis.module';
import { SecurityModule } from '../security/security.module';
import { SessionModule } from '../session/session.module';
@Module({
@@ -17,6 +18,7 @@ import { SessionModule } from '../session/session.module';
AuthModule,
RedisModule,
SessionModule,
SecurityModule,
HealthModule,
],
controllers: [AppController],
@@ -55,6 +55,7 @@ interface SessionStub {
user?: AuthenticatedUser;
createdAt?: number;
absoluteExpiresAt?: number;
csrfToken?: string;
save: jest.Mock;
destroy: jest.Mock;
}
@@ -261,6 +262,34 @@ describe('AuthController.callback', () => {
}
});
it('mints a CSRF token, writes it to the session, and mirrors it to the JS-readable cookie', async () => {
const { controller } = makeController();
const res = makeResStub();
const session = makeSessionStub();
const req = makeReqStub({
signedCookies: { [PRE_AUTH_COOKIE_NAME]: JSON.stringify(PRE_AUTH) },
session,
});
await controller.callback(req, res, 'auth-code', PRE_AUTH.state);
// Server-side source of truth lives on the session.
expect(typeof session.csrfToken).toBe('string');
expect((session.csrfToken as string).length).toBeGreaterThan(20);
// Cookie mirror — name picked from sessionCookieName's twin
// (NODE_ENV-conditional). In the test default (development),
// it's the unprefixed variant.
const csrfCookieCall = res.cookie.mock.calls.find(
(c) => c[0] === 'portal_csrf' || c[0] === '__Host-portal_csrf',
);
expect(csrfCookieCall).toBeDefined();
expect(csrfCookieCall?.[1]).toBe(session.csrfToken);
// Must NOT be HttpOnly — the SPA reads it to echo back via the
// X-CSRF-Token header.
expect(csrfCookieCall?.[2]).toMatchObject({ httpOnly: false });
});
it('registers the new session in the user_sessions index after save', async () => {
const { controller, userSessionIndex } = makeController();
const res = makeResStub();
@@ -501,6 +530,7 @@ describe('AuthController.logout', () => {
});
expect(session.destroy).toHaveBeenCalledTimes(1);
expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
expect(res.clearCookie).toHaveBeenCalledWith('portal_csrf', { path: '/' });
expect(logger.log).toHaveBeenCalledWith(
{ event: 'auth.signed_out', wasAuthenticated: true },
'AuthLogout',
+15 -1
View File
@@ -1,7 +1,9 @@
import { randomBytes } from 'node:crypto';
import { Controller, Get, Inject, Query, Req, Res } from '@nestjs/common';
import type { Request, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName, csrfCookieOptions } from '../security/csrf-cookie';
import { readSessionTimeouts, sessionCookieName } from '../session/session-cookie';
import { UserSessionIndexService } from '../session/user-session-index.service';
import {
@@ -117,19 +119,30 @@ export class AuthController {
try {
const user = await this.authService.completeAuthCodeFlow(code, state, preAuth);
const now = Date.now();
const { absoluteSeconds } = readSessionTimeouts();
const { idleSeconds, absoluteSeconds } = readSessionTimeouts();
const csrfToken = randomBytes(32).toString('base64url');
req.session.user = user;
req.session.createdAt = now;
// Hard ceiling per ADR-0010 §"TTL policy" — checked on every
// request by the absolute-timeout middleware, independent of
// idle TTL.
req.session.absoluteExpiresAt = now + absoluteSeconds * 1000;
// CSRF token per ADR-0009 §"Double-submit CSRF". Server-side
// source of truth lives on the session; the cookie below is
// the SPA's read-only mirror used to echo the value in the
// X-CSRF-Token header.
req.session.csrfToken = csrfToken;
// Force the save before the redirect: express-session writes
// on response end, but the 302 we're about to emit closes the
// response before the async store-write would otherwise
// complete. Without this, the browser hits the SPA before
// Redis carries the new payload.
await saveSession(req);
// Mirror the CSRF token to a JS-readable cookie. maxAge
// matches the session's idle TTL so the cookie expires at
// the same time as the session (rolling, refreshed on each
// request alongside express-session's own cookie).
res.cookie(csrfCookieName(), csrfToken, csrfCookieOptions(idleSeconds * 1000));
// Register the freshly-minted session id in the per-user
// index so a future admin "logout everywhere" can enumerate
// and revoke. Best-effort: a Redis hiccup here doesn't fail
@@ -232,6 +245,7 @@ export class AuthController {
}
res.clearCookie(sessionCookieName(), { path: '/' });
res.clearCookie(csrfCookieName(), { path: '/' });
this.logger.log({ event: 'auth.signed_out', wasAuthenticated }, 'AuthLogout');
res.redirect(302, logoutUrl);
}
@@ -0,0 +1,75 @@
import { readCorsAllowlist } from './check-cors-allowlist';
describe('readCorsAllowlist', () => {
const original = process.env['CORS_ALLOWED_ORIGINS'];
afterEach(() => {
if (original === undefined) {
delete process.env['CORS_ALLOWED_ORIGINS'];
} else {
process.env['CORS_ALLOWED_ORIGINS'] = original;
}
});
it('parses a single origin', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'http://localhost:4200';
expect(readCorsAllowlist()).toEqual(['http://localhost:4200']);
});
it('parses a comma-separated list and trims whitespace', () => {
process.env['CORS_ALLOWED_ORIGINS'] =
'http://localhost:4200, https://portal.apf.example ,https://admin.portal.apf.example';
expect(readCorsAllowlist()).toEqual([
'http://localhost:4200',
'https://portal.apf.example',
'https://admin.portal.apf.example',
]);
});
it('filters out empty entries (trailing commas tolerated)', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'http://localhost:4200,';
expect(readCorsAllowlist()).toEqual(['http://localhost:4200']);
});
it('throws when the var is unset', () => {
delete process.env['CORS_ALLOWED_ORIGINS'];
expect(() => readCorsAllowlist()).toThrow(/CORS_ALLOWED_ORIGINS is not set/);
});
it('throws when the var is the empty string', () => {
process.env['CORS_ALLOWED_ORIGINS'] = '';
expect(() => readCorsAllowlist()).toThrow(/CORS_ALLOWED_ORIGINS is not set/);
});
it('throws when the parsed list is empty (only commas / whitespace)', () => {
process.env['CORS_ALLOWED_ORIGINS'] = ' , , ';
expect(() => readCorsAllowlist()).toThrow(/empty after parsing/);
});
it('throws on a non-URL entry', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'not-a-url';
expect(() => readCorsAllowlist()).toThrow(/not a valid URL/);
});
it('throws on a non-http(s) scheme', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'ftp://files.apf.example';
expect(() => readCorsAllowlist()).toThrow(/must use http: or https:/);
});
it('throws when an entry has a path', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'https://portal.apf.example/foo';
expect(() => readCorsAllowlist()).toThrow(/must be a bare origin/);
});
it('accepts a trailing slash by treating it as the root path', () => {
// `new URL('https://x.example/').pathname` is "/" — we accept
// that as equivalent to a bare origin.
process.env['CORS_ALLOWED_ORIGINS'] = 'https://portal.apf.example/';
expect(readCorsAllowlist()).toEqual(['https://portal.apf.example/']);
});
it('throws when an entry has a query string', () => {
process.env['CORS_ALLOWED_ORIGINS'] = 'https://portal.apf.example?x=1';
expect(() => readCorsAllowlist()).toThrow(/no query string or fragment/);
});
});
@@ -0,0 +1,78 @@
/**
* Parse and validate the `CORS_ALLOWED_ORIGINS` env var into a list
* of origin strings ready to be passed to `app.enableCors({ origin
* }) `.
*
* Wired in `main.ts` (no eager boot-time pre-flight check — the
* value is consumed at CORS configuration time, after `NestFactory.
* create`). The validator runs there to fail fast on a malformed
* URL.
*
* Format: comma-separated list of origins (`<scheme>://<host>[:port]`).
* Whitespace around each entry is trimmed. Empty entries are
* filtered out. Trailing slashes are not allowed (an "origin" is
* scheme+host+port; nothing after).
*
* No fallback to a dev default. The BFF refuses to start without
* an explicit allowlist — getting CORS wrong silently is exactly
* the kind of "works in dev, breaks in prod" issue this guard is
* supposed to catch.
*
* Production should set `CORS_ALLOWED_ORIGINS` to the SPA / admin
* SPA's deploy origins (e.g.
* `https://portal.apf.example,https://admin.portal.apf.example`).
* Dev `.env` lists `http://localhost:4200` (and 4201 once
* portal-admin grows a dev server).
*/
export function readCorsAllowlist(): readonly string[] {
const raw = process.env['CORS_ALLOWED_ORIGINS'];
if (raw === undefined || raw === '') {
throw new Error(
`CORS_ALLOWED_ORIGINS is not set. Add the SPA origin(s) to ` +
`apps/portal-bff/.env, e.g. CORS_ALLOWED_ORIGINS=http://localhost:4200`,
);
}
const origins = raw
.split(',')
.map((o) => o.trim())
.filter((o) => o.length > 0);
if (origins.length === 0) {
throw new Error(`CORS_ALLOWED_ORIGINS is empty after parsing: "${raw}"`);
}
for (const origin of origins) {
assertOriginShape(origin);
}
return origins;
}
function assertOriginShape(origin: string): void {
let url: URL;
try {
url = new URL(origin);
} catch {
throw new Error(`CORS_ALLOWED_ORIGINS entry "${origin}" is not a valid URL`);
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(
`CORS_ALLOWED_ORIGINS entry "${origin}" must use http: or https:, got "${url.protocol}"`,
);
}
if (url.pathname !== '/' && url.pathname !== '') {
throw new Error(
`CORS_ALLOWED_ORIGINS entry "${origin}" must be a bare origin ` +
`(scheme://host[:port]) with no path. Got pathname "${url.pathname}".`,
);
}
if (url.search !== '' || url.hash !== '') {
throw new Error(
`CORS_ALLOWED_ORIGINS entry "${origin}" must be a bare origin — no query string or fragment.`,
);
}
}
+50 -17
View File
@@ -6,14 +6,17 @@ import './observability/tracing';
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app/app.module';
import { readCorsAllowlist } from './config/check-cors-allowlist';
import { assertDatabaseUrl } from './config/check-database-url';
import { assertEntraConfig } from './config/check-entra-config';
import { assertRedisConfig } from './config/check-redis-config';
import { assertLogUserIdSalt } from './config/check-log-user-id-salt';
import { assertSessionEncryptionKey } from './config/check-session-encryption-key';
import { assertSessionSecret } from './config/check-session-secret';
import { CSRF_MIDDLEWARE } from './security/security.token';
import {
SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE,
SESSION_MIDDLEWARE,
@@ -57,21 +60,43 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule, { bufferLogs: true });
app.useLogger(app.get(Logger));
// CORS — minimal dev-time allowlist. The SPA running on
// http://localhost:4200 issues fetches to the BFF and must be
// able to send the W3C `traceparent` (and `tracestate`) headers
// that `@opentelemetry/instrumentation-fetch` injects, so the BFF
// can pick up the parent span id and emit child spans on the same
// trace. The full security-grade allowlist (per-environment
// origins, credentials policy, helmet stack, etc.) lands with the
// phase-2 security ADR — for now this is the minimum needed for
// end-to-end tracing.
// Security headers (phase-2). Defaults from `helmet()` are good
// for an API server returning JSON: X-Frame-Options=SAMEORIGIN,
// X-Content-Type-Options=nosniff, Referrer-Policy=no-referrer,
// X-Powered-By removed, etc. CSP defaults apply too but the BFF
// doesn't render HTML, so they're inert here.
//
// Three overrides for our specific shape:
// - HSTS only in production (dev runs on plain HTTP).
// - crossOriginResourcePolicy: 'cross-origin' so the SPA on its
// own origin can read JSON from the BFF without being blocked
// by Spectre-class CORP protections.
// - contentSecurityPolicy: false in dev — Helmet's default CSP
// blocks `connect-src` from anything but 'self', which is
// fine for HTML pages but irrelevant for JSON responses and
// noisy in browser devtools.
app.use(
helmet({
hsts: process.env['NODE_ENV'] === 'production',
crossOriginResourcePolicy: { policy: 'cross-origin' },
contentSecurityPolicy: process.env['NODE_ENV'] === 'production',
}),
);
// CORS allowlist — env-driven via `CORS_ALLOWED_ORIGINS`, parsed
// and validated at boot. No hardcoded localhost fallback: getting
// CORS wrong silently is exactly the kind of "works in dev, breaks
// in prod" issue this validator is meant to catch.
app.enableCors({
origin: (process.env['CORS_ALLOWED_ORIGINS'] ?? 'http://localhost:4200')
.split(',')
.map((o) => o.trim())
.filter(Boolean),
allowedHeaders: ['Content-Type', 'Accept', 'Authorization', 'traceparent', 'tracestate'],
origin: [...readCorsAllowlist()],
allowedHeaders: [
'Content-Type',
'Accept',
'Authorization',
'X-CSRF-Token',
'traceparent',
'tracestate',
],
credentials: true,
});
@@ -104,9 +129,17 @@ async function bootstrap() {
// needed).
app.use(app.get<RequestHandler>(SESSION_ABSOLUTE_TIMEOUT_MIDDLEWARE));
// Phase-2 security ADRs will harden the above: helmet, real CORS
// allowlist, CSRF protection, rate limiting, auth guards,
// structured error filter.
// Double-submit CSRF (ADR-0009 §"CSRF defense"). Mounted after
// the session middleware so `req.session.csrfToken` is available
// for comparison with the `X-CSRF-Token` request header. Skips
// safe methods (GET / HEAD / OPTIONS), anonymous requests, and
// the auth entry routes that *mint* the token (`/auth/login`,
// `/auth/callback`).
app.use(app.get<RequestHandler>(CSRF_MIDDLEWARE));
// Phase-2 hardening still pending: rate limiting + structured
// error filter (helmet, CORS allowlist, CSRF protection landed
// with this PR).
const globalPrefix = 'api';
app.setGlobalPrefix(globalPrefix);
@@ -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 };
@@ -162,6 +162,7 @@ describe('absoluteTimeout middleware', () => {
expect(session.destroy).toHaveBeenCalledTimes(1);
expect(index.remove).toHaveBeenCalledWith(USER.oid, 'session-abc');
expect(res.clearCookie).toHaveBeenCalledWith('portal_session', { path: '/' });
expect(res.clearCookie).toHaveBeenCalledWith('portal_csrf', { path: '/' });
expect(audit.sessionExpired).toHaveBeenCalledWith({
actor: { oid: USER.oid },
sessionId: 'session-abc',
@@ -1,6 +1,7 @@
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { Logger } from 'nestjs-pino';
import { AuditWriter } from '../audit/audit.service';
import { csrfCookieName } from '../security/csrf-cookie';
import { sessionCookieName } from './session-cookie';
import { UserSessionIndexService } from './user-session-index.service';
@@ -80,6 +81,7 @@ export function createAbsoluteTimeoutMiddleware(
await index.remove(userId, sessionId);
res.clearCookie(sessionCookieName(), { path: '/' });
res.clearCookie(csrfCookieName(), { path: '/' });
// Audit the expiry — blocking per ADR-0013: if the audit row
// can't be written, the request fails (the user sees a 5xx,
@@ -33,6 +33,16 @@ declare module 'express-session' {
* the session.
*/
absoluteExpiresAt?: number;
/**
* Double-submit CSRF token (per ADR-0009). 32 random bytes
* base64url-encoded; minted at session creation alongside
* `user` and lasts the session lifetime. The same value is
* mirrored to the `__Host-portal_csrf` cookie (HttpOnly: false
* so the SPA can read it) and compared with the `X-CSRF-Token`
* request header by the CSRF middleware. The cookie is the
* SPA's read-only mirror; the source of truth lives here.
*/
csrfToken?: string;
}
}