Skip to content

Commit a3c6d11

Browse files
committed
perf(router): avoid parsing unrelated CSRF cookies
1 parent 3b7635b commit a3c6d11

2 files changed

Lines changed: 83 additions & 11 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { beforeEach, describe, expect, test } from 'bun:test'
2+
import { validateCsrfRequest } from '../../../defaults/app/Middleware/Csrf'
3+
import { clearMiddlewareCache, createStacksRouter } from '../src/stacks-router'
4+
5+
const token = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
6+
7+
beforeEach(() => {
8+
clearMiddlewareCache()
9+
})
10+
11+
describe('native CSRF request enforcement', () => {
12+
test.each([
13+
{ label: 'matching header', headers: { cookie: `X-CSRF-Token=${token}`, 'x-csrf-token': token }, body: {}, status: 200 },
14+
{ label: 'form token', headers: { cookie: `X-CSRF-Token=${token}` }, body: { _token: token }, status: 200 },
15+
{ label: 'legacy token', headers: { cookie: `csrf-token=${token}` }, body: { csrf_token: token }, status: 200 },
16+
{ label: 'bearer exemption', headers: { authorization: 'Bearer test-credential' }, body: {}, status: 200 },
17+
{ label: 'missing pair', headers: {}, body: {}, status: 403 },
18+
{ label: 'missing cookie', headers: { 'x-csrf-token': token }, body: {}, status: 403 },
19+
{ label: 'missing submission', headers: { cookie: `X-CSRF-Token=${token}` }, body: {}, status: 403 },
20+
{ label: 'mismatched header', headers: { cookie: `X-CSRF-Token=${token}`, 'x-csrf-token': 'bad-token' }, body: { _token: token }, status: 403 },
21+
{ label: 'non-string body token', headers: { cookie: `X-CSRF-Token=${token}` }, body: { _token: [token] }, status: 403 },
22+
{ label: 'last duplicate wins', headers: { cookie: `X-CSRF-Token=wrong; X-CSRF-Token=${token}`, 'x-csrf-token': token }, body: {}, status: 200 },
23+
{ label: 'last duplicate rejects', headers: { cookie: `X-CSRF-Token=${token}; X-CSRF-Token=wrong`, 'x-csrf-token': token }, body: {}, status: 403 },
24+
{ label: 'canonical beats legacy', headers: { cookie: `X-CSRF-Token=wrong; csrf-token=${token}`, 'x-csrf-token': token }, body: {}, status: 403 },
25+
{ label: 'empty canonical uses legacy', headers: { cookie: `csrf-token=${token}; X-CSRF-Token=`, 'x-csrf-token': token }, body: {}, status: 200 },
26+
{ label: 'legacy duplicate rejects', headers: { cookie: `csrf-token=${token}; csrf-token=wrong`, 'x-csrf-token': token }, body: {}, status: 403 },
27+
{ label: 'exact cookie name required', headers: { cookie: `prefixX-CSRF-Token=${token}; X-CSRF-Token-suffix=${token}`, 'x-csrf-token': token }, body: {}, status: 403 },
28+
{ label: 'whitespace and malformed pairs', headers: { cookie: `other=a=b; malformed; ; X-CSRF-Token = ${token} ; theme=dark`, 'x-csrf-token': token }, body: {}, status: 200 },
29+
])('$label', async ({ headers, body, status }) => {
30+
const router = createStacksRouter()
31+
let handlerRuns = 0
32+
router.post('/native-csrf', () => {
33+
handlerRuns++
34+
return { ok: true }
35+
})
36+
37+
// Exercise both the first module load and the cached middleware path.
38+
for (let attempt = 0; attempt < 2; attempt++) {
39+
const response = await router.handleRequest(new Request('http://localhost/native-csrf', {
40+
method: 'POST',
41+
headers: { ...headers, 'content-type': 'application/json' },
42+
body: JSON.stringify(body),
43+
}))
44+
expect(response.status).toBe(status)
45+
expect(handlerRuns).toBe(status === 200 ? attempt + 1 : 0)
46+
const payload = await response.json()
47+
if (status === 200)
48+
expect(payload).toEqual({ ok: true })
49+
else
50+
expect(payload.message).toBe('CSRF token mismatch')
51+
}
52+
})
53+
})
54+
55+
describe('standalone CSRF validator promise contract', () => {
56+
test('successful validation returns a promise callers can chain', async () => {
57+
const request = new Request('http://localhost/native-csrf', {
58+
method: 'POST',
59+
headers: { cookie: `X-CSRF-Token=${token}`, 'x-csrf-token': token },
60+
})
61+
expect(await validateCsrfRequest(request).then(() => 'accepted')).toBe('accepted')
62+
})
63+
64+
test('invalid requests reject the promise instead of throwing at invocation', async () => {
65+
const request = new Request('http://localhost/native-csrf', { method: 'POST' })
66+
const validation = validateCsrfRequest(request)
67+
await expect(validation).rejects.toThrow('CSRF token mismatch')
68+
})
69+
})

storage/framework/defaults/app/Middleware/Csrf.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -168,21 +168,25 @@ export function seedCsrfCookieIfMissing(req: Request, response: Response, minted
168168
}
169169

170170
/**
171-
* Parse the Cookie header into a key→value map.
172-
* Lenient: malformed pairs are skipped, not thrown.
171+
* Read just the CSRF cookies without building a map of unrelated cookies.
172+
* Last duplicate wins; the canonical name takes precedence over the legacy
173+
* name unless its final value is empty. Malformed pairs are skipped.
173174
*/
174-
function parseCookies(req: Request): Record<string, string> {
175+
function csrfCookieToken(req: Request): string {
175176
const header = req.headers.get('cookie')
176-
if (!header) return {}
177-
const out: Record<string, string> = {}
177+
if (!header) return ''
178+
let canonical = ''
179+
let legacy = ''
178180
for (const part of header.split(';')) {
179181
const idx = part.indexOf('=')
180182
if (idx === -1) continue
181-
const k = part.slice(0, idx).trim()
182-
const v = part.slice(idx + 1).trim()
183-
if (k) out[k] = v
183+
const name = part.slice(0, idx).trim()
184+
if (name === CSRF_COOKIE_NAME)
185+
canonical = part.slice(idx + 1).trim()
186+
else if (name === 'csrf-token')
187+
legacy = part.slice(idx + 1).trim()
184188
}
185-
return out
189+
return canonical || legacy
186190
}
187191

188192
/**
@@ -256,8 +260,7 @@ export async function validateCsrfRequest(request: Request | EnhancedRequest): P
256260
|| (typeof bodyToken === 'string' && bodyToken)
257261
|| ''
258262

259-
const cookies = parseCookies(request)
260-
const cookieToken = cookies[CSRF_COOKIE_NAME] || cookies['csrf-token'] || ''
263+
const cookieToken = csrfCookieToken(request)
261264

262265
if (!submitted || !cookieToken || !safeEqual(submitted, cookieToken)) {
263266
// 419 is the convention Laravel popularized for "CSRF token

0 commit comments

Comments
 (0)