feat(portal-bff): helmet + env-driven CORS allowlist + double-submit CSRF (#122)
## 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:
@@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user