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
70 changes: 70 additions & 0 deletions src/protect/defaults.js
Original file line number Diff line number Diff line change
@@ -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' } }]
}
];
43 changes: 43 additions & 0 deletions src/protect/egress.js
Original file line number Diff line number Diff line change
@@ -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;
}
};
}
36 changes: 34 additions & 2 deletions src/protect/engine/engine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -384,4 +416,4 @@ export class RuleEngine {
}
}

export const _testExports = { matchValue, safeRegExp };
export const _testExports = { matchValue, safeRegExp, isInternalHost };
42 changes: 42 additions & 0 deletions src/protect/engine/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 26 additions & 3 deletions src/protect/protect.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ export interface RuleBundle {
whitelist_keys: Record<string, unknown>;
}

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<Response | null>;
/** Screens the request, then the response (secret-leak redaction / withhold). */
fetch(handler: (request: Request, ...rest: unknown[]) => unknown): (request: Request, ...rest: unknown[]) => Promise<unknown>;
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 {
Expand All @@ -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<Protection>;
Expand Down
Loading
Loading