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
26 changes: 26 additions & 0 deletions src/protect/engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.)
Expand Down
6 changes: 5 additions & 1 deletion src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
60 changes: 60 additions & 0 deletions tests/protect/open-redirect.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading