From dbb157eb775fd3250ffe5135b90e8969d43df322 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 11:40:30 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(protect):=20rule-driven=20Tier-3=20?= =?UTF-8?q?=E2=80=94=20response-leak=20redaction=20+=20egress=20SSRF;=20ar?= =?UTF-8?q?ray=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the output-side protection into the vendored runtime, as RULES (nothing hardcoded), via rule phases: - new rule properties: `phase` (request|response|egress, default request) + `category` - engine: resolve `response.status|body|header.*` and `egress.url|host|method`; new `internal_host` match type (private/loopback/link-local/metadata ranges) - runtime (createProtection): response phase screens the outgoing body; egress phase (opt-in `egress:true`) wraps global fetch to screen outbound calls. Driven by default bundles in defaults.js (overridable via responseRules/egressRules). Response leak handling supports per-rule `action`: - `redact` (default for the secret rules) — mask only the offending span(s) and still serve the page; a legit response that leaks one key gets that key masked, not withheld - `block` — withhold the whole response Also fixes a real engine bug: array `parameter` (["get.a","post.b"]) — used pervasively in rule_v2 — silently never matched (`parameter.indexOf('.')` on an array returns -1). #evaluateCondition now resolves each element and ORs them. Enforcement only in block mode; dry-run observes + serves original. Fail-open throughout. createSupabaseGuard / createServerFnGuard unchanged. Full suite: 151 pass; build + typecheck green. Co-Authored-By: Claude Fable 5 --- src/protect/defaults.js | 70 ++++++++++++ src/protect/egress.js | 43 ++++++++ src/protect/engine/engine.js | 36 ++++++- src/protect/engine/request.js | 42 ++++++++ src/protect/protect.d.ts | 29 ++++- src/protect/runtime.js | 193 +++++++++++++++++++++++++++++++--- tests/protect.test.ts | 80 ++++++++++++++ 7 files changed, 474 insertions(+), 19 deletions(-) create mode 100644 src/protect/defaults.js create mode 100644 src/protect/egress.js diff --git a/src/protect/defaults.js b/src/protect/defaults.js new file mode 100644 index 0000000..58af634 --- /dev/null +++ b/src/protect/defaults.js @@ -0,0 +1,70 @@ +// Default response- and egress-phase rule bundles, as JS constants (not JSON files) so +// tsup bundles them into dist/protect.js without a copy step. These are just rules — +// override per app via createProtection({ responseRules, egressRules }) or extend by +// adding phase-tagged rules to the delivered bundle. Patterns are high-precision +// (low false positive): structural markers a real secret has and normal content doesn't. + +// Response phase — secret / info exposure. Default action `redact` masks only the +// offending span and still serves the page (a legit response that leaks one key gets +// that key masked, not withheld). Use `action: "block"` to withhold the whole response. +export const DEFAULT_RESPONSE_RULES = [ + { + id: 'resp-private-key', + title: 'Private key in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/' } }] + }, + { + id: 'resp-aws-access-key', + title: 'AWS access key id in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:AKIA|ASIA)[0-9A-Z]{16}\\b/' } }] + }, + { + id: 'resp-gcp-api-key', + title: 'Google API key in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\bAIza[0-9A-Za-z_-]{35}\\b/' } }] + }, + { + id: 'resp-jwt', + title: 'JWT in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\beyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}\\b/' } }] + }, + { + id: 'resp-db-connection-string', + title: 'Database connection string with credentials in response body', + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\b(?:mongodb(?:\\+srv)?|postgres(?:ql)?|mysql|redis|amqps?):\\/\\/[^\\s:@\\/]+:[^\\s:@\\/]+@/i' } }] + }, + { + id: 'resp-stack-trace', + title: 'Node stack trace leaking in response body', + phase: 'response', + category: 'info-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/' } }] + } +]; + +// Egress phase — SSRF: block the app's outbound calls to internal / metadata addresses. +export const DEFAULT_EGRESS_RULES = [ + { + id: 'egress-internal-address', + title: 'Outbound request to an internal / metadata address (SSRF)', + phase: 'egress', + category: 'ssrf', + rule_v2: [{ parameter: 'egress.host', match: { type: 'internal_host' } }] + } +]; diff --git a/src/protect/egress.js b/src/protect/egress.js new file mode 100644 index 0000000..6305df3 --- /dev/null +++ b/src/protect/egress.js @@ -0,0 +1,43 @@ +// Egress guard MECHANISM only. Wraps the global `fetch` so the app's outbound requests +// can be screened; the block DECISION is delegated to a caller-supplied `shouldBlock` +// predicate (which the runtime builds from egress-phase rules — see defaults.js). +// No policy is hardcoded here: what counts as "internal"/disallowed lives in rules. +// WinterCG — works on Node 18+, Cloudflare Workers, Bun, Deno. + +/** + * @param {{ shouldBlock: (url:string, host:string|null, method:string)=>boolean, + * onBlock?: (info:{url:string,host:string|null,method:string})=>void }} opts + * @returns {() => void} uninstall (restores the original fetch) + */ +export function installEgressGuard({ shouldBlock, onBlock } = {}) { + const original = globalThis.fetch; + if (typeof original !== 'function' || original.__patchstackGuarded || typeof shouldBlock !== 'function') { + return () => {}; + } + + const guarded = async (input, init) => { + const url = typeof input === 'string' ? input : (input && input.url) || String(input); + let host = null; + try { + host = new URL(url).hostname; + } catch { + host = null; + } + const method = (init && init.method) || (input && input.method) || 'GET'; + + if (shouldBlock(url, host, method)) { + onBlock?.({ url, host, method }); + throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`); + } + return original(input, init); + }; + + guarded.__patchstackGuarded = true; + globalThis.fetch = guarded; + + return () => { + if (globalThis.fetch === guarded) { + globalThis.fetch = original; + } + }; +} diff --git a/src/protect/engine/engine.js b/src/protect/engine/engine.js index 9b3bba5..d5be930 100644 --- a/src/protect/engine/engine.js +++ b/src/protect/engine/engine.js @@ -93,6 +93,30 @@ function warnUnsupportedMatchType(type) { ); } +// Internal / private / loopback / link-local / cloud-metadata host check, used by the +// `internal_host` match type for SSRF egress rules. Handles IPv4 (incl. IPv4-mapped IPv6), +// IPv6 loopback/link-local/unique-local, and localhost / *.local / GCP metadata names. +function isInternalHost(hostname) { + if (!hostname) return false; + const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, ''); + + if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true; + if (host === 'metadata.google.internal') return true; + if (host === '::1' || host === '::') return true; + if (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) return true; + + const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const a = Number(v4[1]); + const b = Number(v4[2]); + if (a === 127 || a === 10 || a === 0) return true; // loopback / private / this-host + if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata + if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12 + if (a === 192 && b === 168) return true; // 192.168.0.0/16 + } + 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. @@ -179,6 +203,10 @@ function matchValue(type, value, matchVal, matchObj) { } } + case 'internal_host': + // SSRF egress: private / loopback / link-local / cloud-metadata destinations. + return isInternalHost(strValue); + case 'quotes': // engine-php exposes `inline_js_xss` as an alias of `quotes`. case 'inline_js_xss': @@ -323,7 +351,11 @@ export class RuleEngine { return this.#evaluateRule(condition.rules, resolver); } - const values = resolver.resolve(parameter); + // `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.) + const params = Array.isArray(parameter) ? parameter : [parameter]; + const values = params.flatMap((p) => resolver.resolve(p)); if (values.length === 0) { return false; @@ -384,4 +416,4 @@ export class RuleEngine { } } -export const _testExports = { matchValue, safeRegExp }; +export const _testExports = { matchValue, safeRegExp, isInternalHost }; diff --git a/src/protect/engine/request.js b/src/protect/engine/request.js index 37f481d..16a2295 100644 --- a/src/protect/engine/request.js +++ b/src/protect/engine/request.js @@ -58,11 +58,53 @@ export class RequestResolver { return this.#resolveServer(key); case 'files': return this.#resolveFiles(key); + case 'response': + return this.#resolveResponse(key); + case 'egress': + return this.#resolveEgress(key); default: return []; } } + // Response-phase sources (req._response = { status, headers, body }). Lets rules inspect + // what the app is about to SEND — e.g. a leaked secret in the body — regardless of route. + #resolveResponse(key) { + const resp = this.#req._response; + if (!resp) { + return []; + } + if (key === 'status') { + return resp.status !== undefined ? [String(resp.status)] : []; + } + if (key === 'body') { + return resp.body != null && resp.body !== '' ? [resp.body] : []; + } + if (key === 'headers') { + const headers = resp.headers ?? {}; + return [Object.entries(headers).map(([k, v]) => `${k}: ${v}`).join('\n')]; + } + if (key.startsWith('header.')) { + const name = key.slice('header.'.length).toLowerCase(); + const value = (resp.headers ?? {})[name]; + return value !== undefined ? [value] : []; + } + return []; + } + + // Egress-phase sources (req._egress = { url, host, method }). Lets rules inspect an + // OUTBOUND request the app is about to make — SSRF at the egress boundary. + #resolveEgress(key) { + const eg = this.#req._egress; + if (!eg) { + return []; + } + if (key === 'url') return eg.url ? [eg.url] : []; + if (key === 'host') return eg.host ? [eg.host] : []; + if (key === 'method') return eg.method ? [eg.method] : []; + return []; + } + applyMutations(mutations, value) { if (!mutations || !Array.isArray(mutations)) { return value; diff --git a/src/protect/protect.d.ts b/src/protect/protect.d.ts index cb74548..85df960 100644 --- a/src/protect/protect.d.ts +++ b/src/protect/protect.d.ts @@ -7,14 +7,20 @@ export interface RuleBundle { whitelist_keys: Record; } +export type Phase = "request" | "response" | "egress"; + export interface Protection { mode: "block" | "dry-run"; - rules: RuleBundle; - /** (request) => Response (403 when blocked) | null (allow / dry-run). */ + /** Active rules split by phase. */ + rules: { request: unknown[]; response: unknown[]; egress: unknown[] }; + /** (request) => Response (403 when blocked) | null (allow / dry-run). Request phase only. */ 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; + /** Present when `egress: true` — restores the original global fetch. */ + uninstallEgress?: () => void; } export interface CreateProtectionOptions { @@ -27,8 +33,25 @@ export interface CreateProtectionOptions { baseUrl?: string; /** Directory for the last-known-good rule cache. */ cacheDir?: string; + /** Override the default response-phase (secret-leak) rule set. */ + responseRules?: unknown[]; + /** Override the default egress-phase (SSRF) rule set. */ + egressRules?: unknown[]; + /** Opt in to wrapping global fetch to screen the app's outbound calls (SSRF). */ + egress?: boolean; + /** Hosts exempt from egress screening. */ + allowHosts?: string[]; + /** Redaction mask (string or per-category function). Default "[REDACTED]". */ + maskWith?: string | ((category?: string) => string); onError?: (err: unknown) => void; - onDetect?: (detection: { mode: string; rule?: { id?: string }; message?: string }) => void; + onEgressBlock?: (info: { url: string; host: string | null; method: string }) => void; + onDetect?: (detection: { + phase?: Phase; + mode: string; + category?: string; + rule?: { id?: string; category?: string }; + message?: string; + }) => void; } export function createProtection(options?: CreateProtectionOptions): Promise; diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 29a2f53..a30291d 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -13,6 +13,8 @@ import { RuleEngine, PatchstackRuleClient } from './engine/index.js'; import { fromFetchRequest } from './engine/fetch.js'; import { fromNodeRequest } from './engine/node.js'; +import { installEgressGuard } from './egress.js'; +import { DEFAULT_RESPONSE_RULES, DEFAULT_EGRESS_RULES } from './defaults.js'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; @@ -57,20 +59,94 @@ export async function createProtection(options = {}) { const onDetect = options.onDetect ?? defaultOnDetect; const bundle = await resolveRules(options); - const engine = new RuleEngine({ ...bundle, onError }); + const incoming = bundle.firewall ?? []; - // Given an evaluation result, either enforce (block mode) or just record (dry-run). - const decide = (result, block, allow) => { + // Split the delivered ruleset by phase (default "request"), merging phase defaults + + // per-call overrides. Detection is fully rule-driven — nothing hardcoded. + const requestRules = byPhase(incoming, 'request'); + const responseRules = [...(options.responseRules ?? DEFAULT_RESPONSE_RULES), ...byPhase(incoming, 'response')]; + const egressRules = [...(options.egressRules ?? DEFAULT_EGRESS_RULES), ...byPhase(incoming, 'egress')]; + + const engine = new RuleEngine({ + firewall: requestRules, + whitelists: bundle.whitelists, + whitelist_keys: bundle.whitelist_keys, + onError + }); + // One engine per response rule so we can find ALL matches (to redact each). `action: + // "redact"` masks the offending span(s); anything else withholds the whole response. + const responseRuleSet = responseRules.map((rule) => ({ + rule, + engine: new RuleEngine({ firewall: [rule], onError }), + redactors: rule.action === 'redact' ? extractRedactors(rule) : null + })); + const egressEngine = new RuleEngine({ firewall: egressRules, onError }); + const maskFn = + typeof options.maskWith === 'function' + ? options.maskWith + : () => (typeof options.maskWith === 'string' ? options.maskWith : '[REDACTED]'); + + // Given a request/egress result, enforce (block mode) or just record (dry-run). + const decide = (phase, result, block, allow) => { if (!result || !result.blocked) return allow(); - onDetect({ mode, rule: result.rule, message: result.message }); + onDetect({ phase, mode, category: result.rule?.category, rule: result.rule, message: result.message }); 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) }; + + let blockRule = null; + const redactions = []; + for (const { rule, engine: re, redactors } of responseRuleSet) { + let result; + try { + result = re.evaluate({ _response: { ...meta, body: text } }); + } catch (err) { + onError?.(err); + continue; + } + if (!result.blocked) continue; + onDetect({ phase: 'response', mode, category: rule.category, rule, message: result.message }); + if (mode !== 'block') continue; // dry-run: observe only + if (redactors && redactors.length) redactions.push({ rule, redactors }); + else if (!blockRule) blockRule = rule; + } + + 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); + } + return response; + }; + + // 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) => { + if (host && allow.has(host.toLowerCase())) return false; + let result; + try { + result = egressEngine.evaluate({ _egress: { url, host, method } }); + } catch (err) { + onError?.(err); + return false; + } + if (!result.blocked) return false; + onDetect({ phase: 'egress', mode, category: result.rule?.category, rule: result.rule, message: result.message }); + return mode === 'block'; + }; + const protection = { mode, - rules: bundle, + rules: { request: requestRules, response: responseRules, egress: egressRules }, - // (request) => Response | null (null = allow, caller proceeds) + // (request) => Response | null (null = allow, caller proceeds). Request phase only. fetchGuard() { return async (request) => { let result; @@ -80,17 +156,22 @@ export async function createProtection(options = {}) { onError?.(err); return null; // fail open } - return decide(result, () => blockResponse(result), () => null); + return decide('request', result, () => blockResponse(result), () => null); }; }, - // Wrap a fetch handler: export default { fetch: protection.fetch(app.fetch) } + // Wrap a fetch handler: screens the request, then the response (redact/block). fetch(handler) { const guard = protection.fetchGuard(); - return async (request, ...rest) => (await guard(request)) ?? handler(request, ...rest); + return async (request, ...rest) => { + const blocked = await guard(request); + if (blocked) return blocked; + const response = await handler(request, ...rest); + return screenFetchResponse(response); + }; }, - // Express middleware (expects express-parsed req.query/req.body). + // Express middleware (request phase; expects express-parsed req.query/req.body). express() { return (req, res, next) => { let result; @@ -100,11 +181,11 @@ export async function createProtection(options = {}) { onError?.(err); return next(); } - decide(result, () => res.status(403).json(blockBody(result)), () => next()); + decide('request', result, () => res.status(403).json(blockBody(result)), () => next()); }; }, - // Node / Connect middleware — buffers the body itself (no body-parser needed). + // Node / Connect middleware — buffers the body itself (request phase). node(nodeOptions = {}) { const maxBytes = nodeOptions.maxBodyBytes ?? 1024 * 1024; return (req, res, next) => { @@ -133,6 +214,7 @@ export async function createProtection(options = {}) { return next(); } decide( + 'request', result, () => { res.statusCode = 403; @@ -146,9 +228,92 @@ export async function createProtection(options = {}) { }, }; + // Egress interception is opt-in (it wraps the global fetch). + if (options.egress) { + protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock }); + } + return protection; } +// --- phase / response helpers ------------------------------------------- + +function byPhase(rules, phase) { + return (rules ?? []).filter((r) => (r.phase ?? 'request') === phase); +} + +async function readTextResponse(response) { + if (!response || typeof response.clone !== 'function') return null; + const ct = (response.headers?.get?.('content-type') || '').toLowerCase(); + const isText = ct === '' || /(json|text|xml|html|javascript|csv|yaml|x-www-form-urlencoded)/.test(ct); + if (!isText) return null; + const len = Number(response.headers?.get?.('content-length') || 0); + if (len && len > 512 * 1024) return null; + try { + const text = await response.clone().text(); + return text.length > 512 * 1024 ? null : text; + } catch { + return null; + } +} + +function headerObject(headers) { + const out = {}; + headers?.forEach?.((v, k) => { out[k.toLowerCase()] = v; }); + return out; +} + +// Derive redaction targets from a rule's own conditions: regex → mask every match; +// contains/stripos → mask the literal. (Other match types can't identify a span → the +// rule falls back to block.) +function extractRedactors(rule) { + const out = []; + const walk = (conds) => { + for (const c of conds ?? []) { + if (Array.isArray(c.rules)) walk(c.rules); + const m = c.match; + if (!m) continue; + if (m.type === 'regex' && typeof m.value === 'string') { + const parsed = m.value.match(/^\/(.+)\/([a-z]*)$/is); + if (parsed) { + const flags = parsed[2].includes('g') ? parsed[2] : parsed[2] + 'g'; + try { + out.push({ re: new RegExp(parsed[1], flags) }); + } catch { + /* skip invalid */ + } + } + } else if ((m.type === 'contains' || m.type === 'stripos') && m.value != null) { + out.push({ literal: String(m.value) }); + } + } + }; + walk(rule.rule_v2); + return out; +} + +function applyRedactors(body, redactors, mask) { + let out = body; + for (const r of redactors) { + if (r.re) out = out.replace(r.re, mask); + else if (r.literal) out = out.split(r.literal).join(mask); + } + return out; +} + +function rebuildResponse(response, body) { + const headers = new Headers(response.headers); + headers.delete('content-length'); // body length changed after redaction + return new Response(body, { status: response.status, statusText: response.statusText, headers }); +} + +function leakResponse() { + return new Response(JSON.stringify({ error: 'Response withheld by Patchstack (sensitive data detected)' }), { + status: 500, + headers: { 'content-type': 'application/json' } + }); +} + // --- rule source -------------------------------------------------------- async function resolveRules(options) { @@ -228,7 +393,7 @@ function blockResponse(result) { }); } -function defaultOnDetect({ mode, rule, message }) { +function defaultOnDetect({ phase, mode, category, rule, message }) { const tag = mode === 'block' ? 'BLOCK' : 'DETECT (dry-run)'; - console.warn(`[patchstack] ${tag} rule=${rule?.id ?? '?'} ${message ?? ''}`.trim()); + console.warn(`[patchstack] ${tag} phase=${phase ?? 'request'} category=${category ?? '?'} rule=${rule?.id ?? '?'} ${message ?? ''}`.trim()); } diff --git a/tests/protect.test.ts b/tests/protect.test.ts index 41f382c..01763ce 100644 --- a/tests/protect.test.ts +++ b/tests/protect.test.ts @@ -83,3 +83,83 @@ describe('createServerFnGuard (TanStack server-function path)', () => { expect(detections.length).toBe(1); }); }); + +const jsonReq = (body: unknown) => + new Request('https://app.example.com/x', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); + +describe('array parameters (rule_v2 ["post.a","post.b"])', () => { + it('matches when ANY listed source has the payload', async () => { + const arrRules = { + firewall: [{ id: 'arr', rule_v2: [{ parameter: ['post.a', 'post.b'], match: { type: 'contains', value: 'evil' } }] }], + whitelists: [], + whitelist_keys: {}, + }; + const g = (await createProtection({ rules: arrRules, mode: 'block' })).fetchGuard(); + expect((await g(jsonReq({ a: 'evil' })))?.status).toBe(403); + expect((await g(jsonReq({ b: 'evil' })))?.status).toBe(403); + expect(await g(jsonReq({ a: 'ok' }))).toBeNull(); + }); +}); + +describe('response phase — leak detection', () => { + const leak = () => new Response(JSON.stringify({ ok: true, awsKey: 'AKIAIOSFODNN7EXAMPLE' }), { status: 200, headers: { 'content-type': 'application/json' } }); + + it('default rules REDACT the leaked span and still serve the page', async () => { + const detections: any[] = []; + const p = await createProtection({ mode: 'block', onDetect: (d: any) => detections.push(d) }); + const res: any = await p.fetch(leak)(jsonReq({})); + expect(res.status).toBe(200); + const body = await res.text(); + expect(body.includes('AKIAIOSFODNN7EXAMPLE')).toBe(false); + expect(body.includes('[REDACTED]')).toBe(true); + expect(detections.some((d) => d.phase === 'response' && d.category === 'secret-exposure')).toBe(true); + }); + + it('action:block rule withholds the whole response', async () => { + const p = await createProtection({ + mode: 'block', + responseRules: [{ id: 'r-block', phase: 'response', action: 'block', category: 'secret-exposure', rule_v2: [{ parameter: 'response.body', match: { type: 'contains', value: 'TOPSECRET' } }] }], + }); + const res: any = await p.fetch(() => new Response('x TOPSECRET y', { headers: { 'content-type': 'text/plain' } }))(jsonReq({})); + expect(res.status).toBe(500); + }); + + it('dry-run serves the ORIGINAL (unmasked) response', async () => { + const p = await createProtection({ mode: 'dry-run' }); + const res: any = await p.fetch(leak)(jsonReq({})); + expect((await res.text()).includes('AKIAIOSFODNN7EXAMPLE')).toBe(true); + }); +}); + +describe('egress phase — SSRF', () => { + it('block mode: internal host blocked, external allowed', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async (u: any) => ({ marker: 'stub', url: String(u) })) as any; + const p = await createProtection({ egress: true, mode: 'block' }); + try { + await expect(globalThis.fetch('http://169.254.169.254/latest/meta-data/')).rejects.toThrow(); + expect((await (globalThis.fetch as any)('https://api.example.com/')).marker).toBe('stub'); + } finally { + p.uninstallEgress?.(); + globalThis.fetch = orig; + } + }); + + it('dry-run: detected but allowed', async () => { + const orig = globalThis.fetch; + globalThis.fetch = (async () => ({ marker: 'stub' })) as any; + const detections: any[] = []; + const p = await createProtection({ egress: true, mode: 'dry-run', onDetect: (d: any) => detections.push(d) }); + try { + expect((await (globalThis.fetch as any)('http://127.0.0.1:9000/')).marker).toBe('stub'); + } finally { + p.uninstallEgress?.(); + globalThis.fetch = orig; + } + expect(detections.some((d) => d.phase === 'egress')).toBe(true); + }); +}); From 660091813592258a678dcada5913a3d559bc9d5a Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 12:12:35 +0200 Subject: [PATCH 2/2] fix(protect): runtime node() re-exposes the buffered body downstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit node() consumes the request stream to screen it, then called next() without setting req.body — so a downstream handler (or a body-parser mounted after the guard) saw an empty stream. Now sets req.body to the parsed body on allow (only if not already set by an upstream parser), matching the engine's createNodeMiddleware. Adds node-body tests. Co-Authored-By: Claude Fable 5 --- src/protect/runtime.js | 11 +++++-- tests/protect/node-body.test.ts | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 tests/protect/node-body.test.ts diff --git a/src/protect/runtime.js b/src/protect/runtime.js index a30291d..588edd4 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -206,9 +206,11 @@ export async function createProtection(options = {}) { }); req.on('end', () => { const rawBody = overflow ? '' : Buffer.concat(chunks).toString('utf8'); + let shaped; let result; try { - result = engine.evaluate(fromNodeRequest(req, rawBody)); + shaped = fromNodeRequest(req, rawBody); + result = engine.evaluate(shaped); } catch (err) { onError?.(err); return next(); @@ -221,7 +223,12 @@ export async function createProtection(options = {}) { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(blockBody(result))); }, - () => next(), + () => { + // 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; + next(); + }, ); }); }; diff --git a/tests/protect/node-body.test.ts b/tests/protect/node-body.test.ts new file mode 100644 index 0000000..85d1358 --- /dev/null +++ b/tests/protect/node-body.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { Readable } from 'node:stream'; +import { createProtection } from '../../src/protect/runtime.js'; + +const rules = { + firewall: [{ id: 'b', rule_v2: [{ parameter: 'post.title', match: { type: 'contains', value: 'evil' } }] }], + whitelists: [], + whitelist_keys: {}, +}; + +function mockReq({ headers = {}, body = '' }: any) { + const r: any = Readable.from(body ? [Buffer.from(body)] : []); + r.method = 'POST'; + r.url = '/'; + r.headers = headers; + r.socket = { remoteAddress: '1.1.1.1' }; + return r; +} +function mockRes(): any { + return { statusCode: 200, ended: false, setHeader() {}, end() { this.ended = true; } }; +} +function run(mw: any, req: any, res: any): Promise<{ nexted: boolean }> { + return new Promise((resolve) => { + const origEnd = res.end.bind(res); + res.end = (c: any) => { origEnd(c); resolve({ nexted: false }); }; + mw(req, res, () => resolve({ nexted: true })); + }); +} + +describe('runtime node() re-exposes the buffered body', () => { + it('sets req.body (parsed) so a downstream handler can read it after screening', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const req = mockReq({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'ok' }) }); + const { nexted } = await run(p.node(), req, mockRes()); + expect(nexted).toBe(true); + expect(req.body).toEqual({ title: 'ok' }); // guard consumed the stream, then re-exposed it + }); + + it('does not clobber a req.body already set by an upstream parser', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const req = mockReq({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'ok' }) }); + req.body = { title: 'ok', fromParser: true }; + await run(p.node(), req, mockRes()); + expect(req.body.fromParser).toBe(true); + }); + + it('still blocks a match', async () => { + const p = await createProtection({ rules, mode: 'block' }); + const res = mockRes(); + await run(p.node(), mockReq({ headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'evil' }) }), res); + expect(res.statusCode).toBe(403); + }); +});