-
Notifications
You must be signed in to change notification settings - Fork 4
feat(auth-service): nonce-based CSP and 5 security cucumber scenarios #100
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1223b38
5b31f3b
8641436
9a0c74a
43ff665
82e5bf3
2ceaded
1da0ec6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| 'ePDS': minor | ||
| --- | ||
|
|
||
| Auth service tightens its Content-Security-Policy and locks down the metrics endpoint. | ||
|
|
||
| **Affects:** Operators | ||
|
|
||
| **Operators:** the auth service's `Content-Security-Policy` response header now uses a per-response nonce on the `script-src` directive instead of `'unsafe-inline'`. The resulting policy looks like `default-src 'self'; script-src 'self' 'nonce-<base64url>'; style-src 'self' 'unsafe-inline'; img-src 'self' data: [client-origin]; connect-src 'self'`. All inline `<script>` tags that ePDS ships are already stamped with the matching nonce, so there is nothing to do on upgrade — but any operator-supplied HTML overlay or injected script that the auth service happens to serve inline will now be blocked by the browser unless it is updated to read `res.locals.cspNonce` and stamp `<script nonce="...">`. External scripts loaded via `src=` are unaffected. | ||
|
|
||
| The `/metrics` endpoint on the auth service is now deny-by-default: if `PDS_ADMIN_PASSWORD` is unset, the endpoint returns `401 Unauthorized` instead of serving metrics unauthenticated. Previously, unset meant "no auth required", which leaked process uptime, RSS memory, and database counters to anyone who could reach the auth service's `/metrics` path. Operators who relied on the open endpoint must set `PDS_ADMIN_PASSWORD` and send HTTP Basic auth as `admin:<password>` to continue scraping. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,245 @@ | ||
| /** | ||
| * Step definitions for security.feature. These scenarios run direct HTTP | ||
| * requests (no browser) because they assert on response headers, status | ||
| * codes, and raw HTML — not user interaction. | ||
| */ | ||
|
|
||
| import { When, Then } from '@cucumber/cucumber' | ||
| import type { DataTable } from '@cucumber/cucumber' | ||
| import type { EpdsWorld } from '../support/world.js' | ||
| import { testEnv } from '../support/env.js' | ||
|
|
||
| /** Response captured by the most recent direct-fetch step. */ | ||
| interface CapturedResponse { | ||
| status: number | ||
| headers: Headers | ||
| body: string | ||
| } | ||
|
|
||
| const capturedBySymbol = new WeakMap<EpdsWorld, CapturedResponse>() | ||
|
|
||
| function setCapturedResponse( | ||
| world: EpdsWorld, | ||
| response: CapturedResponse, | ||
| ): void { | ||
| capturedBySymbol.set(world, response) | ||
| world.lastHttpStatus = response.status | ||
| } | ||
|
|
||
| function getCapturedResponse(world: EpdsWorld): CapturedResponse { | ||
| const captured = capturedBySymbol.get(world) | ||
| if (!captured) { | ||
| throw new Error('No response has been captured by a prior step') | ||
| } | ||
| return captured | ||
| } | ||
|
|
||
| async function captureGet( | ||
| world: EpdsWorld, | ||
| url: string, | ||
| init?: RequestInit, | ||
| ): Promise<void> { | ||
| const res = await fetch(url, { redirect: 'manual', ...init }) | ||
| const body = await res.text() | ||
| setCapturedResponse(world, { status: res.status, headers: res.headers, body }) | ||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // CSRF scenarios | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| When('the recovery page is loaded', async function (this: EpdsWorld) { | ||
| const recoveryUrl = `${testEnv.authUrl}/auth/recover?request_uri=urn:ietf:params:oauth:request_uri:req-security-probe` | ||
| await captureGet(this, recoveryUrl) | ||
| }) | ||
|
|
||
| Then('the response sets a CSRF cookie', function (this: EpdsWorld) { | ||
| const { headers } = getCapturedResponse(this) | ||
| // `headers.get('set-cookie')` collapses multiple Set-Cookie values | ||
| // into a single comma-separated string in the Fetch API, which is | ||
| // ambiguous because cookie values may themselves contain commas | ||
| // (e.g. Expires=Thu, 01 Jan …). Use Headers.getSetCookie() (Node | ||
| // >=18) to get each cookie as its own entry. | ||
| const setCookies = headers.getSetCookie() | ||
| if (!setCookies.some((cookie) => /^epds_csrf=/.test(cookie))) { | ||
|
Check warning on line 64 in e2e/step-definitions/security.steps.ts
|
||
| throw new Error( | ||
| `Expected Set-Cookie to include epds_csrf=..., got: ${setCookies.join(', ') || '(none)'}`, | ||
| ) | ||
| } | ||
| }) | ||
|
|
||
| Then( | ||
| 'the HTML form contains a hidden CSRF token field', | ||
| function (this: EpdsWorld) { | ||
| const { body } = getCapturedResponse(this) | ||
| if (!/<input[^>]*type="hidden"[^>]*name="csrf"[^>]*>/.test(body)) { | ||
| throw new Error('HTML response has no hidden CSRF input field') | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| When( | ||
| 'a POST request is sent to the recovery endpoint without a CSRF token', | ||
| async function (this: EpdsWorld) { | ||
| const res = await fetch(`${testEnv.authUrl}/auth/recover`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, | ||
| body: new URLSearchParams({ | ||
| request_uri: 'urn:ietf:params:oauth:request_uri:req-security-probe', | ||
| email: 'noone@example.com', | ||
| }).toString(), | ||
| redirect: 'manual', | ||
| }) | ||
| const body = await res.text() | ||
| setCapturedResponse(this, { | ||
| status: res.status, | ||
| headers: res.headers, | ||
| body, | ||
| }) | ||
| }, | ||
| ) | ||
|
|
||
| Then('the response status is {int}', function (this: EpdsWorld, code: number) { | ||
| const { status } = getCapturedResponse(this) | ||
| if (status !== code) { | ||
| throw new Error(`Expected status ${code}, got ${status}`) | ||
| } | ||
| }) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Security headers scenario | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| When( | ||
| 'any page is loaded from the auth service', | ||
| async function (this: EpdsWorld) { | ||
| await captureGet(this, `${testEnv.authUrl}/health`) | ||
| }, | ||
| ) | ||
|
|
||
| Then( | ||
| 'the response includes the following security headers:', | ||
| function (this: EpdsWorld, table: DataTable) { | ||
| const { headers } = getCapturedResponse(this) | ||
| const missing: string[] = [] | ||
| for (const row of table.hashes()) { | ||
| const expected = row.value | ||
| const actual = headers.get(row.header) | ||
| if (actual !== expected) { | ||
| missing.push( | ||
| `${row.header}: expected "${expected}", got "${actual ?? '(missing)'}"`, | ||
| ) | ||
| } | ||
| } | ||
| if (missing.length) { | ||
| throw new Error(`Security header mismatch:\n ${missing.join('\n ')}`) | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // CSP scenario | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| When('the login page is loaded', async function (this: EpdsWorld) { | ||
| // /oauth/authorize on the PDS renders the auth-service login page via | ||
| // the epds-callback redirect chain, but hitting it without a valid | ||
| // request_uri triggers an error response before headers are set the | ||
| // way we want. The auth service exposes a preview route that renders | ||
| // the same login template, guarded by AUTH_PREVIEW_ROUTES. If preview | ||
| // is off, fall back to a probe of any auth-service page — the CSP | ||
| // header is applied globally by the security-headers middleware, so | ||
| // an auth-service /health response carries the same header. | ||
| const previewUrl = `${testEnv.authUrl}/preview/login` | ||
| let res = await fetch(previewUrl, { redirect: 'manual' }) | ||
| if (res.status === 404) { | ||
| res = await fetch(`${testEnv.authUrl}/health`, { redirect: 'manual' }) | ||
| } | ||
|
Comment on lines
+154
to
+157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Drain the first response body before the 404 fallback fetch. When ♻️ Suggested patch const previewUrl = `${testEnv.authUrl}/preview/login`
let res = await fetch(previewUrl, { redirect: 'manual' })
if (res.status === 404) {
+ await res.text()
res = await fetch(`${testEnv.authUrl}/health`, { redirect: 'manual' })
}
const body = await res.text()
setCapturedResponse(this, { status: res.status, headers: res.headers, body })🤖 Prompt for AI Agents |
||
| const body = await res.text() | ||
| setCapturedResponse(this, { status: res.status, headers: res.headers, body }) | ||
| }) | ||
|
|
||
| Then( | ||
| 'the Content-Security-Policy header is present', | ||
| function (this: EpdsWorld) { | ||
| const { headers } = getCapturedResponse(this) | ||
| if (!headers.get('content-security-policy')) { | ||
| throw new Error('Content-Security-Policy header is not set') | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| function getScriptSrcDirective(csp: string): string { | ||
| const match = /(?:^|;\s*)script-src\s+([^;]+)/.exec(csp) | ||
| if (!match) { | ||
| throw new Error(`CSP is missing a script-src directive: "${csp}"`) | ||
| } | ||
| return match[1].trim() | ||
| } | ||
|
|
||
| Then( | ||
| 'the script-src directive does not allow unsafe-inline', | ||
| function (this: EpdsWorld) { | ||
| const { headers } = getCapturedResponse(this) | ||
| const csp = headers.get('content-security-policy') ?? '' | ||
| const scriptSrc = getScriptSrcDirective(csp) | ||
| if (/'unsafe-inline'/.test(scriptSrc)) { | ||
| throw new Error( | ||
| `script-src directive allows 'unsafe-inline': "${scriptSrc}"`, | ||
| ) | ||
| } | ||
| }, | ||
| ) | ||
|
|
||
| function extractScriptSrcNonce(csp: string): string { | ||
| const scriptSrc = getScriptSrcDirective(csp) | ||
| const match = /'nonce-([A-Za-z0-9_-]+)'/.exec(scriptSrc) | ||
| if (!match) { | ||
| throw new Error(`script-src directive has no 'nonce-...': "${scriptSrc}"`) | ||
| } | ||
| return match[1] | ||
| } | ||
|
|
||
| Then( | ||
| 'the script-src directive carries a per-response nonce', | ||
| async function (this: EpdsWorld) { | ||
| // Assert two things: (a) the current response has a nonce-shaped | ||
| // token in script-src, and (b) the nonce is freshly generated per | ||
| // response — otherwise a hardcoded constant would pass (a) but | ||
| // defeat the whole point of a CSP nonce. Hit the same endpoint a | ||
| // second time and compare. | ||
| const { headers } = getCapturedResponse(this) | ||
| const firstCsp = headers.get('content-security-policy') ?? '' | ||
| const firstNonce = extractScriptSrcNonce(firstCsp) | ||
|
|
||
| // Under Node's fetch/undici, an un-drained response body keeps the | ||
| // socket open and can leave open-handles after the test exits. Drain | ||
| // each response's body (via .text()) before moving on, matching the | ||
| // pattern in captureGet() above. | ||
| const previewUrl = `${testEnv.authUrl}/preview/login` | ||
| let second = await fetch(previewUrl, { redirect: 'manual' }) | ||
| await second.text() | ||
| if (second.status === 404) { | ||
| second = await fetch(`${testEnv.authUrl}/health`, { redirect: 'manual' }) | ||
| await second.text() | ||
| } | ||
| const secondCsp = second.headers.get('content-security-policy') ?? '' | ||
| const secondNonce = extractScriptSrcNonce(secondCsp) | ||
| if (firstNonce === secondNonce) { | ||
| throw new Error( | ||
| `CSP nonce reused across responses: "${firstNonce}" — expected a fresh nonce per request`, | ||
| ) | ||
|
aspiers marked this conversation as resolved.
|
||
| } | ||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ) | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Metrics scenario | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| When( | ||
| 'GET \\/metrics is called on the auth service without credentials', | ||
|
Check warning on line 241 in e2e/step-definitions/security.steps.ts
|
||
| async function (this: EpdsWorld) { | ||
| await captureGet(this, `${testEnv.authUrl}/metrics`) | ||
| }, | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.