From a1d4f980eecfad7343219c0926ac66df30bfbb40 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Wed, 12 Aug 2026 13:24:07 +0200 Subject: [PATCH] feat(protect): cors_reflected match type for CORS-misconfiguration detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a `cors_reflected` response-phase primitive (dispatched like cross_origin/off_origin): true (→ block) when a response allows credentials AND either uses `Access-Control-Allow-Origin: *` or reflects the caller's own Origin — the combination that lets any malicious site read the authenticated response. A fixed allowlisted origin, or reflection without credentials, is not flagged. Uses the request Origin threaded into the response phase (#106). Third origin-comparison primitive after cross_origin (CSRF) and off_origin (open-redirect). Not a default; authored + route-scoped via `when`. +6 tests; 632 pass; typecheck clean. Co-Authored-By: Claude Opus 4.8 --- src/protect/engine/engine.js | 27 ++++++++++ tests/protect/cors-reflection.test.ts | 71 +++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/protect/cors-reflection.test.ts diff --git a/src/protect/engine/engine.js b/src/protect/engine/engine.js index 4bef3ab..0bd56f7 100644 --- a/src/protect/engine/engine.js +++ b/src/protect/engine/engine.js @@ -222,6 +222,26 @@ function isOffOriginRedirect(resolver) { } } +// CORS-reflection primitive (response phase): the response allows credentials AND lets any origin +// read it — either `Access-Control-Allow-Origin: *`, or it reflects the caller's own Origin (so +// every origin is allowed) — rather than a fixed allowlisted origin. That combination lets any +// malicious site read the authenticated response. Needs the request Origin (threaded via reqCtx). +// Lenient: credentials not allowed, no ACAO, or a fixed (non-reflected, non-*) ACAO → not flagged. +function isReflectedCorsWithCredentials(resolver) { + try { + const acac = String(resolver.resolve('response.header.access-control-allow-credentials')[0] ?? '').toLowerCase(); + if (acac !== 'true') return false; // only dangerous when credentials are allowed + const acao = String(resolver.resolve('response.header.access-control-allow-origin')[0] ?? ''); + if (!acao) return false; + if (acao === '*') return true; // wildcard + credentials + const origin = String(resolver.resolve('server.HTTP_ORIGIN')[0] ?? ''); + if (!origin) return false; + return acao === origin; // ACAO echoes the caller's Origin → any origin is allowed + } catch { + return false; + } +} + // `matchObj` is the full match object; needed by types that read sibling fields // (array_key_value reads `key`/`match`). Optional so direct callers/tests can keep // using the (type, value, matchVal) signature. @@ -476,6 +496,13 @@ export class RuleEngine { return isOffOriginRedirect(resolver); } + // `cors_reflected` (response phase): true (→ block) when the response allows credentials and + // reflects the caller's Origin (or uses `*`) — the CORS-misconfiguration primitive. Needs the + // whole resolver (request Origin vs response ACAO/ACAC). + if (match && match.type === 'cors_reflected') { + return isReflectedCorsWithCredentials(resolver); + } + // `parameter` may be an array (e.g. ["get.action","post.action"]) — the rule_v2 format // uses these pervasively to mean "any of these sources". Resolve each and OR the // candidate values together. (A bare string resolves as a single source.) diff --git a/tests/protect/cors-reflection.test.ts b/tests/protect/cors-reflection.test.ts new file mode 100644 index 0000000..5f1f191 --- /dev/null +++ b/tests/protect/cors-reflection.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// `cors_reflected` (response phase): flags a response that allows credentials AND reflects the +// caller's Origin (or uses `*`) into Access-Control-Allow-Origin — letting any site read the +// authenticated response. Enabled by threading the request into the response phase. Authored + +// route-scoped (not a default). + +const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} }; +const rule = (when?: any) => ({ + phase: 'response', + category: 'cors', + action: 'block', + ...(when ? { when } : {}), + rule_v2: [{ match: { type: 'cors_reflected' } }], +}); +const resp = (acao: string | null, acac: string | null = 'true') => { + const headers: Record = { 'content-type': 'application/json' }; + if (acao !== null) headers['access-control-allow-origin'] = acao; + if (acac !== null) headers['access-control-allow-credentials'] = acac; + return new Response(JSON.stringify({ secret: 'data' }), { status: 200, headers }); +}; +const req = (origin?: string) => + new Request('https://app.example.com/api', { headers: origin ? { origin } : {} }); +const setup = (when?: any) => + createProtection({ rules: emptyBundle, responseRules: [rule(when)], mode: 'block' }); + +describe('cors_reflected — CORS-misconfiguration detection', () => { + it('blocks a credentialed response that reflects the caller Origin', async () => { + const p: any = await setup(); + const out = await p.screenResponse(resp('https://evil.com'), req('https://evil.com')); + expect(out.status).toBe(500); // withheld — the cross-origin read is prevented + }); + + it('blocks a credentialed wildcard (ACAO: *) response', async () => { + const p: any = await setup(); + const out = await p.screenResponse(resp('*'), req('https://evil.com')); + expect(out.status).toBe(500); + }); + + it('allows a fixed (non-reflected) allowlisted origin with credentials', async () => { + const p: any = await setup(); + const out = await p.screenResponse(resp('https://trusted.example.com'), req('https://evil.com')); + expect(out.status).toBe(200); // fixed allowlist ≠ caller Origin → safe + }); + + it('allows reflection WITHOUT credentials (not the dangerous combination)', async () => { + const p: any = await setup(); + const out = await p.screenResponse(resp('https://evil.com', 'false'), req('https://evil.com')); + expect(out.status).toBe(200); + }); + + it('allows a response with no CORS headers', async () => { + const p: any = await setup(); + const out = await p.screenResponse(resp(null, null), req('https://evil.com')); + expect(out.status).toBe(200); + }); + + it('honours `when` route scope', async () => { + const p: any = await setup({ path: '/api' }); + const onScope = await p.screenResponse(resp('*'), req('https://evil.com')); + expect(onScope.status).toBe(500); + // same misconfig on a different route → out of scope → allowed + const other = new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*', 'access-control-allow-credentials': 'true' }, + }); + const offScope = await p.screenResponse(other, new Request('https://app.example.com/other', { headers: { origin: 'https://evil.com' } })); + expect(offScope.status).toBe(200); + }); +});