diff --git a/src/protect/engine/engine.js b/src/protect/engine/engine.js index 141d9df..4bef3ab 100644 --- a/src/protect/engine/engine.js +++ b/src/protect/engine/engine.js @@ -202,6 +202,26 @@ function hostFromUrl(value) { } } +// Open-redirect primitive (response phase): a 3xx whose Location header points to a DIFFERENT origin +// than the request's own Host. A relative Location (same-origin) never matches. Needs the request +// Host, which the response phase threads in via reqCtx. Lenient: no Location, no request Host, or a +// same-origin / relative target → not flagged (so it can't false-positive without the signal). +function isOffOriginRedirect(resolver) { + try { + const status = Number(resolver.resolve('response.status')[0] ?? 0); + if (status < 300 || status >= 400) return false; + const location = resolver.resolve('response.header.location')[0]; + if (!location) return false; + const target = hostFromUrl(String(location)); // null for a relative (same-origin) Location + if (target === null) return false; + const host = String(resolver.resolve('server.HTTP_HOST')[0] ?? '').toLowerCase(); + if (!host) return false; + return target !== host; + } 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. @@ -450,6 +470,12 @@ export class RuleEngine { return isCrossOrigin(resolver); } + // `off_origin` (response phase): true (→ block) when a 3xx redirects to a different origin than + // the request Host — the open-redirect primitive. Like cross_origin, it needs the whole resolver. + if (match && match.type === 'off_origin') { + return isOffOriginRedirect(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/src/protect/runtime.js b/src/protect/runtime.js index b6128c8..319b3b0 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -239,7 +239,11 @@ export async function createProtection(options = {}) { const reqContextFromFetch = (request) => { try { const u = new URL(request.url); - return { method: request.method, originalUrl: u.pathname + u.search, headers: headerObject(request.headers) }; + const headers = headerObject(request.headers); + // A fetch Request doesn't expose the Host header (it's set at send time), so derive it from the + // URL — response rules that compare origins (open-redirect / CORS) need the request Host. + if (!headers.host) headers.host = u.host; + return { method: request.method, originalUrl: u.pathname + u.search, headers }; } catch { return undefined; } diff --git a/tests/protect/open-redirect.test.ts b/tests/protect/open-redirect.test.ts new file mode 100644 index 0000000..f6b5a0b --- /dev/null +++ b/tests/protect/open-redirect.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// `off_origin` (response phase): flags a 3xx whose Location points to a different origin than the +// request Host — the open-redirect primitive, enabled by threading the request into the response +// phase. Not a default (many apps redirect off-site legitimately); authored + route-scoped. + +const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} }; +const rule = (when?: any) => ({ + phase: 'response', + category: 'open-redirect', + action: 'block', + ...(when ? { when } : {}), + rule_v2: [{ match: { type: 'off_origin' } }], +}); +const redirect = (location: string, status = 302) => new Response(null, { status, headers: { location } }); +const req = (url: string) => new Request(url); +const setup = (when?: any) => + createProtection({ rules: emptyBundle, responseRules: [rule(when)], mode: 'block' }); + +describe('off_origin — open-redirect detection', () => { + it('blocks a 3xx that redirects to a different origin', async () => { + const p: any = await setup(); + const out = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/go')); + expect(out.status).toBe(500); // redirect withheld + }); + + it('allows a same-origin absolute redirect', async () => { + const p: any = await setup(); + const out = await p.screenResponse(redirect('https://app.example.com/dashboard'), req('https://app.example.com/go')); + expect(out.status).toBe(302); + expect(out.headers.get('location')).toBe('https://app.example.com/dashboard'); + }); + + it('allows a relative (same-origin) redirect', async () => { + const p: any = await setup(); + const out = await p.screenResponse(redirect('/dashboard'), req('https://app.example.com/go')); + expect(out.status).toBe(302); + }); + + it('does not flag a non-3xx response that carries a Location header', async () => { + const p: any = await setup(); + const out = await p.screenResponse(redirect('https://evil.com/x', 200), req('https://app.example.com/go')); + expect(out.status).toBe(200); + }); + + it('is lenient with no request context (no Host to compare against)', async () => { + const p: any = await setup(); + const out = await p.screenResponse(redirect('https://evil.com/x')); // no request passed + expect(out.status).toBe(302); + }); + + it('honours `when` route scope — blocks on the scoped route only', async () => { + const p: any = await setup({ path: '/go' }); + const onScope = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/go')); + expect(onScope.status).toBe(500); + const offScope = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/elsewhere')); + expect(offScope.status).toBe(302); + }); +});