diff --git a/src/protect/defaults.js b/src/protect/defaults.js index 58af634..0495fa4 100644 --- a/src/protect/defaults.js +++ b/src/protect/defaults.js @@ -55,6 +55,14 @@ export const DEFAULT_RESPONSE_RULES = [ category: 'info-exposure', action: 'redact', rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/' } }] + }, + { + id: 'resp-sql-error', + title: 'SQL / ORM error disclosure in response body', + phase: 'response', + category: 'info-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/(SQLSTATE\\[[0-9A-Z]+\\]|SequelizeDatabaseError|ER_[A-Z_]+|ORA-\\d{5}|PG::[A-Za-z]+Error|SQLITE_ERROR|You have an error in your SQL syntax)/i' } }] } ]; diff --git a/src/protect/egress.js b/src/protect/egress.js index 6305df3..ea69519 100644 --- a/src/protect/egress.js +++ b/src/protect/egress.js @@ -1,43 +1,115 @@ -// 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. +// Egress guard MECHANISM only. Wraps the app's outbound calls so they 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. +// WinterCG-first: always wraps the global `fetch` (Node 18+, Workers, Bun, Deno). On Node it +// ALSO patches `node:http`/`node:https` so outbound calls made via those modules (axios, got, +// the raw http client, …) — which never touch `fetch` — are screened too. /** * @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) + * @returns {Promise<() => void>} uninstall (restores every patched surface) */ -export function installEgressGuard({ shouldBlock, onBlock } = {}) { - const original = globalThis.fetch; - if (typeof original !== 'function' || original.__patchstackGuarded || typeof shouldBlock !== 'function') { - return () => {}; +export async function installEgressGuard({ shouldBlock, onBlock } = {}) { + const restores = []; + if (typeof shouldBlock !== 'function') return () => {}; + + const block = (url, host, method) => { + if (!shouldBlock(url, host, method)) return false; + onBlock?.({ url, host, method }); + return true; + }; + + // 1. global fetch — synchronous, so it's active the instant this returns (no startup race). + const originalFetch = globalThis.fetch; + if (typeof originalFetch === 'function' && !originalFetch.__patchstackGuarded) { + 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 (block(url, host, method)) { + throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`); + } + return originalFetch(input, init); + }; + guarded.__patchstackGuarded = true; + globalThis.fetch = guarded; + restores.push(() => { + if (globalThis.fetch === guarded) globalThis.fetch = originalFetch; + }); } - const guarded = async (input, init) => { - const url = typeof input === 'string' ? input : (input && input.url) || String(input); - let host = null; + // 2. node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws). + for (const moduleName of ['node:http', 'node:https']) { try { - host = new URL(url).hostname; + const mod = await import(moduleName); + const restore = patchHttpModule(mod.default ?? mod, block); + if (restore) restores.push(restore); } catch { - host = null; + /* module not available on this runtime — skip */ } - 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 () => { + for (const restore of restores) { + try { + restore(); + } catch { + /* ignore */ + } } - return original(input, init); }; +} + +// Wrap http(s).request/get so a blocked destination throws before the socket opens. +function patchHttpModule(http, block) { + if (!http || typeof http.request !== 'function' || http.__patchstackGuarded) return null; + const originalRequest = http.request; + const originalGet = http.get; - guarded.__patchstackGuarded = true; - globalThis.fetch = guarded; + const wrap = (original) => + function (...args) { + const target = extractHttpTarget(args); + if (target && block(target.url, target.host, target.method)) { + throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`); + } + return original.apply(this, args); + }; + + http.request = wrap(originalRequest); + if (typeof originalGet === 'function') http.get = wrap(originalGet); + http.__patchstackGuarded = true; return () => { - if (globalThis.fetch === guarded) { - globalThis.fetch = original; - } + http.request = originalRequest; + if (typeof originalGet === 'function') http.get = originalGet; + delete http.__patchstackGuarded; }; } + +// http.request accepts (url), (url, options), or (options) — with an optional trailing callback. +function extractHttpTarget(args) { + const first = args[0]; + try { + if (typeof first === 'string' || first instanceof URL) { + const url = new URL(String(first)); + const opts = args.find((a) => a && typeof a === 'object' && !(a instanceof URL)); + return { url: url.href, host: url.hostname, method: (opts && opts.method) || 'GET' }; + } + if (first && typeof first === 'object') { + const host = String(first.hostname || first.host || '').split(':')[0]; + const protocol = first.protocol || 'http:'; + const port = first.port ? `:${first.port}` : ''; + const path = first.path || '/'; + return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || 'GET' }; + } + } catch { + /* fall through */ + } + return null; +} diff --git a/src/protect/engine/fetch.js b/src/protect/engine/fetch.js index 3a3321a..c9deaff 100644 --- a/src/protect/engine/fetch.js +++ b/src/protect/engine/fetch.js @@ -8,9 +8,14 @@ // free of Node-only APIs. import { RuleEngine } from './engine.js'; +// Cap how much request body we buffer for inspection. A larger body is left UNSCANNED +// (fail-open) rather than buffered into memory — matches the node adapter's maxBodyBytes. +const MAX_BODY_BYTES = 1024 * 1024; + // Build the engine's request shape from a WHATWG Request. The body is read from a // CLONE so the downstream handler still receives an intact request. -export async function fromFetchRequest(request) { +export async function fromFetchRequest(request, options = {}) { + const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES; const url = new URL(request.url); const method = (request.method || 'GET').toUpperCase(); @@ -30,15 +35,12 @@ export async function fromFetchRequest(request) { let rawBody = ''; if (method !== 'GET' && method !== 'HEAD') { - try { - rawBody = await request.clone().text(); - } catch { - rawBody = ''; - } + rawBody = await readCappedText(request, maxBodyBytes); } const contentType = headers['content-type'] || ''; let body = {}; + let files; if (rawBody) { if (contentType.includes('application/json')) { try { @@ -51,6 +53,13 @@ export async function fromFetchRequest(request) { for (const [k, v] of new URLSearchParams(rawBody)) { body[k] = k in body ? [].concat(body[k], v) : v; } + } else if (contentType.includes('multipart/form-data')) { + const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2]; + if (boundary) { + const parsed = parseMultipart(rawBody, boundary); + body = parsed.body; + files = parsed.files; + } } } @@ -64,6 +73,7 @@ export async function fromFetchRequest(request) { originalUrl: uri, query, body, + files, headers, ip: forwarded.split(',')[0].trim(), cookies: parseCookies(headers.cookie), @@ -73,6 +83,52 @@ export async function fromFetchRequest(request) { }; } +// Read a request body as text, but leave it UNSCANNED (fail-open) past `max` bytes so a huge +// upload can't be buffered for inspection. A declared oversize (Content-Length) is skipped before +// reading; anything else is read from a clone (so the downstream handler keeps an intact body) and +// discarded if it turns out over the cap. +async function readCappedText(request, max) { + const declared = Number(request.headers?.get?.('content-length') || 0); + if (declared && declared > max) return ''; + let clone; + try { + clone = request.clone(); + } catch { + return ''; + } + try { + const text = await clone.text(); + return text.length > max ? '' : text; + } catch { + return ''; + } +} + +// Minimal multipart/form-data parser: enough to expose field names + values (so `post.` +// and `raw` rules match uploads, e.g. a `__proto__` field name) and file metadata (filename via +// `files.`). We only need the textual structure, not the binary file contents. +function parseMultipart(rawBody, boundary) { + const body = {}; + const files = {}; + for (const part of rawBody.split('--' + boundary)) { + const headerEnd = part.indexOf('\r\n\r\n'); + if (headerEnd === -1) continue; + const rawHeaders = part.slice(0, headerEnd); + const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0]; + if (!disposition) continue; + const name = /name="([^"]*)"/i.exec(disposition)?.[1]; + if (name == null) continue; + const content = part.slice(headerEnd + 4).replace(/\r\n$/, ''); + const filename = /filename="([^"]*)"/i.exec(disposition)?.[1]; + if (filename !== undefined) { + files[name] = filename; // engine resolves files. → the uploaded filename + } else { + body[name] = name in body ? [].concat(body[name], content) : content; + } + } + return { body, files }; +} + function parseCookies(header) { const cookies = {}; if (!header) { diff --git a/src/protect/runtime.js b/src/protect/runtime.js index c3f1636..09ef1af 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -125,8 +125,18 @@ export async function createProtection(options = {}) { if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' }; if (blockRule) return { verdict: 'block' }; let body = text; - for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category)); - return { verdict: 'redact', body }; + // Redact the offending spans in the body AND in every (string) header value — so a secret + // that leaks in a header (Set-Cookie, an echoed X-Api-Key, …) is masked too, and a rule that + // targets `response.header.*` actually strips the header rather than just detecting it. + const headers = { ...(meta.headers || {}) }; + for (const { rule, redactors } of redactions) { + const mask = maskFn(rule.category); + body = applyRedactors(body, redactors, mask); + for (const name of Object.keys(headers)) { + if (typeof headers[name] === 'string') headers[name] = applyRedactors(headers[name], redactors, mask); + } + } + return { verdict: 'redact', body, headers }; }; // Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard). @@ -135,7 +145,7 @@ export async function createProtection(options = {}) { if (text == null) return response; const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }); if (r.verdict === 'block') return leakResponse(); - if (r.verdict === 'redact') return rebuildResponse(response, r.body); + if (r.verdict === 'redact') return rebuildResponse(response, r.body, r.headers); return response; }; @@ -174,7 +184,7 @@ export async function createProtection(options = {}) { if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); } let r; try { - r = screenText(text, { status: res.statusCode, headers: {} }); + r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} }); } catch (err) { onError?.(err); for (const c of chunks) origWrite(c); @@ -187,6 +197,14 @@ export async function createProtection(options = {}) { } if (r.verdict === 'redact') { try { res.removeHeader && res.removeHeader('content-length'); } catch { /* ignore */ } + if (r.headers && res.setHeader) { + const current = res.getHeaders ? res.getHeaders() : {}; + for (const [name, value] of Object.entries(r.headers)) { + if (typeof value === 'string' && current[name] !== value) { + try { res.setHeader(name, value); } catch { /* ignore invalid header */ } + } + } + } return origEnd(r.body, cb); } for (const c of chunks) origWrite(c); @@ -330,9 +348,9 @@ export async function createProtection(options = {}) { }, }; - // Egress interception is opt-in (it wraps the global fetch). + // Egress interception is opt-in (it wraps the global fetch, and node:http/https on Node). if (options.egress) { - protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock }); + protection.uninstallEgress = await installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock }); } return protection; @@ -403,9 +421,16 @@ function applyRedactors(body, redactors, mask) { return out; } -function rebuildResponse(response, body) { +function rebuildResponse(response, body, redactedHeaders) { const headers = new Headers(response.headers); headers.delete('content-length'); // body length changed after redaction + if (redactedHeaders) { + for (const [name, value] of Object.entries(redactedHeaders)) { + if (typeof value === 'string' && headers.get(name) !== value) { + try { headers.set(name, value); } catch { /* invalid header name — skip */ } + } + } + } return new Response(body, { status: response.status, statusText: response.statusText, headers }); } diff --git a/tests/protect/egress-node-http.test.ts b/tests/protect/egress-node-http.test.ts new file mode 100644 index 0000000..09db882 --- /dev/null +++ b/tests/protect/egress-node-http.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +// The egress guard patches node:http/https (not just global fetch) so outbound calls made via +// those modules — axios, got, the raw client — are screened for SSRF too. + +async function nodeHttp() { + const ns: any = await import('node:http'); + return ns.default ?? ns; +} + +describe('egress guard — node:http', () => { + it('blocks a node:http request to an internal/metadata host, allows external, restores on uninstall', async () => { + const origFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response('stub')) as any; // avoid real network during setup + const p: any = await createProtection({ egress: true, mode: 'block', allowHosts: [] }); + try { + const http = await nodeHttp(); + expect(http.__patchstackGuarded).toBe(true); // module was patched + + let blocked = false; + try { + http.request('http://169.254.169.254/latest/meta-data/'); + } catch (err) { + blocked = /Patchstack blocked/.test(String(err)); + } + expect(blocked).toBe(true); // internal host → thrown before any socket + + // An external host is allowed (guard doesn't throw); destroy immediately, swallow the socket error. + let threw = false; + try { + const r = http.request('http://example.com/'); + r.on('error', () => {}); + r.destroy(); + } catch { + threw = true; + } + expect(threw).toBe(false); + } finally { + p.uninstallEgress?.(); + globalThis.fetch = origFetch; + } + + const http = await nodeHttp(); + expect(http.__patchstackGuarded).toBeUndefined(); // restored after uninstall + }); +}); diff --git a/tests/protect/fetch-body-cap.test.ts b/tests/protect/fetch-body-cap.test.ts new file mode 100644 index 0000000..b955a1a --- /dev/null +++ b/tests/protect/fetch-body-cap.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { fromFetchRequest } from '../../src/protect/engine/fetch.js'; +import { createProtection } from '../../src/protect/runtime.js'; + +// The fetch/route-WAF path must not buffer an unbounded request body into memory. Past the cap +// the body is left UNSCANNED (fail-open) — the request is still evaluated on query/headers/url. + +const post = (body: string, ct = 'application/json', maxBodyBytes?: number) => + fromFetchRequest( + new Request('https://app/x', { method: 'POST', headers: { 'content-type': ct }, body }), + maxBodyBytes ? { maxBodyBytes } : {}, + ); + +describe('fetch request-body cap', () => { + it('skips a body larger than the cap, keeps a small one', async () => { + const overCap = await post('x'.repeat(200), 'text/plain', 32); + expect(overCap._rawBody).toBe(''); + expect(overCap.body).toEqual({}); + + const underCap = await post('small', 'text/plain', 32); + expect(underCap._rawBody).toBe('small'); + }); + + it('does not block a malicious payload buried in an oversized body (fail-open)', async () => { + const rules = { + firewall: [{ id: 'proto', title: 'proto', rule_v2: [{ parameter: 'raw', match: { type: 'contains', value: '__proto__' } }] }], + whitelists: [], + whitelist_keys: {}, + }; + const p = await createProtection({ rules, mode: 'block' }); + const guard = p.fetchGuard(); + + const huge = '{"__proto__":{"x":1},"pad":"' + 'a'.repeat(1024 * 1024 + 64) + '"}'; + const oversized = await guard(new Request('https://app/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: huge })); + expect(oversized).toBeNull(); // over the 1 MiB cap → body unscanned → allowed + + const small = await guard(new Request('https://app/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"__proto__":{"x":1}}' })); + expect(small).not.toBeNull(); // within cap → scanned → blocked + }); +}); diff --git a/tests/protect/multipart.test.ts b/tests/protect/multipart.test.ts new file mode 100644 index 0000000..3f89fea --- /dev/null +++ b/tests/protect/multipart.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { fromFetchRequest } from '../../src/protect/engine/fetch.js'; +import { createProtection } from '../../src/protect/runtime.js'; + +// multipart/form-data bodies are parsed so field-scoped rules (post.) and `raw` match +// uploads — e.g. a `' }, + { name: '__proto__[polluted]', value: 'yes' }, + { name: 'avatar', value: '', filename: 'evil.svg', type: 'image/svg+xml' }, + ]), + ), + ); + expect(shaped.body.title).toBe(''); + expect(shaped.body['__proto__[polluted]']).toBe('yes'); + expect(shaped.files.avatar).toBe('evil.svg'); + expect(shaped._rawBody).toContain('__proto__'); + }); + + it('a post. rule blocks a malicious multipart field', async () => { + const rules = { + firewall: [{ id: 'xss', title: 'xss', rule_v2: [{ parameter: ['post.title'], match: { type: 'contains', value: 'x' }])))).not.toBeNull(); + expect(await guard(req(multipart([{ name: 'title', value: 'a normal title' }])))).toBeNull(); + }); +}); diff --git a/tests/protect/response-hardening.test.ts b/tests/protect/response-hardening.test.ts new file mode 100644 index 0000000..d8ed3f5 --- /dev/null +++ b/tests/protect/response-hardening.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; +import { createProtection } from '../../src/protect/runtime.js'; + +const AWS = 'AKIAIOSFODNN7EXAMPLE'; +const json = (body: string, headers: Record = {}) => + new Response(body, { status: 200, headers: { 'content-type': 'application/json', ...headers } }); + +// #2 — response-HEADER screening, and #5 — verbose-error (SQL) suppression. + +describe('response-header screening', () => { + it('redacts a secret matched by a response.header.* rule', async () => { + const p = await createProtection({ + mode: 'block', + responseRules: [ + { + phase: 'response', + category: 'secret-exposure', + action: 'redact', + rule_v2: [{ parameter: 'response.header.x-api-key', match: { type: 'regex', value: '/AKIA[0-9A-Z]{16}/' } }], + }, + ], + }); + const res: any = await p.screenResponse(json('{"ok":true}', { 'x-api-key': AWS })); + expect(res.headers.get('x-api-key')).not.toContain('AKIA'); + expect(res.headers.get('x-api-key')).toContain('[REDACTED]'); + }); + + it('masks a secret that also leaks into a header (built-in default rule)', async () => { + const p = await createProtection({ mode: 'block' }); // default response rules (body-targeted) + const res: any = await p.screenResponse(json(`{"key":"${AWS}"}`, { 'x-leak': AWS })); + const body = await res.text(); + expect(body.includes(AWS)).toBe(false); // body redacted (default rule matched) + expect(res.headers.get('x-leak')).not.toContain('AKIA'); // and the same secret in the header + }); + + it('leaves benign headers untouched', async () => { + const p = await createProtection({ mode: 'block' }); + const res: any = await p.screenResponse(json('{"ok":true}', { 'x-request-id': 'abc-123' })); + expect(res.headers.get('x-request-id')).toBe('abc-123'); + }); +}); + +describe('verbose-error suppression (SQL/ORM disclosure)', () => { + it('redacts a SQL/ORM error signature from the response body', async () => { + const p = await createProtection({ mode: 'block' }); + const res: any = await p.screenResponse(json('{"error":"SequelizeDatabaseError: relation users does not exist"}', {})); + const body = await res.text(); + expect(body.includes('SequelizeDatabaseError')).toBe(false); + expect(body).toContain('[REDACTED]'); + }); + + it('leaves a benign error body unchanged', async () => { + const p = await createProtection({ mode: 'block' }); + const res: any = await p.screenResponse(json('{"error":"Not found"}', {})); + expect(await res.text()).toContain('Not found'); + }); +});