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
19 changes: 19 additions & 0 deletions examples/protect/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@ export interface Protection {
fetchGuard(): (request: Request) => Promise<Response | null>;
/** Screens the request, then the response (secret-leak redaction / withhold). */
fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise<unknown>;
express(): (req: unknown, res: unknown, next: () => void) => void;
node(options?: { maxBodyBytes?: number }): (req: unknown, res: unknown, next: () => void) => void;
/** Screen a fetch Response through the response-phase rules (redact/withhold). */
screenResponse(response: Response): Promise<Response>;
express(options?: { screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
node(options?: { maxBodyBytes?: number; screenResponses?: boolean }): (req: unknown, res: unknown, next: () => void) => void;
/** Present when `egress: true` — restores the original global fetch. */
uninstallEgress?: () => void;
}
Expand Down
111 changes: 95 additions & 16 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,14 @@ export async function createProtection(options = {}) {
return mode === 'block' ? block() : allow();
};

// Response phase: redact matched spans (default) or withhold; block wins over redact.
const screenFetchResponse = async (response) => {
const text = await readTextResponse(response);
if (text == null) return response;
const meta = { status: response.status, headers: headerObject(response.headers) };

// Response phase core: screen a text body → { verdict: 'pass'|'block'|'redact', body? }.
// redact masks matched spans; block withholds; block wins over redact. Enforcement only in
// block mode (dry-run records via onDetect but returns 'pass').
const isTextCT = (ct) => {
ct = (ct || '').toLowerCase();
return ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct);
};
const screenText = (text, meta) => {
let blockRule = null;
const redactions = [];
for (const { rule, engine: re, redactors } of responseRuleSet) {
Expand All @@ -115,17 +117,78 @@ export async function createProtection(options = {}) {
if (redactors && redactors.length) redactions.push({ rule, redactors });
else if (!blockRule) blockRule = rule;
}
if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' };
if (blockRule) return { verdict: 'block' };
let body = text;
for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
return { verdict: 'redact', body };
};

if (mode !== 'block') return response;
if (blockRule) return leakResponse();
if (redactions.length) {
let body = text;
for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
return rebuildResponse(response, body);
}
// Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard).
const screenResp = async (response) => {
const text = await readTextResponse(response);
if (text == null) return response;
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
if (r.verdict === 'block') return leakResponse();
if (r.verdict === 'redact') return rebuildResponse(response, r.body);
return response;
};

// 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 origWrite = res.write.bind(res);
const origEnd = res.end.bind(res);
const chunks = [];
let size = 0;
let overflow = false;
const MAX = 512 * 1024;
const collect = (chunk, enc) => {
if (chunk == null) return;
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof enc === 'string' ? enc : 'utf8');
size += buf.length;
if (size > MAX) { overflow = true; return; }
chunks.push(buf);
};
res.write = function (chunk, enc, cb) {
if (overflow) return origWrite(chunk, enc, cb);
collect(chunk, enc);
if (typeof enc === 'function') enc();
else if (typeof cb === 'function') cb();
return true;
};
res.end = function (chunk, enc, cb) {
if (typeof chunk === 'function') { cb = chunk; chunk = undefined; enc = undefined; }
else if (typeof enc === 'function') { cb = enc; enc = undefined; }
if (overflow) { if (chunk != null) origWrite(chunk, enc); return origEnd(cb); }
collect(chunk, enc);
const text = Buffer.concat(chunks).toString('utf8');
let ct = res.getHeader ? res.getHeader('content-type') : undefined;
if (Array.isArray(ct)) ct = ct[0];
if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); }
let r;
try {
r = screenText(text, { status: res.statusCode, headers: {} });
} catch (err) {
onError?.(err);
for (const c of chunks) origWrite(c);
return origEnd(cb);
}
if (r.verdict === 'block') {
res.statusCode = 500;
try { res.setHeader('content-type', 'application/json'); } catch { /* headers sent */ }
return origEnd(JSON.stringify({ error: 'Response withheld by Patchstack (sensitive data detected)' }), cb);
}
if (r.verdict === 'redact') {
try { res.removeHeader && res.removeHeader('content-length'); } catch { /* ignore */ }
return origEnd(r.body, cb);
}
for (const c of chunks) origWrite(c);
return origEnd(cb);
};
};

// Egress phase: is this outbound call blocked? (records detection either way)
const allow = new Set((options.allowHosts ?? []).map((h) => String(h).toLowerCase()));
const egressShouldBlock = (url, host, method) => {
Expand All @@ -146,6 +209,10 @@ export async function createProtection(options = {}) {
mode,
rules: { request: requestRules, response: responseRules, egress: egressRules },

// 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),

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

// Express middleware (request phase; expects express-parsed req.query/req.body).
express() {
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
express(exprOptions = {}) {
return (req, res, next) => {
let result;
try {
result = engine.evaluate(req);
} catch (err) {
onError?.(err);
if (exprOptions.screenResponses) wrapNodeResponse(res);
return next();
}
decide('request', result, () => res.status(403).json(blockBody(result)), () => next());
decide(
'request',
result,
() => res.status(403).json(blockBody(result)),
() => {
if (exprOptions.screenResponses) wrapNodeResponse(res);
next();
},
);
};
},

// Node / Connect middleware — buffers the body itself (request phase).
// Pass { screenResponses: true } to also screen the outgoing response (buffers it).
node(nodeOptions = {}) {
const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024;
return (req, res, next) => {
Expand Down Expand Up @@ -227,6 +305,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);
next();
},
);
Expand Down
6 changes: 5 additions & 1 deletion src/protect/supabase-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export function createSupabaseGuard({ protection, supabaseUrl, fetchImpl = fetch
if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
});
const buf = await upstream.arrayBuffer();
return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
const forwarded = new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });

// Response phase: screen what Supabase returned (query results can leak secrets/PII) —
// redact the offending spans / withhold, per the response rules. Fail-open if unavailable.
return protection.screenResponse ? protection.screenResponse(forwarded) : forwarded;
};
}
166 changes: 166 additions & 0 deletions tests/protect/response-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import { Readable } from 'node:stream';
import { createProtection, createSupabaseGuard, GUARD_PATH } from '../../src/protect/runtime.js';

// Opt-in response screening across the non-fetch surfaces: the Supabase tunnel guard, the
// Node/Connect adapter, and the Express adapter. The fetch() path is covered in tier3.test.ts;
// here we prove the same redact/withhold verdicts reach the buffered Node/Express response and
// the forwarded Supabase upstream — and that node()/express() only screen when opted in.

const AWS = 'AKIAIOSFODNN7EXAMPLE';

// --- a Node ServerResponse-like mock that wrapNodeResponse can wrap (write/end/*Header) ---
function mockRes() {
const out: Buffer[] = [];
return {
statusCode: 200,
_headers: {} as Record<string, unknown>,
ended: false,
body: '',
setHeader(k: string, v: unknown) { this._headers[k.toLowerCase()] = v; },
getHeader(k: string) { return this._headers[k.toLowerCase()]; },
removeHeader(k: string) { delete this._headers[k.toLowerCase()]; },
write(chunk: any) { out.push(Buffer.from(chunk)); return true; },
end(chunk?: any) {
if (chunk != null && typeof chunk !== 'function') out.push(Buffer.from(chunk));
this.body = Buffer.concat(out).toString('utf8');
this.ended = true;
},
};
}

function mockReq({ method = 'GET', url = '/', headers = {} as Record<string, string>, body = '' } = {}) {
const req: any = Readable.from(body ? [Buffer.from(body)] : []);
req.method = method;
req.url = url;
req.headers = headers;
req.socket = { remoteAddress: '9.9.9.9' };
return req;
}

// --- Supabase tunnel guard: screen the forwarded upstream response ---
describe('supabase guard — response screening', () => {
const SUPA = 'https://proj.supabase.co';
const guardReq = () =>
new Request(`https://app.example.com${GUARD_PATH}`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-ps-target': `${SUPA}/rest/v1/tasks` },
body: JSON.stringify({ title: 'ok' }),
});

it('redacts a secret leaked in the Supabase result', async () => {
const protection = await createProtection({ mode: 'block' }); // default response rules
const upstream = (async () =>
new Response(JSON.stringify([{ id: 1, api_key: AWS }]), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as any;
const handle = createSupabaseGuard({ protection, supabaseUrl: SUPA, fetchImpl: upstream });
const res = await handle(guardReq());
const body = await res.text();
expect(res.status).toBe(200);
expect(body.includes(AWS)).toBe(false);
expect(body.includes('[REDACTED]')).toBe(true);
});

it('dry-run leaves the Supabase result untouched (observe only)', async () => {
const protection = await createProtection({ mode: 'dry-run' });
const upstream = (async () =>
new Response(JSON.stringify([{ api_key: AWS }]), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as any;
const handle = createSupabaseGuard({ protection, supabaseUrl: SUPA, fetchImpl: upstream });
const body = await (await handle(guardReq())).text();
expect(body.includes(AWS)).toBe(true);
});
});

// --- Node adapter: opt-in wrapNodeResponse ---
describe('node() — response screening', () => {
// Drive the middleware, then let the "app" write the response inside next().
function run(mw: any, req: any, res: any, appEnd: (res: any) => void) {
return new Promise<void>((resolve) => {
mw(req, res, () => { appEnd(res); resolve(); });
});
}
const leakyApp = (res: any) => {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ api_key: AWS }));
};

it('redacts a secret in the response when screenResponses is set', async () => {
const p = await createProtection({ mode: 'block' });
const res = mockRes();
await run(p.node({ screenResponses: true }), mockReq(), res, leakyApp);
expect(res.body.includes(AWS)).toBe(false);
expect(res.body.includes('[REDACTED]')).toBe(true);
expect(res.statusCode).toBe(200);
});

it('does NOT screen the response when screenResponses is omitted', async () => {
const p = await createProtection({ mode: 'block' });
const res = mockRes();
await run(p.node(), mockReq(), res, leakyApp);
expect(res.body.includes(AWS)).toBe(true);
});

it('withholds (500) when a block-action rule matches the response', async () => {
const p = await createProtection({
mode: 'block',
responseRules: [
{ phase: 'response', category: 'x', action: 'block', rule_v2: [{ parameter: 'response.body', match: { type: 'contains', value: 'TOPSECRET' } }] },
],
});
const res = mockRes();
await run(p.node({ screenResponses: true }), mockReq(), res, (r: any) => {
r.setHeader('content-type', 'application/json');
r.end(JSON.stringify({ v: 'TOPSECRET' }));
});
expect(res.statusCode).toBe(500);
expect(res.body.includes('TOPSECRET')).toBe(false);
});

it('passes a non-text response through unscanned', async () => {
const p = await createProtection({ mode: 'block' });
const res = mockRes();
await run(p.node({ screenResponses: true }), mockReq(), res, (r: any) => {
r.setHeader('content-type', 'application/octet-stream');
r.end(Buffer.from(AWS));
});
expect(res.body.includes(AWS)).toBe(true);
});
});

// --- Express adapter: opt-in wrapNodeResponse ---
describe('express() — response screening', () => {
// express() evaluates synchronously; simulate the parsed req + app response.
function run(mw: any, req: any, res: any, appEnd: (res: any) => void) {
return new Promise<void>((resolve) => {
mw(req, res, () => { appEnd(res); resolve(); });
});
}

it('redacts a secret in the response when screenResponses is set', async () => {
const p = await createProtection({ mode: 'block' });
const res = mockRes();
const req: any = { method: 'GET', url: '/', query: {}, body: {}, headers: {} };
await run(p.express({ screenResponses: true }), req, res, (r: any) => {
r.setHeader('content-type', 'application/json');
r.end(JSON.stringify({ api_key: AWS }));
});
expect(res.body.includes(AWS)).toBe(false);
expect(res.body.includes('[REDACTED]')).toBe(true);
});

it('does NOT screen when screenResponses is omitted', async () => {
const p = await createProtection({ mode: 'block' });
const res = mockRes();
const req: any = { method: 'GET', url: '/', query: {}, body: {}, headers: {} };
await run(p.express(), req, res, (r: any) => {
r.setHeader('content-type', 'application/json');
r.end(JSON.stringify({ api_key: AWS }));
});
expect(res.body.includes(AWS)).toBe(true);
});
});
Loading