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
9 changes: 8 additions & 1 deletion AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ Every command at a glance — what it does, whether it reads your source, what i

The explicit `--runtime-url` check is the exception to ordinary `protect --check` being local. Both CLI and running guard need the site's credential. The guard answers only a valid, fresh HMAC challenge on OPTIONS; invalid or absent challenges retain ordinary app routing. `{ activationCheck: false }` disables it. An answer proves the guard and map loaded on that checked route, not whole-application coverage or exploit blocking. Setup without a URL reports activation as unchecked. Let the framework reload first; restart/rebuild/deploy only if the current guard did not load. The responder reads loaded policy without forcing a refresh; normal cold-start initialization still applies.

Runtime rule polling also has a request-triggered check, for hosts that freeze idle instances and for hosts that run many copies of the app (such as Cloudflare Workers), each holding its own rules. Once the rules are older than `refreshMs` (or `requestRefreshMs`, which sets it; `requestRefreshMs: 0` opts out), the request waits for the fetch. Every 30 seconds before that, a serving guard also re-checks in the background: no request waits, and on a host that provides an execution context (Cloudflare Workers' `ctx.waitUntil`) the check is handed to it to finish after the response. When the rules have not changed the API answers 304 with no body. It shares in-flight work, backs off on failure, and waits at most 2500ms by default (`requestRefreshTimeoutMs`, clamped to 50–3000ms) before using last-known-good rules. This path calls the existing authenticated rules/token endpoints only: no source analysis or manifest report runs inside a request. A request carrying the header `x-patchstack-sync` makes that refresh run during the request even before the interval is up, at most once per 15 seconds per process; the request is otherwise screened and served as usual. Patchstack sends it to the site's live address, with a cache-busting `patchstack_sync` query parameter, after the site's rules change and when its owner chooses Sync now, so a rule change applies on that request instead of at the next interval. The header carries nothing and authorizes nothing: the fetch it triggers is the guard's own authenticated rules request, and anyone can send it to bring that fetch forward within the same limit. `protection.synchronization` is a local snapshot, not another network request. Updating rules never adopts a new source/map identity.
Runtime rule polling also has a request-triggered check, for hosts that freeze idle instances and for hosts that run many copies of the app (such as Cloudflare Workers), each holding its own rules. Once the rules are older than `refreshMs` (or `requestRefreshMs`, which sets it; `requestRefreshMs: 0` opts out), the request waits for the fetch. Every 30 seconds before that, a serving guard also re-checks in the background: no request waits, and on a host that provides `waitUntil` (Cloudflare Workers' `ctx.waitUntil`, or the one Nitro puts on the request) the check is handed to it to finish after the response. When the rules have not changed the API answers 304 with no body. It shares in-flight work, backs off on failure, and waits at most 2500ms by default (`requestRefreshTimeoutMs`, clamped to 50–3000ms) before using last-known-good rules. This path calls the existing authenticated rules/token endpoints only: no source analysis or manifest report runs inside a request. A request carrying the header `x-patchstack-sync` makes that refresh run during the request even before the interval is up, at most once per 15 seconds per process; the request is otherwise screened and served as usual. Patchstack sends it to the site's live address, with a cache-busting `patchstack_sync` query parameter, after the site's rules change and when its owner chooses Sync now, so a rule change applies on that request instead of at the next interval. The header carries nothing and authorizes nothing: the fetch it triggers is the guard's own authenticated rules request, and anyone can send it to bring that fetch forward within the same limit. `protection.synchronization` is a local snapshot, not another network request. Updating rules never adopts a new source/map identity.

## Package and command behavior

Expand Down Expand Up @@ -710,6 +710,13 @@ would have stopped while it is still in dry-run. Two separate paths, with differ
blocked, which are reported on both paths. It exists because a rule carrying `dry-run` blocks nothing, so
without it nothing distinguishes a rule that is protecting from one that is quietly wrong.

**Delivery.** Reports are batched and sent in the background. A host that may stop the app as soon as it
answers (Cloudflare Workers, and the hosted builders that deploy onto it) would cut a report off, so the
guard hands it to the host's `waitUntil` to finish after the response: passed beside the request, or on
the request itself, where Nitro puts it. Where a host offers neither, a blocked request's 403 waits up to
2 seconds for its report to be delivered, and a detect-only report is sent by a later request once it is
10 seconds old.

**This is on by default for a site enrolled with Patchstack that is running Patchstack-delivered rules**,
and off otherwise. Specifically, it requires all of: a provisioned site UUID, rules that came from
Patchstack rather than from a local bundle, and a resolvable credential. A local install, or a guard
Expand Down
115 changes: 67 additions & 48 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,7 @@ export async function createProtection(options = {}) {
if (activated) return {blocked:activated, client:undefined};
const refresh = requestRefresher?.check(Boolean(request?.headers?.get?.(SYNC_HEADER)));
if (refresh?.wait) await refresh.promise;
else if (refresh) keepAlive(hostArgs, refresh.promise);
else if (refresh) hostWaitUntil(request, hostArgs)?.(refresh.promise);
// First real request on a new origin: tell Patchstack the deployment is live. Gated on having a
// credential (`rulesAuth` — the site key, or the bundled read-only reader), NOT on detection
// reporting being on. A guard serving live traffic is deployed whether or not its MANAGED rules have
Expand Down Expand Up @@ -1780,6 +1780,39 @@ export async function createProtection(options = {}) {
}).catch(err => { notify(onError, err, 'onError'); proceed(); });
};

/**
* Deliver this request's detections before it is answered, for a seam whose host offers no `waitUntil`.
*
* Such a host can freeze or end the process the instant it answers, cutting off a report still in
* flight, so a block waits for its report to land before the 403 goes out, and a detect-only match is
* delivered by a later request once it has outlived the flush interval. Bounded and fail-open.
*/
const deliverInRequest = async (blocked) => {
if (blocked && detections) {
// A block just recorded a detection, and there is nothing to defer its delivery to. A hosted
// builder's runtime can freeze or end the instant it answers, cutting off the fire-and-forget send —
// so wait for the report to land BEFORE handing back the 403, while the process is still alive. Bounded and fail-open: a slow or down endpoint
// delays an attacker's block by at most this budget, never holds it, and never throws.
try { await detections.settle(DETECTION_REQUEST_DELIVERY_MS); } catch { /* fail open */ }
detectPendingSince = null;
} else if (detections && detections.pending()) {
// A detect-only match recorded a detection but did not block, so there is no 403 to gate on — and
// on a freeze-between-requests host the background flush never runs, so that detection would never
// reach the firewall log. Deliver it here, but ONLY once it has outlived the flush interval: on a
// live server the timer drains the buffer first (this never fires, batching intact); on a frozen
// host the buffer survives the freeze and a later request flushes it. Bounded and fail-open.
const now = Date.now();
if (detectPendingSince === null) {
detectPendingSince = now;
} else if (now - detectPendingSince >= detectionStaleMs) {
try { await detections.settle(DETECTION_REQUEST_DELIVERY_MS); } catch { /* fail open */ }
detectPendingSince = null;
}
} else {
detectPendingSince = null;
}
};

const protection = {
get mode() {
return mode;
Expand Down Expand Up @@ -1816,30 +1849,7 @@ export async function createProtection(options = {}) {
fetchGuard() {
return async (request, ...hostArgs) => {
const { blocked } = await screenFetchRequest(request, hostArgs);
if (blocked && detections) {
// A block just recorded a detection, and this seam (React Router middleware) has no execution
// context to defer delivery to. A hosted builder's runtime can freeze or end the instant it
// answers, cutting off the fire-and-forget send — so wait for the report to land BEFORE handing
// back the 403, while the process is still alive. Bounded and fail-open: a slow or down endpoint
// delays an attacker's block by at most this budget, never holds it, and never throws.
try { await detections.settle(DETECTION_REQUEST_DELIVERY_MS); } catch { /* fail open */ }
detectPendingSince = null;
} else if (detections && detections.pending()) {
// A detect-only match recorded a detection but did not block, so there is no 403 to gate on — and
// on a freeze-between-requests host the background flush never runs, so that detection would never
// reach the firewall log. Deliver it here, but ONLY once it has outlived the flush interval: on a
// live server the timer drains the buffer first (this never fires, batching intact); on a frozen
// host the buffer survives the freeze and a later request flushes it. Bounded and fail-open.
const now = Date.now();
if (detectPendingSince === null) {
detectPendingSince = now;
} else if (now - detectPendingSince >= detectionStaleMs) {
try { await detections.settle(DETECTION_REQUEST_DELIVERY_MS); } catch { /* fail open */ }
detectPendingSince = null;
}
} else {
detectPendingSince = null;
}
await deliverInRequest(blocked);

return blocked;
};
Expand All @@ -1850,18 +1860,18 @@ export async function createProtection(options = {}) {
return async function (request, ...rest) {
// A serverless/edge host (Cloudflare Workers, and the hosted builders that deploy onto it) freezes
// the runtime the moment it answers the visitor — cancelling the detection report in flight, which
// arrives at the platform as a client-closed request and stores nothing. The host passes an
// execution context with `waitUntil` for exactly this: work that must outlive the response. Found
// by shape, not position, because the argument order differs by host; absent (a long-lived Node
// server), the flush interval delivers on its own and nothing here changes.
const ctx = rest.find((arg) => arg && typeof arg === 'object' && typeof arg.waitUntil === 'function');
const keepReportAlive = () => {
if (!ctx || !detections) return;
try {
ctx.waitUntil(detections.settle());
} catch {
/* a host that rejects waitUntil here still delivers on the next request's flush */
// arrives at the platform as a client-closed request and stores nothing. Where the host offers
// `waitUntil` (see hostWaitUntil) the report rides past the response; where it offers none, the
// report is delivered within the request, as fetchGuard does.
const keep = hostWaitUntil(request, rest);
// With `waitUntil` the report rides past the response; without it, it is delivered in the request.
const deliver = (blocked) => {
if (!detections) return undefined;
if (keep) {
keep(detections.settle());
return undefined;
}
return deliverInRequest(blocked);
};

// The request phase's own resolution is carried into the response phase rather than the response
Expand All @@ -1870,13 +1880,13 @@ export async function createProtection(options = {}) {
// not exist.
const { blocked, client } = await screenFetchRequest(request, rest);
if (blocked) {
keepReportAlive();
await deliver(true);

return blocked;
}
const response = await handler.call(this, request, ...rest);
const screened = await screenResp(response, reqContextFromFetch(request, client));
keepReportAlive();
await deliver(false);

return screened;
};
Expand Down Expand Up @@ -2324,17 +2334,26 @@ function baseContentType(ct) {
return String(ct || '').toLowerCase().split(';')[0].trim();
}
/**
* Hand work that must outlive the response to the host's execution context (`ctx.waitUntil` on Cloudflare
* Workers), found by shape because hosts pass it in different positions. Without one the work simply runs
* on; a host that freezes before it finishes is covered by the next stale refresh.
* The host's way to keep work alive after the response (`waitUntil` on Cloudflare Workers), or null.
*
* Hosts offer it in different places, so it is found by shape: an execution-context argument beside the
* request (a Worker's own `fetch(request, env, ctx)`), or on the request itself, where Nitro and srvx put
* it (`request.waitUntil`, `request.runtime.cloudflare.context`) because they call the app with the
* request alone. A host that refuses the work is treated as offering none.
*/
function keepAlive(hostArgs, promise) {
const ctx = hostArgs.find((arg) => arg && typeof arg === 'object' && typeof arg.waitUntil === 'function');
try {
ctx?.waitUntil(promise);
} catch {
/* a host that refuses it still has the next stale refresh */
}
function hostWaitUntil(request, hostArgs) {
const ctx = hostArgs.find((arg) => arg && typeof arg === 'object' && typeof arg.waitUntil === 'function')
?? (request && typeof request.waitUntil === 'function' ? request : null)
?? (typeof request?.runtime?.cloudflare?.context?.waitUntil === 'function' ? request.runtime.cloudflare.context : null);
if (!ctx) return null;

return (promise) => {
try {
ctx.waitUntil(promise);
} catch {
/* refused: what it carried is retried on a later request */
}
};
}

function screenableContentType(ct) {
Expand Down
66 changes: 66 additions & 0 deletions tests/protect/rules-reader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,69 @@ describe('a guard without the site credential, shipped with a rules reader', ()
protection.stop();
});
});

// Nitro (and srvx) call the app's fetch with the request alone and put the host's `waitUntil` on the
// request itself; a host can also offer none at all. The block report must reach Patchstack either way.
describe('delivering a block report on a host that passes no execution context argument', () => {
const reported = (calls: Array<{ url: string }>) => calls.some((c) => c.url.includes('/detections/'));

it('hands it to waitUntil on the request (Nitro)', async () => {
const { protection, calls } = await guard({ rules_reader: READER });
const pending: Promise<unknown>[] = [];
const request = Object.assign(new Request('https://app.test/search?q=live-attack'), { waitUntil: (p: Promise<unknown>) => { pending.push(p); } });

const response = await protection.fetch(() => new Response('ok'))(request);
expect(response.status).toBe(403);
expect(pending).toHaveLength(1);
await Promise.all(pending);
expect(reported(calls)).toBe(true);
await protection.stop();
});

it('hands it to the Cloudflare context Nitro keeps on the request', async () => {
const { protection, calls } = await guard({ rules_reader: READER });
const pending: Promise<unknown>[] = [];
const context = { waitUntil: (p: Promise<unknown>) => { pending.push(p); } };
const request = Object.assign(new Request('https://app.test/search?q=live-attack'), { runtime: { cloudflare: { context } } });

expect((await protection.fetch(() => new Response('ok'))(request)).status).toBe(403);
await Promise.all(pending);
expect(pending).toHaveLength(1);
expect(reported(calls)).toBe(true);
await protection.stop();
});

it('delivers it before answering when the host offers no waitUntil at all', async () => {
const cwd = mkdtempSync(join(tmpdir(), 'ps-reader-'));
dirs.push(cwd);
vi.stubEnv('PATCHSTACK_API_KEY', '');
vi.stubEnv('PATCHSTACK_PULSE_AUTH', '');
// A slow detections endpoint, in place before the guard starts: the report is only delivered once its
// response has come back.
let delivered = false;
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (String(url).endsWith('/token')) return Response.json({ access_token: 'reader-token', expires_in: 3600 });
if (String(url).includes('/detections/')) {
await new Promise((resolve) => setTimeout(resolve, 50));
delivered = true;
return new Response('{}', { status: 202 });
}
return Response.json({ firewall: [{ id: 'live-rule', phase: 'request', action: 'block', rule_v2: [{ parameter: 'get.q', match: { type: 'contains', value: 'live-attack' } }] }], whitelists: [], whitelist_keys: {}, enforcement: 'block' });
}));
const protection: any = await createProtection({
siteUuid: '550e8400-e29b-41d4-a716-446655440000',
pulseRulesUrl: 'https://x.test/monitor/pulse',
cwd,
cacheDir: join(cwd, '.patchstack'),
rules: { firewall: [], whitelists: [], whitelist_keys: {}, _patchstack: { rules_reader: READER } } as never,
reportDetections: true,
});
delivered = false;

const response = await protection.fetch(() => new Response('ok'))(new Request('https://app.test/search?q=live-attack'));
expect(response.status).toBe(403);
// Delivered by the time the 403 is handed back: nothing is left in flight for the host to cut off.
expect(delivered).toBe(true);
await protection.stop();
});
});
Loading