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
53 changes: 41 additions & 12 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ export async function createProtection(options = {}) {
rule,
engine: new RuleEngine({ firewall: [rule], onError }),
redactors: rule.action === 'redact' || rule.action === 'encode' ? extractRedactors(rule) : null,
// Optional cheap pre-filter: literal anchor(s) that MUST appear for the (expensive) regex to
// have any chance of matching. Lets screenText skip the full scan on bodies with no candidate —
// the common case — cutting CPU/latency and shrinking the regex/ReDoS surface. Case-insensitive.
prefilter: Array.isArray(rule.prefilter) && rule.prefilter.length
? rule.prefilter.map((s) => String(s).toLowerCase())
: null,
}));
egressEngine = new RuleEngine({ firewall: egressRules, onError });
};
Expand Down Expand Up @@ -168,13 +174,24 @@ export async function createProtection(options = {}) {
ct = (ct || '').toLowerCase();
return ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
};
const screenText = (text, meta) => {
const screenText = (text, meta, reqCtx) => {
let blockRule = null;
const redactions = [];
for (const { rule, engine: re, redactors } of responseRuleSet) {
let lowerText = null; // lazily lowercased body, only if a rule uses a prefilter
for (const { rule, engine: re, redactors, prefilter } of responseRuleSet) {
// Cheap pre-filter: if none of the rule's literal anchors is in the body, its regex can't
// match — skip the full scan (the common no-secret case) before touching the engine.
if (prefilter) {
if (lowerText === null) lowerText = text.toLowerCase();
if (!prefilter.some((p) => lowerText.includes(p))) continue;
}
let result;
try {
result = re.evaluate({ _response: { ...meta, body: text } });
// Spread the originating request (method / originalUrl / headers) alongside the response,
// so a response rule's `when` route/method scope resolves against the REAL request and so
// request Host/Origin are visible to response rules — rather than the phantom empty request
// the response phase used to build (which made `when` on a response rule inert).
result = re.evaluate({ ...(reqCtx || {}), _response: { ...meta, body: text } });
} catch (err) {
onError?.(err);
continue;
Expand Down Expand Up @@ -217,11 +234,23 @@ export async function createProtection(options = {}) {
return { verdict: 'redact', body, headers };
};

// Minimal request context for the response phase: what a response rule's `when` scope and any
// request-header reference (Host/Origin) need — method, path, and request headers. No body.
const reqContextFromFetch = (request) => {
try {
const u = new URL(request.url);
return { method: request.method, originalUrl: u.pathname + u.search, headers: headerObject(request.headers) };
} catch {
return undefined;
}
};
const reqContextFromNode = (req) => (req ? { method: req.method, originalUrl: req.url, headers: req.headers || {} } : undefined);

// Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard).
const screenResp = async (response) => {
const screenResp = async (response, reqCtx) => {
const text = await readTextResponse(response, screenCap);
if (text == null) return response;
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
if (r.verdict === 'block') return leakResponse();
if (r.verdict === 'redact') return rebuildResponse(response, r.body, r.headers);
return response;
Expand All @@ -230,7 +259,7 @@ export async function createProtection(options = {}) {
// Wrap a Node ServerResponse so its (buffered, text) body is screened before it's sent.
// Opt-in (buffering can delay a streamed response); over 512 KiB it stops buffering and
// passes through unscanned.
const wrapNodeResponse = (res) => {
const wrapNodeResponse = (res, reqCtx) => {
const origWrite = res.write.bind(res);
const origEnd = res.end.bind(res);
const chunks = [];
Expand Down Expand Up @@ -272,7 +301,7 @@ export async function createProtection(options = {}) {
if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); }
let r;
try {
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }, reqCtx);
} catch (err) {
onError?.(err);
for (const c of chunks) origWrite(c);
Expand Down Expand Up @@ -331,7 +360,7 @@ export async function createProtection(options = {}) {

// Screen a fetch Response through the response-phase rules (redact/block). Used by
// .fetch(), and by the Supabase guard on its forwarded upstream response.
screenResponse: (response) => screenResp(response),
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : undefined),

// (request) => Response | null (null = allow, caller proceeds). Request phase only.
fetchGuard() {
Expand All @@ -354,7 +383,7 @@ export async function createProtection(options = {}) {
const blocked = await guard(request);
if (blocked) return blocked;
const response = await handler(request, ...rest);
return screenResp(response);
return screenResp(response, reqContextFromFetch(request));
};
},

Expand All @@ -367,7 +396,7 @@ export async function createProtection(options = {}) {
result = engine.evaluate(req);
} catch (err) {
onError?.(err);
if (exprOptions.screenResponses) wrapNodeResponse(res);
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
return next();
}
decide(
Expand All @@ -381,7 +410,7 @@ export async function createProtection(options = {}) {
}
},
() => {
if (exprOptions.screenResponses) wrapNodeResponse(res);
if (exprOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
next();
},
nodeRequestMeta(req),
Expand Down Expand Up @@ -437,7 +466,7 @@ export async function createProtection(options = {}) {
// This guard consumed the request stream to screen it; re-expose the parsed
// body so a downstream handler (without its own body-parser) can read it.
if (req.body === undefined) req.body = shaped.body;
if (nodeOptions.screenResponses) wrapNodeResponse(res);
if (nodeOptions.screenResponses) wrapNodeResponse(res, reqContextFromNode(req));
next();
},
nodeRequestMeta(req),
Expand Down
43 changes: 43 additions & 0 deletions tests/protect/response-prefilter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { createProtection } from '../../src/protect/runtime.js';

// A response rule may declare a cheap literal `prefilter`; screenText runs the rule's (expensive)
// regex only when at least one anchor is present in the body. This cuts CPU/latency on the common
// no-candidate response and shrinks the regex/ReDoS surface.

const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} };
const textResp = (body: string) => new Response(body, { status: 200, headers: { 'content-type': 'text/plain' } });

const rule = (prefilter?: string[]) => ({
phase: 'response',
category: 'x',
action: 'redact',
...(prefilter ? { prefilter } : {}),
rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/topsecret/' } }],
});

describe('response phase — literal prefilter short-circuit', () => {
it('skips the regex when no prefilter anchor is present (rule does not fire)', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [rule(['zz-absent-marker'])], mode: 'block' });
const out = await p.screenResponse(textResp('a topsecret b')); // regex WOULD match, but the anchor is absent
expect(await out.text()).toBe('a topsecret b'); // short-circuited → untouched
});

it('runs the rule when a prefilter anchor is present', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [rule(['topsecret'])], mode: 'block' });
const out = await p.screenResponse(textResp('a topsecret b'));
expect(/topsecret/.test(await out.text())).toBe(false); // fired → masked
});

it('matches the prefilter case-insensitively', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [rule(['TOPSECRET'])], mode: 'block' });
const out = await p.screenResponse(textResp('a topsecret b'));
expect(/topsecret/.test(await out.text())).toBe(false);
});

it('a rule with no prefilter runs as before (back-compat)', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [rule()], mode: 'block' });
const out = await p.screenResponse(textResp('a topsecret b'));
expect(/topsecret/.test(await out.text())).toBe(false);
});
});
48 changes: 48 additions & 0 deletions tests/protect/response-when-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { createProtection } from '../../src/protect/runtime.js';

// The response phase now receives the originating request, so a response rule's `when` route/method
// scope resolves against the REAL request instead of a phantom empty one (where it was inert). This
// is the enabling change for route-scoped response rules and, later, open-redirect / CORS / IDOR
// rules that need request Host/Origin/identity.

const emptyBundle = { firewall: [], whitelists: [], whitelist_keys: {} };

const redactOnAdmin = {
phase: 'response',
category: 'x',
action: 'redact',
when: { path: '/admin', method: ['GET'] },
rule_v2: [{ parameter: 'response.body', match: { type: 'contains', value: 'topsecret' } }],
};

const body = () => new Response('x topsecret y', { status: 200, headers: { 'content-type': 'text/plain' } });

describe('response phase — `when` route/method scope resolves against the request', () => {
it('applies a route-scoped response rule on the matching route', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [redactOnAdmin], mode: 'block' });
const out = await p.screenResponse(body(), new Request('https://app.example.com/admin', { method: 'GET' }));
// Before threading the request, `when` resolved REQUEST_URI to '/', so this rule never fired and
// the secret was served. Now it fires on /admin and masks.
expect(/topsecret/.test(await out.text())).toBe(false);
});

it('does NOT apply the rule on a non-matching route', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [redactOnAdmin], mode: 'block' });
const out = await p.screenResponse(body(), new Request('https://app.example.com/public', { method: 'GET' }));
expect(/topsecret/.test(await out.text())).toBe(true); // out of scope → untouched
});

it('does NOT apply the rule on a non-matching method', async () => {
const p: any = await createProtection({ rules: emptyBundle, responseRules: [redactOnAdmin], mode: 'block' });
const out = await p.screenResponse(body(), new Request('https://app.example.com/admin', { method: 'POST' }));
expect(/topsecret/.test(await out.text())).toBe(true); // method out of scope → untouched
});

it('an unscoped response rule still works with no request context (back-compat)', async () => {
const rule = { ...redactOnAdmin, when: undefined };
const p: any = await createProtection({ rules: emptyBundle, responseRules: [rule], mode: 'block' });
const out = await p.screenResponse(body()); // no request passed
expect(/topsecret/.test(await out.text())).toBe(false);
});
});
Loading