From dbb157eb775fd3250ffe5135b90e8969d43df322 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 11:40:30 +0200 Subject: [PATCH 1/4] =?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 48bf969180728e26c875ffd18071216135cf28ff Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Tue, 14 Jul 2026 11:59:05 +0200 Subject: [PATCH 2/4] test(protect): port the engine unit suite + restructure tests/protect/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the node-waf engine was vendored into src/protect/engine/, its ~180 unit tests did NOT come along — connect only had the runtime integration test, leaving matchValue / resolver / normalizer / mutations / adapters with no direct coverage. Ports the engine suite from the upstream engine (adapted to vitest) and groups all protect tests under tests/protect/: - engine.test.ts (54) — match types incl. internal_host, evaluate, inclusive/OR, whitelist, nested rules, ctype value-inversion, array parameters, fail-open, safeRegExp - request.test.ts (38) — resolver: get/post/request/cookie/server/files/raw/all + response.*/egress.* + wildcards + mutations - normalizer.test.ts (63) — normalize pipeline + _rawBody prototype-pollution serialization - fetch.test.ts (6) / node.test.ts (5) — the fetch + node request adapters - middleware.test.ts (16) — the Express middleware + rule client (mocked fetch) - runtime.test.ts (11) — createProtection integration (moved from tests/protect.test.ts): request/response-redact/response-block/egress, dry-run/block, Supabase + server-fn guards Full suite 333 pass; typecheck + build green. Engine source unchanged. Co-Authored-By: Claude Fable 5 --- tests/protect/engine.test.ts | 594 ++++++++++++++++++ tests/protect/fetch.test.ts | 98 +++ tests/protect/fixtures/rules-response.json | 114 ++++ tests/protect/middleware.test.ts | 297 +++++++++ tests/protect/node.test.ts | 118 ++++ tests/protect/normalizer.test.ts | 469 ++++++++++++++ tests/protect/request.test.ts | 327 ++++++++++ .../runtime.test.ts} | 2 +- 8 files changed, 2018 insertions(+), 1 deletion(-) create mode 100644 tests/protect/engine.test.ts create mode 100644 tests/protect/fetch.test.ts create mode 100644 tests/protect/fixtures/rules-response.json create mode 100644 tests/protect/middleware.test.ts create mode 100644 tests/protect/node.test.ts create mode 100644 tests/protect/normalizer.test.ts create mode 100644 tests/protect/request.test.ts rename tests/{protect.test.ts => protect/runtime.test.ts} (99%) diff --git a/tests/protect/engine.test.ts b/tests/protect/engine.test.ts new file mode 100644 index 0000000..c416804 --- /dev/null +++ b/tests/protect/engine.test.ts @@ -0,0 +1,594 @@ +import { describe, it } from 'vitest'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { RuleEngine, _testExports } from '../../src/protect/engine/engine.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureRules = JSON.parse( + await readFile(join(__dirname, 'fixtures', 'rules-response.json'), 'utf-8') +); + +const { matchValue, safeRegExp } = _testExports; + +function createReq(overrides = {}) { + return { + method: 'GET', + url: '/test', + originalUrl: '/test', + query: {}, + body: {}, + headers: {}, + ...overrides + }; +} + +describe('RuleEngine', () => { + + describe('matchValue', () => { + + it('should match equals (loose)', () => { + assert.strictEqual(matchValue('equals', '1', 1), true); + assert.strictEqual(matchValue('equals', 'hello', 'hello'), true); + assert.strictEqual(matchValue('equals', 'hello', 'world'), false); + }); + + it('should match equals_strict', () => { + assert.strictEqual(matchValue('equals_strict', '1', '1'), true); + assert.strictEqual(matchValue('equals_strict', '1', 1), false); + }); + + it('should match contains (case-insensitive)', () => { + assert.strictEqual(matchValue('contains', 'Hello World', 'hello'), true); + assert.strictEqual(matchValue('contains', 'test', 'xyz'), false); + }); + + it('should match not_contains', () => { + assert.strictEqual(matchValue('not_contains', 'hello', 'xyz'), true); + assert.strictEqual(matchValue('not_contains', 'hello', 'ell'), false); + }); + + it('should match regex', () => { + assert.strictEqual(matchValue('regex', 'UNION SELECT', '/union\\s+select/i'), true); + assert.strictEqual(matchValue('regex', 'normal query', '/union\\s+select/i'), false); + }); + + it('should match more_than', () => { + assert.strictEqual(matchValue('more_than', '10', 5), true); + assert.strictEqual(matchValue('more_than', '3', 5), false); + }); + + it('should match less_than', () => { + assert.strictEqual(matchValue('less_than', '3', 5), true); + assert.strictEqual(matchValue('less_than', '10', 5), false); + }); + + it('should match ctype_digit', () => { + assert.strictEqual(matchValue('ctype_digit', '12345', null), true); + assert.strictEqual(matchValue('ctype_digit', '123abc', null), false); + }); + + it('should honor the value inversion for ctype_* (value:false = flag when NOT of class)', () => { + // The canonical vPatch shape: `{ type: 'ctype_digit', value: false }` must FLAG a + // non-numeric value and PASS a numeric one — previously the matchVal was ignored, + // which inverted the rule (blocking legit numeric IDs, passing injection). + assert.strictEqual(matchValue('ctype_digit', '123abc', false), true, 'non-numeric should match value:false'); + assert.strictEqual(matchValue('ctype_digit', '12345', false), false, 'numeric should not match value:false'); + assert.strictEqual(matchValue('ctype_alnum', 'a b;drop', false), true, 'special chars match value:false'); + assert.strictEqual(matchValue('ctype_alnum', 'abc123', false), false, 'alnum should not match value:false'); + }); + + it('should not match ctype_*/is_numeric on empty or absent values', () => { + // engine-php skips empty (`$value != ''`); otherwise every missing param would + // false-positive a `value:false` rule. + assert.strictEqual(matchValue('ctype_digit', '', false), false); + assert.strictEqual(matchValue('ctype_alnum', '', false), false); + assert.strictEqual(matchValue('is_numeric', '', false), false); + assert.strictEqual(matchValue('ctype_digit', null, false), false); + }); + + it('should match is_numeric', () => { + assert.strictEqual(matchValue('is_numeric', '3.14', null), true); + assert.strictEqual(matchValue('is_numeric', 'abc', null), false); + assert.strictEqual(matchValue('is_numeric', '', null), false); + }); + + it('should match isset', () => { + assert.strictEqual(matchValue('isset', 'anything', null), true); + assert.strictEqual(matchValue('isset', null, null), false); + }); + + it('should match in_array', () => { + assert.strictEqual(matchValue('in_array', 'b', ['a', 'b', 'c']), true); + assert.strictEqual(matchValue('in_array', 'x', ['a', 'b', 'c']), false); + }); + + it('should match not_in_array', () => { + assert.strictEqual(matchValue('not_in_array', 'x', ['a', 'b']), true); + assert.strictEqual(matchValue('not_in_array', 'a', ['a', 'b']), false); + }); + + it('should match hostname', () => { + assert.strictEqual(matchValue('hostname', 'https://example.com/path', 'example.com'), true); + assert.strictEqual(matchValue('hostname', 'https://other.com/path', 'example.com'), false); + }); + + it('should match internal_host (SSRF egress ranges)', () => { + for (const host of ['127.0.0.1', '169.254.169.254', '10.0.0.5', '192.168.1.1', '172.16.0.1', 'localhost', 'metadata.google.internal', '::1']) { + assert.strictEqual(matchValue('internal_host', host, null), true, `${host} should be internal`); + } + for (const host of ['example.com', '8.8.8.8', '1.1.1.1', '172.32.0.1']) { + assert.strictEqual(matchValue('internal_host', host, null), false, `${host} should be external`); + } + }); + + it('should match quotes (and the inline_js_xss alias)', () => { + assert.strictEqual(matchValue('quotes', `x' OR 1=1`, null), true); + assert.strictEqual(matchValue('quotes', 'no quotes here', null), false); + assert.strictEqual(matchValue('inline_js_xss', 'say "hi"', null), true); + }); + + it('should match inline_xss (quote AND > or =)', () => { + assert.strictEqual(matchValue('inline_xss', `" onmouseover=alert(1)`, null), true); + assert.strictEqual(matchValue('inline_xss', `">' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 102); + }); + + it('should block path traversal in URI', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ + url: '/files/../../../etc/passwd', + originalUrl: '/files/../../../etc/passwd' + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 104); + }); + + it('should block prototype pollution in raw body', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ + body: '{"__proto__":{"admin":true}}' + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 105); + }); + + it('should block SSRF to internal IPs', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ + method: 'POST', + body: { url: 'http://127.0.0.1/admin' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 106); + }); + + it('should block base64-encoded payloads with mutations', () => { + const engine = new RuleEngine(fixtureRules); + const encoded = Buffer.from('').toString('base64'); + const req = createReq({ + method: 'POST', + body: { data: encoded } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 107); + }); + + it('should block malicious user-agent', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ + headers: { 'user-agent': 'sqlmap/1.5' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 108); + }); + + it('should allow clean requests', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ query: { search: 'normal search term' } }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, false); + assert.strictEqual(result.rule, null); + }); + + it('should handle empty rules', () => { + const engine = new RuleEngine({ firewall: [] }); + const result = engine.evaluate(createReq()); + + assert.strictEqual(result.blocked, false); + }); + + it('resolves array parameters (["get.x","post.x"]) as OR across sources', () => { + const engine = new RuleEngine({ + firewall: [{ id: 'a', rule_v2: [{ parameter: ['get.x', 'post.x'], match: { type: 'contains', value: 'bad' } }] }] + }); + assert.strictEqual(engine.evaluate(createReq({ query: { x: 'bad' } })).blocked, true, 'matches via get'); + assert.strictEqual(engine.evaluate(createReq({ query: {}, body: { x: 'bad' } })).blocked, true, 'matches via post'); + assert.strictEqual(engine.evaluate(createReq({ query: { x: 'ok' } })).blocked, false, 'no match'); + }); + + }); + + describe('fail open', () => { + // A rule whose evaluation throws (here: a getter that blows up). + const throwingRule = { + id: 'bad', + get rule_v2() { + throw new Error('boom'); + } + }; + + it('skips a throwing rule, allows the request, and reports the error', () => { + const errors = []; + const engine = new RuleEngine({ firewall: [throwingRule], onError: (e) => errors.push(e) }); + + const result = engine.evaluate(createReq({ query: { x: '1' } })); + + assert.strictEqual(result.blocked, false); // fail open — never throws, never blocks + assert.strictEqual(errors.length, 1); + assert.match(errors[0].message, /boom/); + }); + + it('a throwing rule does not shadow a later rule that matches', () => { + const good = { + id: 'good', + title: 'x', + rule_v2: [{ parameter: 'get.x', match: { type: 'isset' } }] + }; + const engine = new RuleEngine({ firewall: [throwingRule, good], onError: () => {} }); + + const result = engine.evaluate(createReq({ query: { x: '1' } })); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 'good'); + }); + + it('evaluate() never throws even without an onError handler', () => { + const engine = new RuleEngine({ firewall: [throwingRule] }); + // Suppress the default console.error for this assertion. + const originalError = console.error; + console.error = () => {}; + try { + assert.doesNotThrow(() => engine.evaluate(createReq())); + } finally { + console.error = originalError; + } + }); + }); + + describe('inclusive (AND) logic', () => { + + it('should require ALL inclusive conditions to match', () => { + const engine = new RuleEngine(fixtureRules); + + // Rule 103 requires BOTH: post.action=delete_all_users AND REQUEST_METHOD=POST + const req = createReq({ + method: 'POST', + body: { action: 'delete_all_users' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + assert.strictEqual(result.rule.id, 103); + }); + + it('should not block when only one inclusive condition matches', () => { + const engine = new RuleEngine({ + firewall: [{ + id: 200, + title: 'Test AND rule', + rule_v2: [ + { parameter: 'post.action', match: { type: 'equals', value: 'delete_all' }, inclusive: true }, + { parameter: 'post.confirm', match: { type: 'equals', value: 'yes' }, inclusive: true } + ] + }] + }); + + // Only action matches, confirm is missing + const req = createReq({ + method: 'POST', + body: { action: 'delete_all' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, false); + }); + + it('should block when all inclusive conditions match', () => { + const engine = new RuleEngine({ + firewall: [{ + id: 200, + title: 'Test AND rule', + rule_v2: [ + { parameter: 'post.action', match: { type: 'equals', value: 'delete_all' }, inclusive: true }, + { parameter: 'post.confirm', match: { type: 'equals', value: 'yes' }, inclusive: true } + ] + }] + }); + + const req = createReq({ + method: 'POST', + body: { action: 'delete_all', confirm: 'yes' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + }); + + }); + + describe('whitelists', () => { + + it('should not block whitelisted requests', () => { + const engine = new RuleEngine(fixtureRules); + + // Rule 101 blocks SQL injection in search, but whitelist allows IP 10.0.0.1 + const req = createReq({ + query: { search: '1 UNION SELECT * FROM users' }, + ip: '10.0.0.1' + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, false); + }); + + it('should block non-whitelisted requests', () => { + const engine = new RuleEngine(fixtureRules); + + const req = createReq({ + query: { search: '1 UNION SELECT * FROM users' }, + ip: '192.168.1.1' + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + }); + + }); + + describe('nested rules', () => { + + it('should evaluate nested rule conditions', () => { + const engine = new RuleEngine({ + firewall: [{ + id: 300, + title: 'Nested rule test', + rule_v2: [ + { + parameter: 'rules', + match: { type: 'equals', value: '' }, + inclusive: false, + rules: [ + { + parameter: 'post.action', + match: { type: 'equals', value: 'exploit' }, + inclusive: true + }, + { + parameter: 'post.target', + match: { type: 'contains', value: 'admin' }, + inclusive: true + } + ] + } + ] + }] + }); + + const req = createReq({ + method: 'POST', + body: { action: 'exploit', target: 'admin_panel' } + }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true); + }); + + }); + + describe('URL-encoded bypass regression tests', () => { + + it('should block URL-encoded SQL injection (normalization enforced)', () => { + const engine = new RuleEngine(fixtureRules); + // Without normalization, %20UNION%20SELECT bypasses the /union\s+select/i regex + const req = createReq({ query: { search: '1%20UNION%20SELECT%20*%20FROM%20users' } }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true, 'URL-encoded SQLi should be caught after normalization'); + assert.strictEqual(result.rule.id, 101); + }); + + it('should block double-encoded SQL injection', () => { + const engine = new RuleEngine(fixtureRules); + const req = createReq({ query: { search: '1%2520UNION%2520SELECT' } }); + const result = engine.evaluate(req); + + assert.strictEqual(result.blocked, true, 'Double-encoded SQLi should be caught'); + assert.strictEqual(result.rule.id, 101); + }); + + it('should block URL-encoded XSS in comment field', () => { + const engine = new RuleEngine(fixtureRules); + // %3Cscript%3E decodes to