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..588edd4 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) => { @@ -125,30 +206,121 @@ 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(); } decide( + 'request', result, () => { res.statusCode = 403; 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(); + }, ); }); }; }, }; + // 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 +400,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); + }); +}); 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); + }); +});