Skip to content
11 changes: 11 additions & 0 deletions .changeset/csp-nonce-and-metrics-auth.md
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.
245 changes: 245 additions & 0 deletions e2e/step-definitions/security.steps.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use 'String#startsWith' method instead.

See more on https://sonarcloud.io/project/issues?id=hypercerts-org_ePDS&issues=AZ2yZ8-ZseNgnG04H3XN&open=AZ2yZ8-ZseNgnG04H3XN&pullRequest=100
throw new Error(
`Expected Set-Cookie to include epds_csrf=..., got: ${setCookies.join(', ') || '(none)'}`,
)
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Drain the first response body before the 404 fallback fetch.

When /preview/login returns 404, the first response is replaced without being consumed. Drain it before the /health fetch to avoid leaked handles in this fallback path.

♻️ 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
Verify each finding against the current code and only fix it if needed.

In `@e2e/step-definitions/security.steps.ts` around lines 154 - 157, When handling
the fallback from fetch(previewUrl) to the health endpoint, drain or cancel the
first response's body before reassigning res so the underlying handle isn't
leaked; in the block that checks if res.status === 404 (where res comes from
fetch(previewUrl)), consume or cancel the body (e.g., await res.text() or
res.arrayBuffer() or res.body?.cancel()) before calling
fetch(`${testEnv.authUrl}/health`) and reassigning res.

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`,
)
Comment thread
aspiers marked this conversation as resolved.
}
},
Comment thread
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=hypercerts-org_ePDS&issues=AZ2svryjMEXeMQGE_I6P&open=AZ2svryjMEXeMQGE_I6P&pullRequest=100
async function (this: EpdsWorld) {
await captureGet(this, `${testEnv.authUrl}/metrics`)
},
)
40 changes: 19 additions & 21 deletions features/security.feature
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@ Feature: Security measures

# --- CSRF protection ---

@pending
Scenario: Forms include CSRF protection
When the login page is loaded
Scenario: Server-rendered forms include a CSRF token
# The login page submits via JS fetch to better-auth, which enforces its
# own CSRF protections at the handler level. Server-rendered HTML forms
# (recovery, choose-handle, account-settings) use ePDS's CSRF middleware
# and must carry a matching hidden token field.
When the recovery page is loaded
Then the response sets a CSRF cookie
And the HTML form contains a hidden CSRF token field

@pending
Scenario: POST without CSRF token is rejected
When a POST request is sent to the OTP verification endpoint without a CSRF token
Scenario: POST to a CSRF-protected route without a token is rejected
When a POST request is sent to the recovery endpoint without a CSRF token
Then the response status is 403

# --- Rate limiting ---
Expand All @@ -34,21 +36,20 @@ Feature: Security measures

# --- Security headers ---

@pending
Scenario: Auth service responses include security headers
When any page is loaded from the auth service via Caddy
Then the response includes:
| header | value |
| Strict-Transport-Security | max-age=31536000 |
| X-Frame-Options | DENY |
| X-Content-Type-Options | nosniff |
| Referrer-Policy | no-referrer |

@pending
Scenario: Content-Security-Policy restricts inline content
When any page is loaded from the auth service
Then the response includes the following security headers:
| header | value |
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
| X-Frame-Options | DENY |
| X-Content-Type-Options | nosniff |
| Referrer-Policy | no-referrer |

Scenario: Content-Security-Policy restricts inline scripts
When the login page is loaded
Then the Content-Security-Policy header is present
And it does not allow unsafe-inline scripts
And the script-src directive does not allow unsafe-inline
And the script-src directive carries a per-response nonce

# --- Monitoring ---

Expand All @@ -59,12 +60,9 @@ Feature: Security measures
When GET /health is called on the PDS core
Then it returns status 200 with { "status": "ok" }

@pending
Scenario: Metrics endpoint requires authentication
When GET /metrics is called on the auth service without credentials
Then the response status is 401
When GET /metrics is called with valid Basic auth credentials
Then the response includes uptime and memory usage metrics

# --- Same-site deployment topology (sec-fetch-site) ---
#
Expand Down
1 change: 1 addition & 0 deletions packages/auth-service/src/__tests__/login-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,7 @@ describe('renderLoginPage handle login button', () => {
pdsPublicUrl: 'https://pds.example.com',
otpLength: 6,
otpCharset: 'numeric',
cspNonce: 'test-nonce',
})
}

Expand Down
Loading
Loading