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
+2 -1
View File
@@ -1,6 +1,7 @@
export { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './lib/auth.config';
export { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME, AUTH_NAVIGATOR } from './lib/auth.config';
export { authGuard } from './lib/auth.guard';
export { AuthService } from './lib/auth.service';
export type { AuthState, CurrentUser } from './lib/auth.types';
export { bffCredentialsInterceptor } from './lib/bff-credentials.interceptor';
export { bffUnauthorizedInterceptor } from './lib/bff-unauthorized.interceptor';
export { csrfInterceptor } from './lib/csrf.interceptor';
+9
View File
@@ -26,3 +26,12 @@ export const AUTH_NAVIGATOR = new InjectionToken<(url: string) => void>('AUTH_NA
providedIn: 'root',
factory: () => (url: string) => window.location.assign(url),
});
/**
* Name of the BFF's CSRF cookie that the `csrfInterceptor` reads
* to echo back in the `X-CSRF-Token` header. Provided by the host
* from its per-environment config (`environment.bffCsrfCookieName`)
* so the lib doesn't have to know the env-conditional naming
* (`portal_csrf` dev / `__Host-portal_csrf` prod) the BFF picks.
*/
export const AUTH_CSRF_COOKIE_NAME = new InjectionToken<string>('AUTH_CSRF_COOKIE_NAME');
@@ -0,0 +1,115 @@
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME } from './auth.config';
import { csrfInterceptor } from './csrf.interceptor';
const BFF_BASE = 'http://bff.test/api';
const CSRF_COOKIE = 'portal_csrf';
function setup() {
TestBed.configureTestingModule({
providers: [
provideHttpClient(withInterceptors([csrfInterceptor])),
provideHttpClientTesting(),
{ provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE },
{ provide: AUTH_CSRF_COOKIE_NAME, useValue: CSRF_COOKIE },
],
});
return {
http: TestBed.inject(HttpClient),
httpCtrl: TestBed.inject(HttpTestingController),
};
}
/**
* jsdom keeps `document.cookie` as a single mutable string. Wipe it
* around each test so prior writes don't leak.
*/
function setCookie(name: string, value: string): void {
document.cookie = `${name}=${value}; path=/`;
}
function clearAllCookies(): void {
const all = document.cookie.split(';');
for (const c of all) {
const eq = c.indexOf('=');
const name = (eq > -1 ? c.slice(0, eq) : c).trim();
if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
}
}
describe('csrfInterceptor', () => {
beforeEach(() => clearAllCookies());
afterEach(() => {
clearAllCookies();
TestBed.resetTestingModule();
});
it('echoes the cookie value into the X-CSRF-Token header on POST', () => {
setCookie(CSRF_COOKIE, 'tok-abc');
const { http, httpCtrl } = setup();
http.post(`${BFF_BASE}/profile`, { name: 'Jane' }).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/profile`);
expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc');
req.flush({});
});
it.each(['PUT', 'PATCH', 'DELETE'])('sets the header on %s too', (method) => {
setCookie(CSRF_COOKIE, 'tok-abc');
const { http, httpCtrl } = setup();
http.request(method, `${BFF_BASE}/profile`, { body: {} }).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/profile`);
expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc');
req.flush({});
});
it('does NOT set the header on GET (BFF skips CSRF on safe methods)', () => {
setCookie(CSRF_COOKIE, 'tok-abc');
const { http, httpCtrl } = setup();
http.get(`${BFF_BASE}/me`).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/me`);
expect(req.request.headers.has('X-CSRF-Token')).toBe(false);
req.flush({});
});
it('does NOT touch requests to other origins', () => {
setCookie(CSRF_COOKIE, 'tok-abc');
const { http, httpCtrl } = setup();
http.post('http://otel.example/v1/traces', {}).subscribe();
const req = httpCtrl.expectOne('http://otel.example/v1/traces');
expect(req.request.headers.has('X-CSRF-Token')).toBe(false);
req.flush({});
});
it('omits the header when the cookie is missing (lets the BFF respond with its 403)', () => {
// No setCookie call — cookie isn't set.
const { http, httpCtrl } = setup();
http.post(`${BFF_BASE}/profile`, {}).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/profile`);
expect(req.request.headers.has('X-CSRF-Token')).toBe(false);
req.flush({});
});
it('URL-decodes the cookie value (cookies are URI-encoded in transit)', () => {
// Cookies preserve their stored form; if anything URL-encodes
// the token (current BFF doesn't, base64url is safe), we still
// round-trip cleanly.
setCookie(CSRF_COOKIE, encodeURIComponent('A B+/='));
const { http, httpCtrl } = setup();
http.post(`${BFF_BASE}/profile`, {}).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/profile`);
expect(req.request.headers.get('X-CSRF-Token')).toBe('A B+/=');
req.flush({});
});
it('finds the right cookie when others are also set', () => {
setCookie('other_cookie', 'noise');
setCookie(CSRF_COOKIE, 'tok-abc');
setCookie('yet_another', 'noise');
const { http, httpCtrl } = setup();
http.post(`${BFF_BASE}/profile`, {}).subscribe();
const req = httpCtrl.expectOne(`${BFF_BASE}/profile`);
expect(req.request.headers.get('X-CSRF-Token')).toBe('tok-abc');
req.flush({});
});
});
@@ -0,0 +1,56 @@
import type { HttpHandlerFn, HttpInterceptorFn, HttpRequest } from '@angular/common/http';
import { inject } from '@angular/core';
import { AUTH_BFF_BASE_URL, AUTH_CSRF_COOKIE_NAME } from './auth.config';
/**
* Double-submit CSRF helper interceptor (per ADR-0009). Reads the
* BFF-issued `__Host-portal_csrf` cookie (or its dev twin
* `portal_csrf`) and copies the value into an `X-CSRF-Token` header
* on every BFF request whose method mutates state.
*
* GET / HEAD / OPTIONS pass through without a header — the BFF
* skips CSRF on safe methods anyway, and adding the header would
* just be noise.
*
* Non-BFF origins pass through untouched. Same posture as the other
* BFF-only interceptors in this lib.
*
* If the cookie is missing (anonymous, freshly-cleared session,
* etc.), the request goes through without the header — the BFF
* skips CSRF on anonymous requests, and on the authenticated path
* the BFF will answer 403 which the SPA can surface as "please
* sign in again".
*/
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export const csrfInterceptor: HttpInterceptorFn = (
req: HttpRequest<unknown>,
next: HttpHandlerFn,
) => {
const bffBaseUrl = inject(AUTH_BFF_BASE_URL);
const cookieName = inject(AUTH_CSRF_COOKIE_NAME);
if (!req.url.startsWith(bffBaseUrl)) return next(req);
if (SAFE_METHODS.has(req.method.toUpperCase())) return next(req);
const token = readCookie(cookieName);
if (!token) return next(req);
return next(req.clone({ setHeaders: { 'X-CSRF-Token': token } }));
};
function readCookie(name: string): string | null {
// `document.cookie` is the only browser API to read non-HttpOnly
// cookies. The CSRF cookie is deliberately *not* HttpOnly so we
// can do exactly this; the session cookie remains HttpOnly.
const cookies = typeof document !== 'undefined' ? document.cookie : '';
for (const raw of cookies.split(';')) {
const eq = raw.indexOf('=');
if (eq < 0) continue;
const key = raw.slice(0, eq).trim();
if (key === name) {
return decodeURIComponent(raw.slice(eq + 1));
}
}
return null;
}