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
6 changes: 3 additions & 3 deletions AGENT-INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,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 `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.
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`, the one Nitro puts on the request, or Vercel's request context) the check is handed to it to finish after the response. A background check that fails or is cut off delays only the next background check, never the refresh a request waits for. 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 @@ -744,8 +744,8 @@ would have stopped while it is still in dry-run. Two separate paths, with differ

**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
guard hands it to the host's `waitUntil` to finish after the response: passed beside the request, on the
request itself, where Nitro puts it, or in Vercel's request context, where `@vercel/functions` reads 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.

Expand Down
23 changes: 18 additions & 5 deletions src/protect/rules/request-refresh.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export const SYNC_HEADER = 'x-patchstack-sync';
* Two deadlines. Past `intervalMs` the rules are stale: the request waits for the fetch, because on a host
* that freezes between requests nothing else will run it. Past `backgroundMs` (shorter) the rules are only
* worth re-checking: the fetch starts but no request waits for it, and the caller hands it to the host to
* finish (`waitUntil`) where it can. A background check the host cuts off is caught by the stale deadline.
* finish (`waitUntil`) where it can. A background check the host cuts off is caught by the stale deadline,
* which is why a failed background check backs off only the background deadline: counting it against the
* stale one would let a host that cuts every one of them off (no `waitUntil`) hold stale rules for 8 times
* the interval.
*
* `check()` returns null when nothing is due, or `{promise, wait}`: `wait` says whether this request
* should hold for `promise`.
Expand All @@ -22,17 +25,27 @@ export function requestRefresh(tick, {intervalMs, backgroundMs = 0, timeoutMs, f
let lastForced = -Infinity;
let inflight = null;
let failures = 0;
let backgroundFailures = 0;
let stopped = false;
function note(ok) {
failures = ok ? 0 : Math.min(failures + 1, 3);
backgroundFailures = ok ? 0 : backgroundFailures;
due = now() + intervalMs * (2 ** failures);
backgroundDue = now() + backgroundMs * (2 ** failures);
}
function start() {
// A background check that succeeded counts like any refresh; one that failed or was cut off moves only
// its own deadline, so the stale deadline still arrives on time.
function noteBackground(ok) {
if (ok) return note(true);
backgroundFailures = Math.min(backgroundFailures + 1, 3);
backgroundDue = now() + backgroundMs * (2 ** backgroundFailures);
}
function start(background = false) {
const settle = background ? noteBackground : note;
let timer;
const work = Promise.resolve().then(tick).then(result => { note(result?.ok !== false); }, () => { note(false); });
const work = Promise.resolve().then(tick).then(result => { settle(result?.ok !== false); }, () => { settle(false); });
// A broken transport or custom store must not hold an application request indefinitely.
const bounded = new Promise(resolve => { timer = setTimeout(() => { note(false); resolve(); }, timeoutMs); });
const bounded = new Promise(resolve => { timer = setTimeout(() => { settle(false); resolve(); }, timeoutMs); });
inflight = Promise.race([work, bounded]).finally(() => { clearTimeout(timer); inflight = null; });
return inflight;
}
Expand All @@ -48,7 +61,7 @@ export function requestRefresh(tick, {intervalMs, backgroundMs = 0, timeoutMs, f
return {promise: start(), wait: true};
}
if (backgroundMs > 0 && backgroundMs < intervalMs && now() >= backgroundDue) {
return {promise: start(), wait: false};
return {promise: start(true), wait: false};
}
return null;
}
Expand Down
22 changes: 19 additions & 3 deletions src/protect/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -2333,18 +2333,34 @@ function byPhase(rules, phase) {
function baseContentType(ct) {
return String(ct || '').toLowerCase().split(';')[0].trim();
}
/**
* Vercel's context for the request being handled, which carries its `waitUntil`, or null.
*
* Vercel sets it on every function and middleware invocation; `@vercel/functions` reads it the same way.
* It is the only place a Next.js middleware that receives the request alone can reach `waitUntil`.
*/
function vercelRequestContext() {
try {
const context = globalThis[Symbol.for('@vercel/request-context')]?.get?.();
return typeof context?.waitUntil === 'function' ? context : null;
} catch {
return null;
}
}

/**
* 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
* request (a Worker's own `fetch(request, env, ctx)`), 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.
* request alone, or in Vercel's request context. A host that refuses the work is treated as offering none.
*/
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);
?? (typeof request?.runtime?.cloudflare?.context?.waitUntil === 'function' ? request.runtime.cloudflare.context : null)
?? vercelRequestContext();
if (!ctx) return null;

return (promise) => {
Expand Down
27 changes: 27 additions & 0 deletions tests/protect/runtime-synchronization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,33 @@ it('waits once past an explicit requestRefreshMs', async () => {
} finally { await p.stop(); }
});

it('keeps the stale deadline when a background check fails or is cut off', async () => {
vi.useFakeTimers();
const tick = vi.fn(async () => { throw new Error('cut off'); });
const scheduler = requestRefresh(tick,{intervalMs:300000,backgroundMs:30000,timeoutMs:100});
vi.setSystemTime(Date.now()+31000);
const background = scheduler.check();
expect(background?.wait).toBe(false);
await background?.promise;
// A host with no waitUntil cuts every background check off; the refresh a request waits for still comes due.
vi.setSystemTime(Date.now()+270000);
expect(scheduler.check()?.wait).toBe(true);
});

it('hands a background check to Vercel\'s request context when the request is the only argument', async () => {
vi.useFakeTimers(); service();
const kept: Promise<unknown>[] = [];
const key = Symbol.for('@vercel/request-context');
(globalThis as any)[key] = { get: () => ({ waitUntil: (work: Promise<unknown>) => { kept.push(work); } }) };
const p = await createProtection({...options,refreshMs:300000});
try {
vi.setSystemTime(Date.now()+31000);
// As a Next.js middleware calls it: the request alone.
expect(await p.fetchGuard()(new Request('https://app.example.test/'))).toBeNull();
expect(kept).toHaveLength(1);
} finally { delete (globalThis as any)[key]; await p.stop(); }
});

it('does not create request-driven traffic for a manual-only guard', async () => {
vi.useFakeTimers();const api=service();const p=await createProtection(options);
try {const before=api.fetch.mock.calls.length;vi.setSystemTime(Date.now()+1000000);await p.fetchGuard()(new Request('https://app.example.test/'));expect(api.fetch.mock.calls.length).toBe(before);}
Expand Down
Loading