From 81144ebc86303064caf8c590c12ee39802a8f157 Mon Sep 17 00:00:00 2001 From: Mario Tarosso Date: Mon, 26 Oct 2026 10:52:41 +0000 Subject: [PATCH] Deliver detection reports on Nitro-built Workers Lovable's current template builds with Nitro for Cloudflare Workers. Nitro calls the app's fetch with the request alone and puts the host's waitUntil on the request itself. The guard's fetch() looked for waitUntil only in an argument beside the request, so on these apps it found none and returned the 403 with the report still in flight: Cloudflare cut it off and the firewall log stayed empty while blocking worked. - hostWaitUntil finds waitUntil beside the request, on the request (request.waitUntil) or on request.runtime.cloudflare.context. The background rules check from #443 uses it too. - When a host offers none, fetch() delivers in the request as fetchGuard already does: a block waits up to 2 seconds for its report, and a detect-only report goes out on a later request once stale. The two paths share one routine. - AGENT-INSTALL.md says when reports are sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENT-INSTALL.md | 9 ++- src/protect/runtime.js | 115 +++++++++++++++++------------ tests/protect/rules-reader.test.ts | 66 +++++++++++++++++ 3 files changed, 141 insertions(+), 49 deletions(-) diff --git a/AGENT-INSTALL.md b/AGENT-INSTALL.md index d3ba2b1..8d07487 100644 --- a/AGENT-INSTALL.md +++ b/AGENT-INSTALL.md @@ -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 @@ -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 diff --git a/src/protect/runtime.js b/src/protect/runtime.js index 30bbbfd..a80a255 100644 --- a/src/protect/runtime.js +++ b/src/protect/runtime.js @@ -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 @@ -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; @@ -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; }; @@ -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 @@ -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; }; @@ -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) { diff --git a/tests/protect/rules-reader.test.ts b/tests/protect/rules-reader.test.ts index 33122ad..60fa856 100644 --- a/tests/protect/rules-reader.test.ts +++ b/tests/protect/rules-reader.test.ts @@ -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[] = []; + const request = Object.assign(new Request('https://app.test/search?q=live-attack'), { waitUntil: (p: Promise) => { 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[] = []; + const context = { waitUntil: (p: Promise) => { 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(); + }); +});