Skip to content

Commit 2755af3

Browse files
committed
feat(auth-service): nonce-based CSP and deny-by-default /metrics
Replace the auth service's Content-Security-Policy script-src 'unsafe-inline' with a per-response nonce. The security-headers middleware now generates a fresh base64url nonce on every request, stamps it into script-src, and exposes it via res.locals.cspNonce so templates can emit <script nonce="..."> for inline scripts. All inline scripts ePDS ships (login page, choose-handle page, preview index) are threaded through to read and stamp the nonce. Also tighten /metrics on the auth service: if PDS_ADMIN_PASSWORD is unset, return 401 instead of serving metrics unauthenticated, so a missing env var can't silently open the endpoint. Extract the inline security-headers middleware into its own module with dedicated unit tests (7 tests) covering the nonce contract, baseline headers, and client-origin img-src. Enable 5 previously-pending security.feature scenarios: two CSRF checks (targeting the server-rendered recovery form, which uses ePDS's own CSRF middleware rather than better-auth's), the security-headers table, the CSP check, and the metrics 401. New step definitions live in e2e/step-definitions/security.steps.ts.
1 parent 7229c22 commit 2755af3

11 files changed

Lines changed: 371 additions & 40 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'ePDS': minor
3+
---
4+
5+
Auth service tightens its Content-Security-Policy and locks down the metrics endpoint.
6+
7+
**Affects:** Operators
8+
9+
**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.
10+
11+
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.
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/**
2+
* Step definitions for security.feature. These scenarios run direct HTTP
3+
* requests (no browser) because they assert on response headers, status
4+
* codes, and raw HTML — not user interaction.
5+
*/
6+
7+
import { When, Then } from '@cucumber/cucumber'
8+
import type { DataTable } from '@cucumber/cucumber'
9+
import type { EpdsWorld } from '../support/world.js'
10+
import { testEnv } from '../support/env.js'
11+
12+
/** Response captured by the most recent direct-fetch step. */
13+
interface CapturedResponse {
14+
status: number
15+
headers: Headers
16+
body: string
17+
}
18+
19+
const capturedBySymbol = new WeakMap<EpdsWorld, CapturedResponse>()
20+
21+
function setCapturedResponse(
22+
world: EpdsWorld,
23+
response: CapturedResponse,
24+
): void {
25+
capturedBySymbol.set(world, response)
26+
world.lastHttpStatus = response.status
27+
}
28+
29+
function getCapturedResponse(world: EpdsWorld): CapturedResponse {
30+
const captured = capturedBySymbol.get(world)
31+
if (!captured) {
32+
throw new Error('No response has been captured by a prior step')
33+
}
34+
return captured
35+
}
36+
37+
async function captureGet(
38+
world: EpdsWorld,
39+
url: string,
40+
init?: RequestInit,
41+
): Promise<void> {
42+
const res = await fetch(url, { redirect: 'manual', ...init })
43+
const body = await res.text()
44+
setCapturedResponse(world, { status: res.status, headers: res.headers, body })
45+
}
46+
47+
// ---------------------------------------------------------------------------
48+
// CSRF scenarios
49+
// ---------------------------------------------------------------------------
50+
51+
When('the recovery page is loaded', async function (this: EpdsWorld) {
52+
const recoveryUrl = `${testEnv.authUrl}/auth/recover?request_uri=urn:ietf:params:oauth:request_uri:req-security-probe`
53+
await captureGet(this, recoveryUrl)
54+
})
55+
56+
Then('the response sets a CSRF cookie', function (this: EpdsWorld) {
57+
const { headers } = getCapturedResponse(this)
58+
const setCookie = headers.get('set-cookie') ?? ''
59+
if (!/epds_csrf=/.test(setCookie)) {
60+
throw new Error(
61+
`Expected Set-Cookie to include epds_csrf=..., got: ${setCookie || '(none)'}`,
62+
)
63+
}
64+
})
65+
66+
Then(
67+
'the HTML form contains a hidden CSRF token field',
68+
function (this: EpdsWorld) {
69+
const { body } = getCapturedResponse(this)
70+
if (!/<input[^>]*type="hidden"[^>]*name="csrf"[^>]*>/.test(body)) {
71+
throw new Error('HTML response has no hidden CSRF input field')
72+
}
73+
},
74+
)
75+
76+
When(
77+
'a POST request is sent to the recovery endpoint without a CSRF token',
78+
async function (this: EpdsWorld) {
79+
const res = await fetch(`${testEnv.authUrl}/auth/recover`, {
80+
method: 'POST',
81+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
82+
body: new URLSearchParams({
83+
request_uri: 'urn:ietf:params:oauth:request_uri:req-security-probe',
84+
email: 'noone@example.com',
85+
}).toString(),
86+
redirect: 'manual',
87+
})
88+
const body = await res.text()
89+
setCapturedResponse(this, {
90+
status: res.status,
91+
headers: res.headers,
92+
body,
93+
})
94+
},
95+
)
96+
97+
Then('the response status is {int}', function (this: EpdsWorld, code: number) {
98+
const { status } = getCapturedResponse(this)
99+
if (status !== code) {
100+
throw new Error(`Expected status ${code}, got ${status}`)
101+
}
102+
})
103+
104+
// ---------------------------------------------------------------------------
105+
// Security headers scenario
106+
// ---------------------------------------------------------------------------
107+
108+
When(
109+
'any page is loaded from the auth service',
110+
async function (this: EpdsWorld) {
111+
await captureGet(this, `${testEnv.authUrl}/health`)
112+
},
113+
)
114+
115+
Then(
116+
'the response includes the following security headers:',
117+
function (this: EpdsWorld, table: DataTable) {
118+
const { headers } = getCapturedResponse(this)
119+
const missing: string[] = []
120+
for (const row of table.hashes()) {
121+
const expected = row.value
122+
const actual = headers.get(row.header)
123+
if (actual !== expected) {
124+
missing.push(
125+
`${row.header}: expected "${expected}", got "${actual ?? '(missing)'}"`,
126+
)
127+
}
128+
}
129+
if (missing.length) {
130+
throw new Error(`Security header mismatch:\n ${missing.join('\n ')}`)
131+
}
132+
},
133+
)
134+
135+
// ---------------------------------------------------------------------------
136+
// CSP scenario
137+
// ---------------------------------------------------------------------------
138+
139+
When('the login page is loaded', async function (this: EpdsWorld) {
140+
// /oauth/authorize on the PDS renders the auth-service login page via
141+
// the epds-callback redirect chain, but hitting it without a valid
142+
// request_uri triggers an error response before headers are set the
143+
// way we want. The auth service exposes a preview route that renders
144+
// the same login template, guarded by AUTH_PREVIEW_ROUTES. If preview
145+
// is off, fall back to a probe of any auth-service page — the CSP
146+
// header is applied globally by the security-headers middleware, so
147+
// an auth-service /health response carries the same header.
148+
const previewUrl = `${testEnv.authUrl}/preview/login`
149+
let res = await fetch(previewUrl, { redirect: 'manual' })
150+
if (res.status === 404) {
151+
res = await fetch(`${testEnv.authUrl}/health`, { redirect: 'manual' })
152+
}
153+
const body = await res.text()
154+
setCapturedResponse(this, { status: res.status, headers: res.headers, body })
155+
})
156+
157+
Then(
158+
'the Content-Security-Policy header is present',
159+
function (this: EpdsWorld) {
160+
const { headers } = getCapturedResponse(this)
161+
if (!headers.get('content-security-policy')) {
162+
throw new Error('Content-Security-Policy header is not set')
163+
}
164+
},
165+
)
166+
167+
function getScriptSrcDirective(csp: string): string {
168+
const match = /(?:^|;\s*)script-src\s+([^;]+)/.exec(csp)
169+
if (!match) {
170+
throw new Error(`CSP is missing a script-src directive: "${csp}"`)
171+
}
172+
return match[1].trim()
173+
}
174+
175+
Then(
176+
'the script-src directive does not allow unsafe-inline',
177+
function (this: EpdsWorld) {
178+
const { headers } = getCapturedResponse(this)
179+
const csp = headers.get('content-security-policy') ?? ''
180+
const scriptSrc = getScriptSrcDirective(csp)
181+
if (/'unsafe-inline'/.test(scriptSrc)) {
182+
throw new Error(
183+
`script-src directive allows 'unsafe-inline': "${scriptSrc}"`,
184+
)
185+
}
186+
},
187+
)
188+
189+
Then(
190+
'the script-src directive carries a per-response nonce',
191+
function (this: EpdsWorld) {
192+
const { headers } = getCapturedResponse(this)
193+
const csp = headers.get('content-security-policy') ?? ''
194+
const scriptSrc = getScriptSrcDirective(csp)
195+
if (!/'nonce-[A-Za-z0-9_-]+'/.test(scriptSrc)) {
196+
throw new Error(`script-src directive has no 'nonce-...': "${scriptSrc}"`)
197+
}
198+
},
199+
)
200+
201+
// ---------------------------------------------------------------------------
202+
// Metrics scenario
203+
// ---------------------------------------------------------------------------
204+
205+
When(
206+
'GET \\/metrics is called on the auth service without credentials',
207+
async function (this: EpdsWorld) {
208+
await captureGet(this, `${testEnv.authUrl}/metrics`)
209+
},
210+
)

features/security.feature

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@ Feature: Security measures
88

99
# --- CSRF protection ---
1010

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

17-
@pending
18-
Scenario: POST without CSRF token is rejected
19-
When a POST request is sent to the OTP verification endpoint without a CSRF token
20+
Scenario: POST to a CSRF-protected route without a token is rejected
21+
When a POST request is sent to the recovery endpoint without a CSRF token
2022
Then the response status is 403
2123

2224
# --- Rate limiting ---
@@ -34,21 +36,20 @@ Feature: Security measures
3436

3537
# --- Security headers ---
3638

37-
@pending
3839
Scenario: Auth service responses include security headers
39-
When any page is loaded from the auth service via Caddy
40-
Then the response includes:
41-
| header | value |
42-
| Strict-Transport-Security | max-age=31536000 |
43-
| X-Frame-Options | DENY |
44-
| X-Content-Type-Options | nosniff |
45-
| Referrer-Policy | no-referrer |
46-
47-
@pending
48-
Scenario: Content-Security-Policy restricts inline content
40+
When any page is loaded from the auth service
41+
Then the response includes the following security headers:
42+
| header | value |
43+
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload |
44+
| X-Frame-Options | DENY |
45+
| X-Content-Type-Options | nosniff |
46+
| Referrer-Policy | no-referrer |
47+
48+
Scenario: Content-Security-Policy restricts inline scripts
4949
When the login page is loaded
5050
Then the Content-Security-Policy header is present
51-
And it does not allow unsafe-inline scripts
51+
And the script-src directive does not allow unsafe-inline
52+
And the script-src directive carries a per-response nonce
5253

5354
# --- Monitoring ---
5455

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

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

6967
# --- Same-site deployment topology (sec-fetch-site) ---
7068
#

packages/auth-service/src/__tests__/security-headers.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ describe('buildAuthServiceCsp', () => {
5353
expect(csp).toContain("style-src 'self' 'unsafe-inline'")
5454
expect(csp).toContain("connect-src 'self'")
5555
})
56+
57+
it('uses the nonce in script-src and drops unsafe-inline from script-src when nonce is supplied', () => {
58+
const csp = buildAuthServiceCsp(null, 'abc123')
59+
expect(csp).toContain("script-src 'self' 'nonce-abc123'")
60+
expect(csp).not.toContain("script-src 'self' 'unsafe-inline'")
61+
// style-src keeps 'unsafe-inline' — scoped fallback for our own stylesheets.
62+
expect(csp).toContain("style-src 'self' 'unsafe-inline'")
63+
})
64+
65+
it('still widens img-src when both client_id and nonce are supplied', () => {
66+
const csp = buildAuthServiceCsp('https://app.example.com/cm.json', 'abc123')
67+
expect(csp).toContain("img-src 'self' data: https://app.example.com")
68+
expect(csp).toContain("script-src 'self' 'nonce-abc123'")
69+
})
5670
})
5771

5872
describe('extractClientIdFromRequest', () => {
@@ -107,6 +121,7 @@ describe('createSecurityHeadersMiddleware', () => {
107121
setHeader: vi.fn((name: string, value: string) => {
108122
calls.push([name, value])
109123
}),
124+
locals: {} as Record<string, unknown>,
110125
}
111126
return { res, calls }
112127
}
@@ -163,6 +178,35 @@ describe('createSecurityHeadersMiddleware', () => {
163178
expect(csp).toContain("img-src 'self' data: https://app.example.com")
164179
})
165180

181+
it('writes a base64url nonce to res.locals.cspNonce', () => {
182+
const mw = createSecurityHeadersMiddleware()
183+
const { res } = makeRes()
184+
mw({ query: {} }, res, () => {})
185+
const nonce = res.locals.cspNonce
186+
expect(typeof nonce).toBe('string')
187+
expect(nonce as string).toMatch(/^[A-Za-z0-9_-]+$/)
188+
// 16 random bytes base64url-encoded = 22 chars (no padding).
189+
expect((nonce as string).length).toBeGreaterThanOrEqual(20)
190+
})
191+
192+
it('uses the same nonce value in script-src that it wrote to res.locals', () => {
193+
const mw = createSecurityHeadersMiddleware()
194+
const { res, calls } = makeRes()
195+
mw({ query: {} }, res, () => {})
196+
const nonce = res.locals.cspNonce as string
197+
const csp = calls.find(([name]) => name === 'Content-Security-Policy')?.[1]
198+
expect(csp).toContain(`script-src 'self' 'nonce-${nonce}'`)
199+
})
200+
201+
it('generates a fresh nonce per request', () => {
202+
const mw = createSecurityHeadersMiddleware()
203+
const first = makeRes()
204+
const second = makeRes()
205+
mw({ query: {} }, first.res, () => {})
206+
mw({ query: {} }, second.res, () => {})
207+
expect(first.res.locals.cspNonce).not.toBe(second.res.locals.cspNonce)
208+
})
209+
166210
it('prefers direct client_id over authFlowLookup', () => {
167211
const lookup = vi.fn(() => 'https://from-lookup.example/cm.json')
168212
const mw = createSecurityHeadersMiddleware({ authFlowLookup: lookup })

packages/auth-service/src/index.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -80,17 +80,21 @@ export function createAuthService(config: AuthServiceConfig): {
8080

8181
// Metrics endpoint (protect with admin auth in production)
8282
app.get('/metrics', (req, res) => {
83+
// Metrics expose process-level signal (uptime, RSS, DB counts) that
84+
// we don't want leaking unauthenticated. Deny-by-default: if no
85+
// admin password is configured, the endpoint is unavailable rather
86+
// than open.
8387
const adminPassword = process.env.PDS_ADMIN_PASSWORD
84-
if (adminPassword) {
85-
const authHeader = req.headers.authorization
86-
if (
87-
!authHeader ||
88-
authHeader !==
89-
'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64')
90-
) {
91-
res.status(401).json({ error: 'Unauthorized' })
92-
return
93-
}
88+
if (!adminPassword) {
89+
res.status(401).json({ error: 'Unauthorized' })
90+
return
91+
}
92+
const authHeader = req.headers.authorization
93+
const expected =
94+
'Basic ' + Buffer.from('admin:' + adminPassword).toString('base64')
95+
if (!authHeader || authHeader !== expected) {
96+
res.status(401).json({ error: 'Unauthorized' })
97+
return
9498
}
9599
const metrics = ctx.db.getMetrics()
96100
res.json({

0 commit comments

Comments
 (0)