diff --git a/examples/protect/package-lock.json b/examples/protect/package-lock.json new file mode 100644 index 0000000..a659fbc --- /dev/null +++ b/examples/protect/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "connect-protect-demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "connect-protect-demo", + "dependencies": { + "lodash": "4.17.11" + } + }, + "node_modules/lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "license": "MIT" + } + } +} diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index 85df960..8b327a1 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -17,8 +17,10 @@ export interface Protection { fetchGuard(): (request: Request) => Promise; /** Screens the request, then the response (secret-leak redaction / withhold). */ fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise; - 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; + 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; } diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 588edd4..ff224f7 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -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) { @@ -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) => { @@ -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) => { @@ -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) => { @@ -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(); }, ); diff --git a/src/protect/supabase-guard.js b/src/protect/supabase-guard.js index 09c8300..4201373 100644 --- a/src/protect/supabase-guard.js +++ b/src/protect/supabase-guard.js @@ -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; }; } diff --git a/tests/protect/response-guards.test.ts b/tests/protect/response-guards.test.ts new file mode 100644 index 0000000..8244989 --- /dev/null +++ b/tests/protect/response-guards.test.ts @@ -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, + 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, 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((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((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); + }); +});