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
17 changes: 14 additions & 3 deletions src/protect/egress.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* lookup?: Function }} opts
* @returns {Promise<() => void>} uninstall (restores every patched surface)
*/
export async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = true, lookup, allowHosts } = {}) {
export async function installEgressGuard({ shouldBlock, onBlock, onSkip, dnsScreen = true, lookup, allowHosts } = {}) {
const restores = [];
if (typeof shouldBlock !== 'function') return () => {};
const exempt = new Set((allowHosts ?? []).map((h) => String(h).toLowerCase()));
Expand Down Expand Up @@ -41,20 +41,31 @@ export async function installEgressGuard({ shouldBlock, onBlock, dnsScreen = tru
}
}

// A resolver failure means the destination was NOT screened by IP — the hostname check alone let it
// through. That's a real (if rare) coverage hole, so report it via onSkip instead of failing open
// silently. Still fail-open: a broken resolver must not take the app's outbound traffic down.
const skip = (reason, detail) => { try { onSkip?.({ phase: 'egress', reason, detail }); } catch { /* never affect traffic */ } };

// True when a hostname resolves to a disallowed address. Fail-open: any resolver error → false.
const resolvesToDisallowed = (url, host, method) =>
new Promise((resolve) => {
if (!screen || !host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
if (!screen) {
// No node:dns/net here (edge runtime) or screening disabled — hostname rules only.
if (host && dnsScreen) skip('resolver-unavailable', { host });
return resolve(false);
}
if (!host || screen.isIP(host) !== 0 || screen.isExempt(host)) return resolve(false);
try {
screen.lookup(host, { all: true }, (err, addresses) => {
if (err || !Array.isArray(addresses)) return resolve(false);
if (err || !Array.isArray(addresses)) { skip('resolver-failed', { host }); return resolve(false); }
for (const a of addresses) {
const ip = a && typeof a === 'object' ? a.address : a;
if (ip && block(url, ip, method)) return resolve(true);
}
resolve(false);
});
} catch {
skip('resolver-failed', { host });
resolve(false);
}
});
Expand Down
7 changes: 5 additions & 2 deletions src/protect/engine/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ export class PatchstackRuleClient {
#cacheTime = null;
#ttlEffective = 0;
#etag;
#timeoutMs;

constructor({ token, baseUrl, cacheTtl, etag } = {}) {
constructor({ token, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
// Bounded so app STARTUP can't hang on a slow API (see the boot budget in rules/source.js).
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30_000;
this.#token = token ?? process.env.PATCHSTACK_WAF_TOKEN;
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_WAF_API_URL ?? DEFAULT_BASE_URL;
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
Expand Down Expand Up @@ -48,7 +51,7 @@ export class PatchstackRuleClient {
method: 'POST',
headers,
body: JSON.stringify({}),
signal: AbortSignal.timeout(30_000)
signal: AbortSignal.timeout(this.#timeoutMs)
});

if (response.status === 304) {
Expand Down
8 changes: 6 additions & 2 deletions src/protect/engine/pulse-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@ const JITTER_FRACTION = 0.1;
// the client simply behaves as before (a full fetch every refresh).
export class PulseRuleClient {
#siteUuid;
#timeoutMs;
#baseUrl;
#cacheTtl;
#cache = null;
#cacheTime = null;
#ttlEffective = 0;
#etag;

constructor({ siteUuid, baseUrl, cacheTtl, etag } = {}) {
constructor({ siteUuid, baseUrl, cacheTtl, etag, timeoutMs } = {}) {
// Bounded so app STARTUP can't hang on a slow API: hosted platforms fail a deploy whose health
// check is slow, and we always have a cache/bundled fallback to boot from.
this.#timeoutMs = Number(timeoutMs) > 0 ? Number(timeoutMs) : 30_000;
this.#siteUuid = siteUuid ?? process.env.PATCHSTACK_SITE_UUID;
this.#baseUrl = baseUrl ?? process.env.PATCHSTACK_PULSE_RULES_URL ?? DEFAULT_BASE_URL;
this.#cacheTtl = Number.isFinite(cacheTtl) && cacheTtl > 0 ? cacheTtl : DEFAULT_CACHE_TTL;
Expand All @@ -41,7 +45,7 @@ export class PulseRuleClient {
try {
const headers = { Accept: 'application/json' };
if (this.#etag) headers['If-None-Match'] = this.#etag;
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(30_000) });
const response = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(this.#timeoutMs) });

if (response.status === 304) {
this.#touch(now); // revalidated — reset the clock (fresh jitter)
Expand Down
10 changes: 7 additions & 3 deletions src/protect/rules/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@
import { PatchstackRuleClient } from '../engine/index.js';
import { PulseRuleClient } from '../engine/pulse-client.js';

export async function resolveRules(options, store) {
export async function resolveRules(options, store, ctx = {}) {
// The INITIAL load is on the app's startup path, so the runtime gives it a short budget (see
// bootTimeoutMs) and falls back to cache/bundled rather than delaying boot; refreshes get the full
// budget. A timeout here is not a protection gap by itself — last-known-good still applies.
const timeoutMs = ctx.timeoutMs;
if (options.siteUuid) {
const prior = await store.read(); // { bundle, etag } | null
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag });
const client = new PulseRuleClient({ siteUuid: options.siteUuid, baseUrl: options.pulseRulesUrl, etag: prior?.etag, timeoutMs });
const res = await client.getRules();
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
if (res.success && !res.notModified) {
Expand All @@ -30,7 +34,7 @@ export async function resolveRules(options, store) {

if (options.token) {
const prior = await store.read();
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag });
const client = new PatchstackRuleClient({ token: options.token, baseUrl: options.baseUrl, etag: prior?.etag, timeoutMs });
const res = await client.getRules();
if (res.success && res.notModified && prior?.bundle) return normalizeBundle(prior.bundle);
if (res.success && !res.notModified) {
Expand Down
95 changes: 74 additions & 21 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,14 @@
// One entry point that composes the node-waf engine + adapters with:
// - a rule source: an explicit bundle, or fetched from the Patchstack API (token),
// with a disk cache so the engine keeps working on last-known-good if the API is down
// - execution modes: 'dry-run' (detect + log, never block — the safe onramp) and
// 'block' (enforce). Default is 'dry-run'.
// - fail-open everywhere: a rule/engine error never blocks (or crashes) a request.
// - execution modes: 'dry-run' (detect + log, never block — the safe onramp) and 'block' (enforce).
// This API's default is 'dry-run'. NOTE the scaffolded guard (`patchstack-connect protect`)
// deliberately passes mode: 'block' and only drops to dry-run when PATCHSTACK_MODE=dry-run — so an
// installed guard ENFORCES by default even though this constructor's default doesn't. Precedence:
// PATCHSTACK_MODE env > API `enforcement` > options.mode > dry-run.
// - fail-open everywhere: a rule/engine error never blocks (or crashes) a request. Where the guard
// fails open *without* inspecting (body caps, live streams, binary bodies, resolver failures) it
// is counted and reported — see `protection.coverage()` / the `onSkip` option.
//
// Runtime guards: .express(), .node(), .fetch(handler) / .fetchGuard() — same policy,
// every runtime an AI builder deploys to.
Expand Down Expand Up @@ -98,7 +103,11 @@ export async function createProtection(options = {}) {

// One tiered store (memory → filesystem/pluggable) shared by the initial load and every refresh.
const store = makeStore(options);
const bundle = await resolveRules(options, store);
// Startup must not hang on the network: hosted platforms (Replit et al.) fail a deploy whose health
// check is slow, and the guard can always boot from last-known-good / the bundled fallback. Refreshes
// keep the full budget. Override with { bootTimeoutMs }.
const bootTimeoutMs = Number(options.bootTimeoutMs) > 0 ? Number(options.bootTimeoutMs) : 5_000;
const bundle = await resolveRules(options, store, { timeoutMs: bootTimeoutMs });
// Mode is mutable so a Pulse refresh can flip dry-run ↔ block when SaaS enables production.
// Precedence: PATCHSTACK_MODE env (local override) > API enforcement > options.mode > dry-run.
let mode = resolveMode(options, bundle);
Expand Down Expand Up @@ -150,6 +159,25 @@ export async function createProtection(options = {}) {

applyBundle(bundle);

// Fail-open COVERAGE. The guard deliberately passes traffic through rather than risk breaking the
// app: an oversized request body, a response past the screening cap, a live stream, a binary body, a
// parse failure, a DNS resolver failure. Each of those is a real hole in enforcement, and until now
// it was SILENT — "always-on" read as "always inspected". Every such bypass is now counted and
// reported to `onSkip`, so a host can alert on it and `protection.coverage()` can be surfaced.
const skipCounts = Object.create(null);
const onSkip = typeof options.onSkip === 'function' ? options.onSkip : null;
const recordSkip = (phase, reason, detail) => {
const key = `${phase}:${reason}`;
skipCounts[key] = (skipCounts[key] ?? 0) + 1;
if (onSkip) {
try {
onSkip({ phase, reason, detail, count: skipCounts[key] });
} catch {
/* a reporting callback must never affect request handling */
}
}
};

const maskFn =
typeof options.maskWith === 'function'
? options.maskWith
Expand Down Expand Up @@ -263,8 +291,14 @@ export async function createProtection(options = {}) {

// Screen a fetch Response (used by .fetch() and — via protection.screenResponse — the Supabase guard).
const screenResp = async (response, reqCtx) => {
const text = await readTextResponse(response, screenCap);
if (text == null) return response;
const read = await readTextResponse(response, screenCap);
if (read.skip) {
// Nothing was screened — a leak/PII rule cannot have applied. Record it (a live stream and a
// binary body are by design; a body-cap or read failure is a coverage hole worth alerting on).
if (read.skip !== 'not-a-response') recordSkip('response', read.skip, { status: response?.status });
return response;
}
const text = read.text;
const r = screenText(text, { status: response.status, headers: headerObject(response.headers) }, reqCtx);
if (r.verdict === 'block') return leakResponse();
if (r.verdict === 'redact') return rebuildResponse(response, r.body, r.headers);
Expand Down Expand Up @@ -293,6 +327,7 @@ export async function createProtection(options = {}) {
chunks.length = 0;
origWrite(buf);
overflow = true;
recordSkip('response', 'body-cap', { bytes: size });
return;
}
chunks.push(buf);
Expand All @@ -316,6 +351,7 @@ export async function createProtection(options = {}) {
const kind = screenableContentType(ct);
// Skip live streams / binary bodies (incl. an octet-stream that sniffs as binary) — untouched.
if (kind === 'skip' || (kind === 'sniff' && looksBinary(buffer))) {
recordSkip('response', kind === 'skip' ? (baseContentType(ct) === 'text/event-stream' ? 'live-stream' : 'non-text-content-type') : 'binary-body');
for (const c of chunks) origWrite(c);
return origEnd(cb);
}
Expand Down Expand Up @@ -381,6 +417,16 @@ export async function createProtection(options = {}) {
return { request: requestRules, response: responseRules, egress: egressRules };
},

/**
* Enforcement coverage: how often the guard FAILED OPEN rather than inspecting, keyed
* `<phase>:<reason>` (e.g. `response:body-cap`, `request:body-cap`, `response:live-stream`,
* `egress:resolver-failed`). "Always-on" is not "always inspected" — surface this (or pass
* `onSkip`) so an unscreened path is visible and alertable rather than silent.
*/
coverage() {
return { skipped: { ...skipCounts } };
},

// Screen a fetch Response through the response-phase rules (redact/block). Used by
// .fetch(), and by the Supabase guard on its forwarded upstream response.
screenResponse: (response, request) => screenResp(response, request ? reqContextFromFetch(request) : undefined),
Expand Down Expand Up @@ -462,6 +508,7 @@ export async function createProtection(options = {}) {
next();
});
req.on('end', () => {
if (overflow) recordSkip('request', 'body-cap', { bytes: size, limit: maxBytes });
const rawBody = overflow ? '' : Buffer.concat(chunks).toString('utf8');
let shaped;
let result;
Expand Down Expand Up @@ -504,6 +551,9 @@ export async function createProtection(options = {}) {
protection.uninstallEgress = await installEgressGuard({
shouldBlock: egressShouldBlock,
onBlock: options.onEgressBlock,
// Route egress coverage gaps (a DNS resolver failure / no resolver on this runtime) into the
// same skip accounting as the request/response phases.
onSkip: ({ reason, detail }) => recordSkip('egress', reason, detail),
dnsScreen: options.screenDns !== false,
allowHosts: options.allowHosts,
});
Expand Down Expand Up @@ -541,7 +591,7 @@ export async function createProtection(options = {}) {
onError?.(err); // a failed report must not stop the rule refresh
}
}
const next = await resolveRules(options, store);
const next = await resolveRules(options, store, { timeoutMs: options.refreshTimeoutMs });
mode = resolveMode(options, next);
applyBundle(next);
};
Expand Down Expand Up @@ -632,23 +682,26 @@ function looksBinary(bytes) {
return n > 0 && ctrl / n > 0.1;
}

// Returns { text } when the body was fully buffered for screening, or { skip: <reason> } when it was
// NOT screened — the reason is surfaced to `onSkip`/coverage so a fail-open bypass is observable
// instead of silent (an unscreened response is a real hole in enforcement).
async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
if (!response || typeof response.clone !== 'function') return null;
if (!response || typeof response.clone !== 'function') return { skip: 'not-a-response' };
const ct = response.headers?.get?.('content-type') || '';
const kind = screenableContentType(ct);
if (kind === 'skip') return null;
if (kind === 'skip') return { skip: baseContentType(ct) === 'text/event-stream' ? 'live-stream' : 'non-text-content-type' };
const sniff = kind === 'sniff';
const len = Number(response.headers?.get?.('content-length') || 0);
if (len && len > cap) return null;
if (len && len > cap) return { skip: 'body-cap' };
let clone;
try {
clone = response.clone();
} catch {
return null;
return { skip: 'clone-failed' };
}

// Stream the read so a body WITHOUT a Content-Length can't buffer past the cap. Over the cap the
// response is left UNSCREENED (null) — but we keep draining the clone so the original stays intact.
// response is left UNSCREENED — but we keep draining the clone so the original stays intact.
const body = clone.body;
if (body && typeof body.getReader === 'function') {
const reader = body.getReader();
Expand All @@ -663,31 +716,31 @@ async function readTextResponse(response, cap = DEFAULT_SCREEN_CAP) {
if (!value) continue;
if (!sniffed) {
sniffed = true;
if (looksBinary(value)) return null; // octet-stream that's actually binary — skip
if (looksBinary(value)) return { skip: 'binary-body' };
}
size += value.byteLength;
if (over) continue; // keep draining, stop buffering
if (size > cap) { over = true; continue; }
chunks.push(value);
}
} catch {
return null;
return { skip: 'read-failed' };
}
if (over) return null;
if (over) return { skip: 'body-cap' };
try {
return new TextDecoder().decode(concatBytes(chunks, size));
return { text: new TextDecoder().decode(concatBytes(chunks, size)) };
} catch {
return null;
return { skip: 'decode-failed' };
}
}

try {
const text = await clone.text();
if (text.length > cap) return null;
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return null;
return text;
if (text.length > cap) return { skip: 'body-cap' };
if (sniff && looksBinary(new TextEncoder().encode(text.slice(0, 512)))) return { skip: 'binary-body' };
return { text };
} catch {
return null;
return { skip: 'read-failed' };
}
}

Expand Down
Loading
Loading