|
| 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 | +}) |
0 commit comments