Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/protect/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } }]
}
];

Expand Down
122 changes: 97 additions & 25 deletions src/protect/egress.js
Original file line number Diff line number Diff line change
@@ -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;
}
68 changes: 62 additions & 6 deletions src/protect/engine/fetch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 {
Expand All @@ -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;
}
}
}

Expand All @@ -64,6 +73,7 @@ export async function fromFetchRequest(request) {
originalUrl: uri,
query,
body,
files,
headers,
ip: forwarded.split(',')[0].trim(),
cookies: parseCookies(headers.cookie),
Expand All @@ -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.<field>`
// and `raw` rules match uploads, e.g. a `__proto__` field name) and file metadata (filename via
// `files.<field>`). 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.<name> → the uploaded filename
} else {
body[name] = name in body ? [].concat(body[name], content) : content;
}
}
return { body, files };
}

function parseCookies(header) {
const cookies = {};
if (!header) {
Expand Down
39 changes: 32 additions & 7 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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;
};

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
}

Expand Down
47 changes: 47 additions & 0 deletions tests/protect/egress-node-http.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading
Loading