Skip to content

Commit 82e5bf3

Browse files
aspiersclaude
andcommitted
fix(shared): timingSafeEqual byte-length check + CodeRabbit follow-ups
- shared.timingSafeEqual: compare UTF-8 byte lengths, not JS code units. Previously a non-ASCII input with the same .length as the expected value would reach crypto.timingSafeEqual with different byte lengths and throw RangeError — turning a 401 into a 500. Guards every caller (metrics-auth, recovery, callback signatures). - metrics-auth: regression test for non-ASCII Authorization header with matching code-unit length. - security.steps.ts: Headers.getSetCookie() for reliable multi-cookie access; nonce step now fetches twice and asserts nonces differ, so a hardcoded constant would no longer pass. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 43ff665 commit 82e5bf3

4 files changed

Lines changed: 79 additions & 12 deletions

File tree

e2e/step-definitions/security.steps.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,15 @@ When('the recovery page is loaded', async function (this: EpdsWorld) {
5555

5656
Then('the response sets a CSRF cookie', function (this: EpdsWorld) {
5757
const { headers } = getCapturedResponse(this)
58-
const setCookie = headers.get('set-cookie') ?? ''
59-
if (!/epds_csrf=/.test(setCookie)) {
58+
// `headers.get('set-cookie')` collapses multiple Set-Cookie values
59+
// into a single comma-separated string in the Fetch API, which is
60+
// ambiguous because cookie values may themselves contain commas
61+
// (e.g. Expires=Thu, 01 Jan …). Use Headers.getSetCookie() (Node
62+
// >=18) to get each cookie as its own entry.
63+
const setCookies = headers.getSetCookie()
64+
if (!setCookies.some((cookie) => /^epds_csrf=/.test(cookie))) {
6065
throw new Error(
61-
`Expected Set-Cookie to include epds_csrf=..., got: ${setCookie || '(none)'}`,
66+
`Expected Set-Cookie to include epds_csrf=..., got: ${setCookies.join(', ') || '(none)'}`,
6267
)
6368
}
6469
})
@@ -186,14 +191,38 @@ Then(
186191
},
187192
)
188193

194+
function extractScriptSrcNonce(csp: string): string {
195+
const scriptSrc = getScriptSrcDirective(csp)
196+
const match = /'nonce-([A-Za-z0-9_-]+)'/.exec(scriptSrc)
197+
if (!match) {
198+
throw new Error(`script-src directive has no 'nonce-...': "${scriptSrc}"`)
199+
}
200+
return match[1]
201+
}
202+
189203
Then(
190204
'the script-src directive carries a per-response nonce',
191-
function (this: EpdsWorld) {
205+
async function (this: EpdsWorld) {
206+
// Assert two things: (a) the current response has a nonce-shaped
207+
// token in script-src, and (b) the nonce is freshly generated per
208+
// response — otherwise a hardcoded constant would pass (a) but
209+
// defeat the whole point of a CSP nonce. Hit the same endpoint a
210+
// second time and compare.
192211
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}"`)
212+
const firstCsp = headers.get('content-security-policy') ?? ''
213+
const firstNonce = extractScriptSrcNonce(firstCsp)
214+
215+
const previewUrl = `${testEnv.authUrl}/preview/login`
216+
let second = await fetch(previewUrl, { redirect: 'manual' })
217+
if (second.status === 404) {
218+
second = await fetch(`${testEnv.authUrl}/health`, { redirect: 'manual' })
219+
}
220+
const secondCsp = second.headers.get('content-security-policy') ?? ''
221+
const secondNonce = extractScriptSrcNonce(secondCsp)
222+
if (firstNonce === secondNonce) {
223+
throw new Error(
224+
`CSP nonce reused across responses: "${firstNonce}" — expected a fresh nonce per request`,
225+
)
197226
}
198227
},
199228
)

packages/auth-service/src/__tests__/metrics-auth.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,19 @@ describe('checkMetricsAuth', () => {
7272
const r = checkMetricsAuth(samelengthWrong, adminPassword)
7373
expect(r.ok).toBe(false)
7474
})
75+
76+
it('rejects a non-ASCII header with the same code-unit length as expected', () => {
77+
// Guard against the timingSafeEqual footgun where an attacker
78+
// can trigger RangeError (500) instead of 401 by sending a
79+
// header whose JS .length matches but whose UTF-8 byte length
80+
// does not (e.g. multibyte chars). Must return 401, not throw.
81+
const expected = basic('admin', adminPassword)
82+
const sameCodeUnitsWrong = 'é'.repeat(expected.length)
83+
expect(() =>
84+
checkMetricsAuth(sameCodeUnitsWrong, adminPassword),
85+
).not.toThrow()
86+
const r = checkMetricsAuth(sameCodeUnitsWrong, adminPassword)
87+
expect(r.ok).toBe(false)
88+
})
7589
})
7690
})

packages/shared/src/__tests__/crypto.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ describe('timingSafeEqual', () => {
5757
it('returns false for different lengths', () => {
5858
expect(timingSafeEqual('short', 'longer-string')).toBe(false)
5959
})
60+
61+
it('returns false (no throw) for inputs of equal code-unit length but different byte length', () => {
62+
// Guards against a regression where the length check was done in
63+
// JavaScript code units (a.length) but the underlying
64+
// crypto.timingSafeEqual compares UTF-8 byte length, which throws
65+
// RangeError when the two differ. A non-ASCII attacker-supplied
66+
// value trivially hits this path.
67+
const ascii = 'x'.repeat(30)
68+
const nonAscii = 'é'.repeat(30)
69+
expect(() => timingSafeEqual(nonAscii, ascii)).not.toThrow()
70+
expect(timingSafeEqual(nonAscii, ascii)).toBe(false)
71+
})
6072
})
6173

6274
describe('verifyInternalSecret', () => {

packages/shared/src/crypto.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,26 @@ export function hashToken(token: string): string {
2424
return crypto.createHash('sha256').update(token).digest('hex')
2525
}
2626

27-
/** Timing-safe comparison of two strings. */
27+
/**
28+
* Timing-safe comparison of two strings.
29+
*
30+
* Encodes both inputs as UTF-8 Buffers and compares by byte length.
31+
* Comparing `a.length !== b.length` (JavaScript code units) is not
32+
* sufficient: a non-ASCII input with the same code-unit count as the
33+
* expected value has a different UTF-8 byte length, and
34+
* `crypto.timingSafeEqual` throws `RangeError` when byte lengths
35+
* differ. This matters for any caller that feeds attacker-controlled
36+
* text (HTTP headers, cookies) in as `a`.
37+
*/
2838
export function timingSafeEqual(a: string, b: string): boolean {
29-
if (a.length !== b.length) {
30-
const dummy = Buffer.alloc(a.length)
39+
const ab = Buffer.from(a)
40+
const bb = Buffer.from(b)
41+
if (ab.length !== bb.length) {
42+
const dummy = Buffer.alloc(bb.length)
3143
crypto.timingSafeEqual(dummy, dummy)
3244
return false
3345
}
34-
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
46+
return crypto.timingSafeEqual(ab, bb)
3547
}
3648

3749
/**

0 commit comments

Comments
 (0)