@@ -174,9 +174,10 @@ export function sanitizeSmsReportFields(body) {
174174// expiration TTL equal to the window so stale keys self-clean.
175175const RATE_LIMIT_WINDOW_S = 60 ;
176176const RATE_LIMIT_MAX_REQUESTS = 5 ;
177- // A carrier hands each phone its own /64 out of a shared /48, so the /48 gets
178- // a larger budget than one client, and the whole Worker gets one per minute so
179- // a flood spread over many networks can't turn into a flood of GitHub commits.
177+ // A carrier hands each phone its own /64 out of a shared /48, so the /48 (an
178+ // IPv4 /24) gets a larger budget than one client, and the whole Worker gets one
179+ // per minute so a flood spread over many networks can't turn into a flood of
180+ // GitHub commits.
180181const GROUP_RATE_LIMIT_MAX_REQUESTS = 20 ;
181182const GLOBAL_RATE_LIMIT_MAX_REQUESTS = 30 ;
182183
@@ -347,16 +348,36 @@ export async function readBoundedText(request, limit = MAX_BODY_BYTES) {
347348}
348349
349350/**
350- * Check the per-client, per-/48 and Worker-wide rate limits, in that order.
351- * Returns { allowed: boolean, remaining: number, retryAfter: number } for the
352- * first budget that refuses, or for the client budget when all allow.
351+ * The network a client's group budget keys on: an IPv6 /48 (a carrier hands
352+ * each phone a /64 out of a shared /48) or an IPv4 /24, so a handful of
353+ * addresses in one block can't each spend a full client budget.
354+ */
355+ export function limiterGroupKey ( ip ) {
356+ const key = clientKey ( ip , REPORTER_PREFIX_BITS ) ;
357+ const octets = key . match ( / ^ ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. ( \d { 1 , 3 } ) \. \d { 1 , 3 } $ / ) ;
358+ return octets ? `${ octets [ 1 ] } .${ octets [ 2 ] } .${ octets [ 3 ] } .0/24` : key ;
359+ }
360+
361+ /**
362+ * Check the Worker-wide, per-client and per-group rate limits. Returns
363+ * { allowed: boolean, remaining: number, retryAfter: number } for the first
364+ * budget that refuses, or for the client budget when all allow.
365+ *
366+ * The Worker-wide budget is the REPORT_GLOBAL_COUNTER Durable Object when it
367+ * is bound. A rate-limiting binding counts separately in every Cloudflare
368+ * location, so it can't hold one budget for the whole Worker; the single
369+ * object can. Its budget is checked before the client is charged, so a report
370+ * the Worker can't take doesn't spend the client's budget, and it is charged
371+ * only after the client and group budgets allow, so one client hammering its
372+ * own limit can't drain everyone else's.
353373 *
354374 * When the REPORT_LIMITER rate-limiting binding is bound it is authoritative:
355375 * the platform evaluates its counter atomically per key, so a burst of
356376 * concurrent requests cannot slip past the cap the way it can between the KV
357377 * counter's read and write (KV is eventually consistent and non-atomic).
358378 * The KV counter remains as a best-effort fallback for a deployment that has
359- * not provisioned the limiter binding yet.
379+ * not provisioned the limiter binding yet. Without the Durable Object the
380+ * Worker-wide budget falls back the same way and is charged last.
360381 *
361382 * Missing bindings fail closed unless a local test explicitly sets
362383 * ALLOW_UNLIMITED_REPORTS=true. Production must never set that flag.
@@ -366,25 +387,90 @@ export async function checkRateLimit(ip, env) {
366387 return { allowed : false , remaining : 0 , retryAfter : 0 , identityError : true } ;
367388 }
368389 const client = clientKey ( ip , LIMITER_PREFIX_BITS ) ;
390+ const group = limiterGroupKey ( ip ) ;
369391 const budgets = [
370392 { limiter : env ?. REPORT_LIMITER , key : client , kvKey : `rl:${ client } ` , max : RATE_LIMIT_MAX_REQUESTS } ,
371- {
372- limiter : env ?. REPORT_GROUP_LIMITER ,
373- key : clientKey ( ip , REPORTER_PREFIX_BITS ) ,
374- kvKey : `rlg:${ clientKey ( ip , REPORTER_PREFIX_BITS ) } ` ,
375- max : GROUP_RATE_LIMIT_MAX_REQUESTS ,
376- } ,
377- { limiter : env ?. REPORT_GLOBAL_LIMITER , key : "all" , kvKey : "rlall" , max : GLOBAL_RATE_LIMIT_MAX_REQUESTS } ,
393+ { limiter : env ?. REPORT_GROUP_LIMITER , key : group , kvKey : `rlg:${ group } ` , max : GROUP_RATE_LIMIT_MAX_REQUESTS } ,
378394 ] ;
395+ const counter = env ?. REPORT_GLOBAL_COUNTER ;
396+ if ( counter ) {
397+ const open = await askGlobalCounter ( counter , "peek" ) ;
398+ if ( ! open . allowed ) return open ;
399+ } else {
400+ budgets . push ( { limiter : env ?. REPORT_GLOBAL_LIMITER , key : "all" , kvKey : "rlall" , max : GLOBAL_RATE_LIMIT_MAX_REQUESTS } ) ;
401+ }
379402 let first ;
380403 for ( const budget of budgets ) {
381404 const outcome = await consumeBudget ( budget , env ) ;
382405 if ( ! outcome . allowed ) return outcome ;
383406 first ??= outcome ;
384407 }
408+ if ( counter ) {
409+ const taken = await askGlobalCounter ( counter , "take" ) ;
410+ if ( ! taken . allowed ) return taken ;
411+ }
385412 return first ;
386413}
387414
415+ /** Peek at or take from the Worker-wide budget held by the one counter object. */
416+ async function askGlobalCounter ( namespace , action ) {
417+ let answer ;
418+ try {
419+ const stub = namespace . get ( namespace . idFromName ( GLOBAL_COUNTER_NAME ) ) ;
420+ const response = await stub . fetch ( `https://global-counter/${ action } ` , { method : "POST" } ) ;
421+ answer = await response . json ( ) ;
422+ } catch ( error ) {
423+ console . error ( "Unable to reach the Worker-wide report counter" , error ) ;
424+ return { allowed : false , remaining : 0 , retryAfter : 0 , stateError : true } ;
425+ }
426+ if ( answer ?. allowed === true ) {
427+ return { allowed : true , remaining : answer . remaining , retryAfter : 0 } ;
428+ }
429+ if ( answer ?. allowed === false && Number . isFinite ( answer . retryAfter ) ) {
430+ return { allowed : false , remaining : 0 , retryAfter : Math . max ( 1 , Math . ceil ( answer . retryAfter ) ) } ;
431+ }
432+ console . error ( "Malformed answer from the Worker-wide report counter" ) ;
433+ return { allowed : false , remaining : 0 , retryAfter : 0 , stateError : true } ;
434+ }
435+
436+ const GLOBAL_COUNTER_NAME = "all" ;
437+
438+ /**
439+ * The Worker-wide report budget, one instance (named "all") for every
440+ * Cloudflare location. Each request runs to completion without awaiting, so
441+ * the check and the increment can't interleave. The count lives in memory:
442+ * the object stays loaded while reports arrive, and one evicted after a quiet
443+ * spell starts a fresh window, which an idle minute would have given anyway.
444+ */
445+ export class ReportGlobalCounter {
446+ constructor ( _state , _env , now = ( ) => Date . now ( ) ) {
447+ this . now = now ;
448+ this . windowStart = 0 ;
449+ this . count = 0 ;
450+ }
451+
452+ async fetch ( request ) {
453+ const action = new URL ( request . url ) . pathname . slice ( 1 ) ;
454+ if ( action !== "peek" && action !== "take" ) {
455+ return new Response ( "Not found" , { status : 404 } ) ;
456+ }
457+ const now = this . now ( ) ;
458+ if ( now - this . windowStart >= RATE_LIMIT_WINDOW_S * 1000 ) {
459+ this . windowStart = now ;
460+ this . count = 0 ;
461+ }
462+ const allowed = this . count < GLOBAL_RATE_LIMIT_MAX_REQUESTS ;
463+ if ( allowed && action === "take" ) this . count += 1 ;
464+ const retryAfter = allowed
465+ ? 0
466+ : Math . max ( 1 , Math . ceil ( ( this . windowStart + RATE_LIMIT_WINDOW_S * 1000 - now ) / 1000 ) ) ;
467+ return new Response (
468+ JSON . stringify ( { allowed, remaining : GLOBAL_RATE_LIMIT_MAX_REQUESTS - this . count , retryAfter } ) ,
469+ { headers : { "Content-Type" : "application/json" } } ,
470+ ) ;
471+ }
472+ }
473+
388474/** Take one request from a budget: the atomic limiter binding when bound, else a KV counter. */
389475async function consumeBudget ( { limiter, key : limiterKey , kvKey : key , max } , env ) {
390476 if ( limiter ) {
0 commit comments