Skip to content

Commit f796704

Browse files
feat(protect): off_origin match type for open-redirect detection (#109)
Add an `off_origin` response-phase primitive: true (→ block) when a 3xx redirect's Location header points to a different origin than the request's own Host. Relative / same-origin Locations never match; lenient when the request Host is unknown. This is the first capability unlocked by threading the request into the response phase (#106). Also derive the request Host from the URL in reqContextFromFetch — a fetch Request doesn't expose a Host header, and origin-comparing response rules (open-redirect, later CORS) need it. Not a default rule (many apps redirect off-site legitimately); it's authored and route-scoped via `when`. +6 tests; 632 pass; typecheck clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 2797318 commit f796704

3 files changed

Lines changed: 91 additions & 1 deletion

File tree

src/protect/engine/engine.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,26 @@ function hostFromUrl(value) {
202202
}
203203
}
204204

205+
// Open-redirect primitive (response phase): a 3xx whose Location header points to a DIFFERENT origin
206+
// than the request's own Host. A relative Location (same-origin) never matches. Needs the request
207+
// Host, which the response phase threads in via reqCtx. Lenient: no Location, no request Host, or a
208+
// same-origin / relative target → not flagged (so it can't false-positive without the signal).
209+
function isOffOriginRedirect(resolver) {
210+
try {
211+
const status = Number(resolver.resolve('response.status')[0] ?? 0);
212+
if (status < 300 || status >= 400) return false;
213+
const location = resolver.resolve('response.header.location')[0];
214+
if (!location) return false;
215+
const target = hostFromUrl(String(location)); // null for a relative (same-origin) Location
216+
if (target === null) return false;
217+
const host = String(resolver.resolve('server.HTTP_HOST')[0] ?? '').toLowerCase();
218+
if (!host) return false;
219+
return target !== host;
220+
} catch {
221+
return false;
222+
}
223+
}
224+
205225
// `matchObj` is the full match object; needed by types that read sibling fields
206226
// (array_key_value reads `key`/`match`). Optional so direct callers/tests can keep
207227
// using the (type, value, matchVal) signature.
@@ -450,6 +470,12 @@ export class RuleEngine {
450470
return isCrossOrigin(resolver);
451471
}
452472

473+
// `off_origin` (response phase): true (→ block) when a 3xx redirects to a different origin than
474+
// the request Host — the open-redirect primitive. Like cross_origin, it needs the whole resolver.
475+
if (match && match.type === 'off_origin') {
476+
return isOffOriginRedirect(resolver);
477+
}
478+
453479
// `parameter` may be an array (e.g. ["get.action","post.action"]) — the rule_v2 format
454480
// uses these pervasively to mean "any of these sources". Resolve each and OR the
455481
// candidate values together. (A bare string resolves as a single source.)

src/protect/runtime.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,11 @@ export async function createProtection(options = {}) {
243243
const reqContextFromFetch = (request) => {
244244
try {
245245
const u = new URL(request.url);
246-
return { method: request.method, originalUrl: u.pathname + u.search, headers: headerObject(request.headers) };
246+
const headers = headerObject(request.headers);
247+
// A fetch Request doesn't expose the Host header (it's set at send time), so derive it from the
248+
// URL — response rules that compare origins (open-redirect / CORS) need the request Host.
249+
if (!headers.host) headers.host = u.host;
250+
return { method: request.method, originalUrl: u.pathname + u.search, headers };
247251
} catch {
248252
return undefined;
249253
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createProtection } from '../../src/protect/runtime.js';
3+
4+
// `off_origin` (response phase): flags a 3xx whose Location points to a different origin than the
5+
// request Host — the open-redirect primitive, enabled by threading the request into the response
6+
// phase. Not a default (many apps redirect off-site legitimately); authored + route-scoped.
7+
8+
const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} };
9+
const rule = (when?: any) => ({
10+
phase: 'response',
11+
category: 'open-redirect',
12+
action: 'block',
13+
...(when ? { when } : {}),
14+
rule_v2: [{ match: { type: 'off_origin' } }],
15+
});
16+
const redirect = (location: string, status = 302) => new Response(null, { status, headers: { location } });
17+
const req = (url: string) => new Request(url);
18+
const setup = (when?: any) =>
19+
createProtection({ rules: emptyBundle, responseRules: [rule(when)], mode: 'block' });
20+
21+
describe('off_origin — open-redirect detection', () => {
22+
it('blocks a 3xx that redirects to a different origin', async () => {
23+
const p: any = await setup();
24+
const out = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/go'));
25+
expect(out.status).toBe(500); // redirect withheld
26+
});
27+
28+
it('allows a same-origin absolute redirect', async () => {
29+
const p: any = await setup();
30+
const out = await p.screenResponse(redirect('https://app.example.com/dashboard'), req('https://app.example.com/go'));
31+
expect(out.status).toBe(302);
32+
expect(out.headers.get('location')).toBe('https://app.example.com/dashboard');
33+
});
34+
35+
it('allows a relative (same-origin) redirect', async () => {
36+
const p: any = await setup();
37+
const out = await p.screenResponse(redirect('/dashboard'), req('https://app.example.com/go'));
38+
expect(out.status).toBe(302);
39+
});
40+
41+
it('does not flag a non-3xx response that carries a Location header', async () => {
42+
const p: any = await setup();
43+
const out = await p.screenResponse(redirect('https://evil.com/x', 200), req('https://app.example.com/go'));
44+
expect(out.status).toBe(200);
45+
});
46+
47+
it('is lenient with no request context (no Host to compare against)', async () => {
48+
const p: any = await setup();
49+
const out = await p.screenResponse(redirect('https://evil.com/x')); // no request passed
50+
expect(out.status).toBe(302);
51+
});
52+
53+
it('honours `when` route scope — blocks on the scoped route only', async () => {
54+
const p: any = await setup({ path: '/go' });
55+
const onScope = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/go'));
56+
expect(onScope.status).toBe(500);
57+
const offScope = await p.screenResponse(redirect('https://evil.com/x'), req('https://app.example.com/elsewhere'));
58+
expect(offScope.status).toBe(302);
59+
});
60+
});

0 commit comments

Comments
 (0)