Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/protect/engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.)
Expand Down
71 changes: 71 additions & 0 deletions tests/protect/cors-reflection.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { '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);
});
});
Loading