Skip to content

Commit a1d4f98

Browse files
feat(protect): cors_reflected match type for CORS-misconfiguration detection
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 <noreply@anthropic.com>
1 parent f796704 commit a1d4f98

2 files changed

Lines changed: 98 additions & 0 deletions

File tree

src/protect/engine/engine.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,26 @@ function isOffOriginRedirect(resolver) {
222222
}
223223
}
224224

225+
// CORS-reflection primitive (response phase): the response allows credentials AND lets any origin
226+
// read it — either `Access-Control-Allow-Origin: *`, or it reflects the caller's own Origin (so
227+
// every origin is allowed) — rather than a fixed allowlisted origin. That combination lets any
228+
// malicious site read the authenticated response. Needs the request Origin (threaded via reqCtx).
229+
// Lenient: credentials not allowed, no ACAO, or a fixed (non-reflected, non-*) ACAO → not flagged.
230+
function isReflectedCorsWithCredentials(resolver) {
231+
try {
232+
const acac = String(resolver.resolve('response.header.access-control-allow-credentials')[0] ?? '').toLowerCase();
233+
if (acac !== 'true') return false; // only dangerous when credentials are allowed
234+
const acao = String(resolver.resolve('response.header.access-control-allow-origin')[0] ?? '');
235+
if (!acao) return false;
236+
if (acao === '*') return true; // wildcard + credentials
237+
const origin = String(resolver.resolve('server.HTTP_ORIGIN')[0] ?? '');
238+
if (!origin) return false;
239+
return acao === origin; // ACAO echoes the caller's Origin → any origin is allowed
240+
} catch {
241+
return false;
242+
}
243+
}
244+
225245
// `matchObj` is the full match object; needed by types that read sibling fields
226246
// (array_key_value reads `key`/`match`). Optional so direct callers/tests can keep
227247
// using the (type, value, matchVal) signature.
@@ -476,6 +496,13 @@ export class RuleEngine {
476496
return isOffOriginRedirect(resolver);
477497
}
478498

499+
// `cors_reflected` (response phase): true (→ block) when the response allows credentials and
500+
// reflects the caller's Origin (or uses `*`) — the CORS-misconfiguration primitive. Needs the
501+
// whole resolver (request Origin vs response ACAO/ACAC).
502+
if (match && match.type === 'cors_reflected') {
503+
return isReflectedCorsWithCredentials(resolver);
504+
}
505+
479506
// `parameter` may be an array (e.g. ["get.action","post.action"]) — the rule_v2 format
480507
// uses these pervasively to mean "any of these sources". Resolve each and OR the
481508
// candidate values together. (A bare string resolves as a single source.)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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

Comments
 (0)