|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import { createProtection } from '../../src/protect/runtime.js'; |
| 3 | + |
| 4 | +// `cors_reflected` (response phase): flags a response that allows credentials AND reflects the |
| 5 | +// caller's Origin (or uses `*`) into Access-Control-Allow-Origin — letting any site read the |
| 6 | +// authenticated response. Enabled by threading the request into the response phase. Authored + |
| 7 | +// route-scoped (not a default). |
| 8 | + |
| 9 | +const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} }; |
| 10 | +const rule = (when?: any) => ({ |
| 11 | + phase: 'response', |
| 12 | + category: 'cors', |
| 13 | + action: 'block', |
| 14 | + ...(when ? { when } : {}), |
| 15 | + rule_v2: [{ match: { type: 'cors_reflected' } }], |
| 16 | +}); |
| 17 | +const resp = (acao: string | null, acac: string | null = 'true') => { |
| 18 | + const headers: Record<string, string> = { 'content-type': 'application/json' }; |
| 19 | + if (acao !== null) headers['access-control-allow-origin'] = acao; |
| 20 | + if (acac !== null) headers['access-control-allow-credentials'] = acac; |
| 21 | + return new Response(JSON.stringify({ secret: 'data' }), { status: 200, headers }); |
| 22 | +}; |
| 23 | +const req = (origin?: string) => |
| 24 | + new Request('https://app.example.com/api', { headers: origin ? { origin } : {} }); |
| 25 | +const setup = (when?: any) => |
| 26 | + createProtection({ rules: emptyBundle, responseRules: [rule(when)], mode: 'block' }); |
| 27 | + |
| 28 | +describe('cors_reflected — CORS-misconfiguration detection', () => { |
| 29 | + it('blocks a credentialed response that reflects the caller Origin', async () => { |
| 30 | + const p: any = await setup(); |
| 31 | + const out = await p.screenResponse(resp('https://evil.com'), req('https://evil.com')); |
| 32 | + expect(out.status).toBe(500); // withheld — the cross-origin read is prevented |
| 33 | + }); |
| 34 | + |
| 35 | + it('blocks a credentialed wildcard (ACAO: *) response', async () => { |
| 36 | + const p: any = await setup(); |
| 37 | + const out = await p.screenResponse(resp('*'), req('https://evil.com')); |
| 38 | + expect(out.status).toBe(500); |
| 39 | + }); |
| 40 | + |
| 41 | + it('allows a fixed (non-reflected) allowlisted origin with credentials', async () => { |
| 42 | + const p: any = await setup(); |
| 43 | + const out = await p.screenResponse(resp('https://trusted.example.com'), req('https://evil.com')); |
| 44 | + expect(out.status).toBe(200); // fixed allowlist ≠ caller Origin → safe |
| 45 | + }); |
| 46 | + |
| 47 | + it('allows reflection WITHOUT credentials (not the dangerous combination)', async () => { |
| 48 | + const p: any = await setup(); |
| 49 | + const out = await p.screenResponse(resp('https://evil.com', 'false'), req('https://evil.com')); |
| 50 | + expect(out.status).toBe(200); |
| 51 | + }); |
| 52 | + |
| 53 | + it('allows a response with no CORS headers', async () => { |
| 54 | + const p: any = await setup(); |
| 55 | + const out = await p.screenResponse(resp(null, null), req('https://evil.com')); |
| 56 | + expect(out.status).toBe(200); |
| 57 | + }); |
| 58 | + |
| 59 | + it('honours `when` route scope', async () => { |
| 60 | + const p: any = await setup({ path: '/api' }); |
| 61 | + const onScope = await p.screenResponse(resp('*'), req('https://evil.com')); |
| 62 | + expect(onScope.status).toBe(500); |
| 63 | + // same misconfig on a different route → out of scope → allowed |
| 64 | + const other = new Response('{}', { |
| 65 | + status: 200, |
| 66 | + headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'access-control-allow-credentials': 'true' }, |
| 67 | + }); |
| 68 | + const offScope = await p.screenResponse(other, new Request('https://app.example.com/other', { headers: { origin: 'https://evil.com' } })); |
| 69 | + expect(offScope.status).toBe(200); |
| 70 | + }); |
| 71 | +}); |
0 commit comments