Skip to content

Commit d69f2ec

Browse files
feat(protect): hardening — body cap, header screening, multipart, node egress, SQL-error suppression (#79)
Five request/response-guard improvements: - Cap the request body on the fetch/route-WAF path. fromFetchRequest read the whole body with no limit; now a Content-Length over the cap is skipped before reading and anything else is discarded past 1 MiB (fail-open — query/headers/url still evaluated), matching the node adapter. Configurable via { maxBodyBytes }. - Response-HEADER screening. screenText now redacts matched secret spans in header values too (not just the body), so a `response.header.*` rule actually strips the header and a secret that also leaks into a header (Set-Cookie, an echoed X-Api-Key) is masked. Wired through the fetch and node response paths. - Parse multipart/form-data. Only JSON + urlencoded were parsed into post.*; multipart fields now populate post.<field> (+ `raw`) and file parts surface via files.<field>, so field-scoped rules match uploads (e.g. a `__proto__` field name). - Egress SSRF for node:http/https. The egress guard wrapped only global fetch; it now also patches node:http/https request/get on Node (axios/got/raw client), best-effort and restored on uninstall. installEgressGuard is async; createProtection awaits it. - Verbose-error suppression: a default response rule redacts SQL/ORM error signatures (SQLSTATE/Sequelize/ORA-/PG::/SQLITE_ERROR/…) beyond the existing stack-trace rule. +10 tests (body cap, multipart, response-header screening, node:http egress, SQL redaction). 437 tests pass, typecheck + build clean. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 64824ab commit d69f2ec

8 files changed

Lines changed: 403 additions & 38 deletions

File tree

src/protect/defaults.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export const DEFAULT_RESPONSE_RULES = [
5555
category: 'info-exposure',
5656
action: 'redact',
5757
rule_v2: [{ parameter: 'response.body', match: { type: 'regex', value: '/\\n\\s+at\\s+.+\\(.+:\\d+:\\d+\\)/' } }]
58+
},
59+
{
60+
id: 'resp-sql-error',
61+
title: 'SQL / ORM error disclosure in response body',
62+
phase: 'response',
63+
category: 'info-exposure',
64+
action: 'redact',
65+
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' } }]
5866
}
5967
];
6068

src/protect/egress.js

Lines changed: 97 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,115 @@
1-
// Egress guard MECHANISM only. Wraps the global `fetch` so the app's outbound requests
2-
// can be screened; the block DECISION is delegated to a caller-supplied `shouldBlock`
3-
// predicate (which the runtime builds from egress-phase rules — see defaults.js).
4-
// No policy is hardcoded here: what counts as "internal"/disallowed lives in rules.
5-
// WinterCG — works on Node 18+, Cloudflare Workers, Bun, Deno.
1+
// Egress guard MECHANISM only. Wraps the app's outbound calls so they can be screened; the
2+
// block DECISION is delegated to a caller-supplied `shouldBlock` predicate (which the runtime
3+
// builds from egress-phase rules — see defaults.js). No policy is hardcoded here.
4+
// WinterCG-first: always wraps the global `fetch` (Node 18+, Workers, Bun, Deno). On Node it
5+
// ALSO patches `node:http`/`node:https` so outbound calls made via those modules (axios, got,
6+
// the raw http client, …) — which never touch `fetch` — are screened too.
67

78
/**
89
* @param {{ shouldBlock: (url:string, host:string|null, method:string)=>boolean,
910
* onBlock?: (info:{url:string,host:string|null,method:string})=>void }} opts
10-
* @returns {() => void} uninstall (restores the original fetch)
11+
* @returns {Promise<() => void>} uninstall (restores every patched surface)
1112
*/
12-
export function installEgressGuard({ shouldBlock, onBlock } = {}) {
13-
const original = globalThis.fetch;
14-
if (typeof original !== 'function' || original.__patchstackGuarded || typeof shouldBlock !== 'function') {
15-
return () => {};
13+
export async function installEgressGuard({ shouldBlock, onBlock } = {}) {
14+
const restores = [];
15+
if (typeof shouldBlock !== 'function') return () => {};
16+
17+
const block = (url, host, method) => {
18+
if (!shouldBlock(url, host, method)) return false;
19+
onBlock?.({ url, host, method });
20+
return true;
21+
};
22+
23+
// 1. global fetch — synchronous, so it's active the instant this returns (no startup race).
24+
const originalFetch = globalThis.fetch;
25+
if (typeof originalFetch === 'function' && !originalFetch.__patchstackGuarded) {
26+
const guarded = async (input, init) => {
27+
const url = typeof input === 'string' ? input : (input && input.url) || String(input);
28+
let host = null;
29+
try {
30+
host = new URL(url).hostname;
31+
} catch {
32+
host = null;
33+
}
34+
const method = (init && init.method) || (input && input.method) || 'GET';
35+
if (block(url, host, method)) {
36+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
37+
}
38+
return originalFetch(input, init);
39+
};
40+
guarded.__patchstackGuarded = true;
41+
globalThis.fetch = guarded;
42+
restores.push(() => {
43+
if (globalThis.fetch === guarded) globalThis.fetch = originalFetch;
44+
});
1645
}
1746

18-
const guarded = async (input, init) => {
19-
const url = typeof input === 'string' ? input : (input && input.url) || String(input);
20-
let host = null;
47+
// 2. node:http / node:https — best-effort; absent on Workers/Deno-without-node (import throws).
48+
for (const moduleName of ['node:http', 'node:https']) {
2149
try {
22-
host = new URL(url).hostname;
50+
const mod = await import(moduleName);
51+
const restore = patchHttpModule(mod.default ?? mod, block);
52+
if (restore) restores.push(restore);
2353
} catch {
24-
host = null;
54+
/* module not available on this runtime — skip */
2555
}
26-
const method = (init && init.method) || (input && input.method) || 'GET';
56+
}
2757

28-
if (shouldBlock(url, host, method)) {
29-
onBlock?.({ url, host, method });
30-
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${host ?? url}`);
58+
return () => {
59+
for (const restore of restores) {
60+
try {
61+
restore();
62+
} catch {
63+
/* ignore */
64+
}
3165
}
32-
return original(input, init);
3366
};
67+
}
68+
69+
// Wrap http(s).request/get so a blocked destination throws before the socket opens.
70+
function patchHttpModule(http, block) {
71+
if (!http || typeof http.request !== 'function' || http.__patchstackGuarded) return null;
72+
const originalRequest = http.request;
73+
const originalGet = http.get;
3474

35-
guarded.__patchstackGuarded = true;
36-
globalThis.fetch = guarded;
75+
const wrap = (original) =>
76+
function (...args) {
77+
const target = extractHttpTarget(args);
78+
if (target && block(target.url, target.host, target.method)) {
79+
throw new Error(`Patchstack blocked an outbound request to a disallowed address: ${target.host ?? target.url}`);
80+
}
81+
return original.apply(this, args);
82+
};
83+
84+
http.request = wrap(originalRequest);
85+
if (typeof originalGet === 'function') http.get = wrap(originalGet);
86+
http.__patchstackGuarded = true;
3787

3888
return () => {
39-
if (globalThis.fetch === guarded) {
40-
globalThis.fetch = original;
41-
}
89+
http.request = originalRequest;
90+
if (typeof originalGet === 'function') http.get = originalGet;
91+
delete http.__patchstackGuarded;
4292
};
4393
}
94+
95+
// http.request accepts (url), (url, options), or (options) — with an optional trailing callback.
96+
function extractHttpTarget(args) {
97+
const first = args[0];
98+
try {
99+
if (typeof first === 'string' || first instanceof URL) {
100+
const url = new URL(String(first));
101+
const opts = args.find((a) => a && typeof a === 'object' && !(a instanceof URL));
102+
return { url: url.href, host: url.hostname, method: (opts && opts.method) || 'GET' };
103+
}
104+
if (first && typeof first === 'object') {
105+
const host = String(first.hostname || first.host || '').split(':')[0];
106+
const protocol = first.protocol || 'http:';
107+
const port = first.port ? `:${first.port}` : '';
108+
const path = first.path || '/';
109+
return { url: `${protocol}//${host}${port}${path}`, host, method: first.method || 'GET' };
110+
}
111+
} catch {
112+
/* fall through */
113+
}
114+
return null;
115+
}

src/protect/engine/fetch.js

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88
// free of Node-only APIs.
99
import { RuleEngine } from './engine.js';
1010

11+
// Cap how much request body we buffer for inspection. A larger body is left UNSCANNED
12+
// (fail-open) rather than buffered into memory — matches the node adapter's maxBodyBytes.
13+
const MAX_BODY_BYTES = 1024 * 1024;
14+
1115
// Build the engine's request shape from a WHATWG Request. The body is read from a
1216
// CLONE so the downstream handler still receives an intact request.
13-
export async function fromFetchRequest(request) {
17+
export async function fromFetchRequest(request, options = {}) {
18+
const maxBodyBytes = options.maxBodyBytes ?? MAX_BODY_BYTES;
1419
const url = new URL(request.url);
1520
const method = (request.method || 'GET').toUpperCase();
1621

@@ -30,15 +35,12 @@ export async function fromFetchRequest(request) {
3035

3136
let rawBody = '';
3237
if (method !== 'GET' && method !== 'HEAD') {
33-
try {
34-
rawBody = await request.clone().text();
35-
} catch {
36-
rawBody = '';
37-
}
38+
rawBody = await readCappedText(request, maxBodyBytes);
3839
}
3940

4041
const contentType = headers['content-type'] || '';
4142
let body = {};
43+
let files;
4244
if (rawBody) {
4345
if (contentType.includes('application/json')) {
4446
try {
@@ -51,6 +53,13 @@ export async function fromFetchRequest(request) {
5153
for (const [k, v] of new URLSearchParams(rawBody)) {
5254
body[k] = k in body ? [].concat(body[k], v) : v;
5355
}
56+
} else if (contentType.includes('multipart/form-data')) {
57+
const boundary = /boundary=("?)([^";]+)\1/i.exec(contentType)?.[2];
58+
if (boundary) {
59+
const parsed = parseMultipart(rawBody, boundary);
60+
body = parsed.body;
61+
files = parsed.files;
62+
}
5463
}
5564
}
5665

@@ -64,6 +73,7 @@ export async function fromFetchRequest(request) {
6473
originalUrl: uri,
6574
query,
6675
body,
76+
files,
6777
headers,
6878
ip: forwarded.split(',')[0].trim(),
6979
cookies: parseCookies(headers.cookie),
@@ -73,6 +83,52 @@ export async function fromFetchRequest(request) {
7383
};
7484
}
7585

86+
// Read a request body as text, but leave it UNSCANNED (fail-open) past `max` bytes so a huge
87+
// upload can't be buffered for inspection. A declared oversize (Content-Length) is skipped before
88+
// reading; anything else is read from a clone (so the downstream handler keeps an intact body) and
89+
// discarded if it turns out over the cap.
90+
async function readCappedText(request, max) {
91+
const declared = Number(request.headers?.get?.('content-length') || 0);
92+
if (declared && declared > max) return '';
93+
let clone;
94+
try {
95+
clone = request.clone();
96+
} catch {
97+
return '';
98+
}
99+
try {
100+
const text = await clone.text();
101+
return text.length > max ? '' : text;
102+
} catch {
103+
return '';
104+
}
105+
}
106+
107+
// Minimal multipart/form-data parser: enough to expose field names + values (so `post.<field>`
108+
// and `raw` rules match uploads, e.g. a `__proto__` field name) and file metadata (filename via
109+
// `files.<field>`). We only need the textual structure, not the binary file contents.
110+
function parseMultipart(rawBody, boundary) {
111+
const body = {};
112+
const files = {};
113+
for (const part of rawBody.split('--' + boundary)) {
114+
const headerEnd = part.indexOf('\r\n\r\n');
115+
if (headerEnd === -1) continue;
116+
const rawHeaders = part.slice(0, headerEnd);
117+
const disposition = /content-disposition:[^\r\n]*/i.exec(rawHeaders)?.[0];
118+
if (!disposition) continue;
119+
const name = /name="([^"]*)"/i.exec(disposition)?.[1];
120+
if (name == null) continue;
121+
const content = part.slice(headerEnd + 4).replace(/\r\n$/, '');
122+
const filename = /filename="([^"]*)"/i.exec(disposition)?.[1];
123+
if (filename !== undefined) {
124+
files[name] = filename; // engine resolves files.<name> → the uploaded filename
125+
} else {
126+
body[name] = name in body ? [].concat(body[name], content) : content;
127+
}
128+
}
129+
return { body, files };
130+
}
131+
76132
function parseCookies(header) {
77133
const cookies = {};
78134
if (!header) {

src/protect/runtime.js

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,18 @@ export async function createProtection(options = {}) {
125125
if (mode !== 'block' || (!blockRule && !redactions.length)) return { verdict: 'pass' };
126126
if (blockRule) return { verdict: 'block' };
127127
let body = text;
128-
for (const { rule, redactors } of redactions) body = applyRedactors(body, redactors, maskFn(rule.category));
129-
return { verdict: 'redact', body };
128+
// Redact the offending spans in the body AND in every (string) header value — so a secret
129+
// that leaks in a header (Set-Cookie, an echoed X-Api-Key, …) is masked too, and a rule that
130+
// targets `response.header.*` actually strips the header rather than just detecting it.
131+
const headers = { ...(meta.headers || {}) };
132+
for (const { rule, redactors } of redactions) {
133+
const mask = maskFn(rule.category);
134+
body = applyRedactors(body, redactors, mask);
135+
for (const name of Object.keys(headers)) {
136+
if (typeof headers[name] === 'string') headers[name] = applyRedactors(headers[name], redactors, mask);
137+
}
138+
}
139+
return { verdict: 'redact', body, headers };
130140
};
131141

132142
// Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard).
@@ -135,7 +145,7 @@ export async function createProtection(options = {}) {
135145
if (text == null) return response;
136146
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) });
137147
if (r.verdict === 'block') return leakResponse();
138-
if (r.verdict === 'redact') return rebuildResponse(response, r.body);
148+
if (r.verdict === 'redact') return rebuildResponse(response, r.body, r.headers);
139149
return response;
140150
};
141151

@@ -174,7 +184,7 @@ export async function createProtection(options = {}) {
174184
if (!isTextCT(ct)) { for (const c of chunks) origWrite(c); return origEnd(cb); }
175185
let r;
176186
try {
177-
r = screenText(text, { status: res.statusCode, headers: {} });
187+
r = screenText(text, { status: res.statusCode, headers: res.getHeaders ? res.getHeaders() : {} });
178188
} catch (err) {
179189
onError?.(err);
180190
for (const c of chunks) origWrite(c);
@@ -187,6 +197,14 @@ export async function createProtection(options = {}) {
187197
}
188198
if (r.verdict === 'redact') {
189199
try { res.removeHeader && res.removeHeader('content-length'); } catch { /* ignore */ }
200+
if (r.headers && res.setHeader) {
201+
const current = res.getHeaders ? res.getHeaders() : {};
202+
for (const [name, value] of Object.entries(r.headers)) {
203+
if (typeof value === 'string' && current[name] !== value) {
204+
try { res.setHeader(name, value); } catch { /* ignore invalid header */ }
205+
}
206+
}
207+
}
190208
return origEnd(r.body, cb);
191209
}
192210
for (const c of chunks) origWrite(c);
@@ -330,9 +348,9 @@ export async function createProtection(options = {}) {
330348
},
331349
};
332350

333-
// Egress interception is opt-in (it wraps the global fetch).
351+
// Egress interception is opt-in (it wraps the global fetch, and node:http/https on Node).
334352
if (options.egress) {
335-
protection.uninstallEgress = installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
353+
protection.uninstallEgress = await installEgressGuard({ shouldBlock: egressShouldBlock, onBlock: options.onEgressBlock });
336354
}
337355

338356
return protection;
@@ -403,9 +421,16 @@ function applyRedactors(body, redactors, mask) {
403421
return out;
404422
}
405423

406-
function rebuildResponse(response, body) {
424+
function rebuildResponse(response, body, redactedHeaders) {
407425
const headers = new Headers(response.headers);
408426
headers.delete('content-length'); // body length changed after redaction
427+
if (redactedHeaders) {
428+
for (const [name, value] of Object.entries(redactedHeaders)) {
429+
if (typeof value === 'string' && headers.get(name) !== value) {
430+
try { headers.set(name, value); } catch { /* invalid header name — skip */ }
431+
}
432+
}
433+
}
409434
return new Response(body, { status: response.status, statusText: response.statusText, headers });
410435
}
411436

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { createProtection } from '../../src/protect/runtime.js';
3+
4+
// The egress guard patches node:http/https (not just global fetch) so outbound calls made via
5+
// those modules — axios, got, the raw client — are screened for SSRF too.
6+
7+
async function nodeHttp() {
8+
const ns: any = await import('node:http');
9+
return ns.default ?? ns;
10+
}
11+
12+
describe('egress guard — node:http', () => {
13+
it('blocks a node:http request to an internal/metadata host, allows external, restores on uninstall', async () => {
14+
const origFetch = globalThis.fetch;
15+
globalThis.fetch = (async () => new Response('stub')) as any; // avoid real network during setup
16+
const p: any = await createProtection({ egress: true, mode: 'block', allowHosts: [] });
17+
try {
18+
const http = await nodeHttp();
19+
expect(http.__patchstackGuarded).toBe(true); // module was patched
20+
21+
let blocked = false;
22+
try {
23+
http.request('http://169.254.169.254/latest/meta-data/');
24+
} catch (err) {
25+
blocked = /Patchstack blocked/.test(String(err));
26+
}
27+
expect(blocked).toBe(true); // internal host → thrown before any socket
28+
29+
// An external host is allowed (guard doesn't throw); destroy immediately, swallow the socket error.
30+
let threw = false;
31+
try {
32+
const r = http.request('http://example.com/');
33+
r.on('error', () => {});
34+
r.destroy();
35+
} catch {
36+
threw = true;
37+
}
38+
expect(threw).toBe(false);
39+
} finally {
40+
p.uninstallEgress?.();
41+
globalThis.fetch = origFetch;
42+
}
43+
44+
const http = await nodeHttp();
45+
expect(http.__patchstackGuarded).toBeUndefined(); // restored after uninstall
46+
});
47+
});

0 commit comments

Comments
 (0)