commit 013dcc8172d15dc5c492bddeab496531641829ff
parent 9f285f3280df751270cfb225671d7fd76cf6dddd
Author: Michael Camilleri <[email protected]>
Date: Thu, 2 Jul 2026 03:29:27 +0900
Limit push worker fanout
This commit bounds the push worker paths that could previously be driven
at unlimited frequency once a participant had a valid game credential.
Signed /publish requests now pass through a Durable Object sliding
window before APNs fanout, keyed by game credential for game pushes and
by target address for account-scoped pushes.
The App Attest bootstrap endpoints are also throttled by source IP and
device ID before they create challenge or registration state. Rate-limit
buckets are stored under keyed HMAC digests rather than raw identifiers
or enumerable hashes, and the limits can be tuned through environment
variables without changing the worker code.
Co-Authored-By: Codex GPT 5.5 <[email protected]>
Diffstat:
1 file changed, 108 insertions(+), 0 deletions(-)
diff --git a/Workers/push-worker.js b/Workers/push-worker.js
@@ -148,6 +148,8 @@ export class PushRegistry {
if (!deviceID || !keyID) {
return badRequest("deviceID and keyID required");
}
+ const limited = await this.checkAttestationRateLimit(request, "challenge", deviceID);
+ if (limited) return limited;
const ttlSeconds = this.appAttestChallengeTTLSeconds();
const challenge = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
await this.pruneExpired(`appattest-challenge:${deviceID}:`, ttlSeconds);
@@ -168,6 +170,8 @@ export class PushRegistry {
if (!deviceID || !keyID || !challenge || !attestationObject) {
return badRequest("deviceID, keyID, challenge, attestationObject required");
}
+ const limited = await this.checkAttestationRateLimit(request, "register", deviceID);
+ if (limited) return limited;
const challengeKey = this.appAttestChallengeKey(deviceID, challenge);
const challengeIssuedAt = await this.state.storage.get(challengeKey);
const challengeExpired = challengeIssuedAt
@@ -451,6 +455,9 @@ export class PushRegistry {
}
}
+ const limited = await this.checkPublishRateLimit({ credID, addressees, broadcast });
+ if (limited) return limited;
+
const targets = broadcast === true
? await this.resolveBroadcastTargets(credID, senderDeviceID, excludeAddress, alertBody, payload, enc)
: await this.resolveTargets(addressees, senderDeviceID, credID);
@@ -496,6 +503,85 @@ export class PushRegistry {
return Response.json({ delivered, removed, muted, failed });
}
+ async checkAttestationRateLimit(request, endpoint, deviceID) {
+ const ip = request.headers.get("CF-Connecting-IP") || "unknown";
+ const ipLimit = endpoint === "register"
+ ? this.rateLimitConfig("APP_ATTEST_REGISTER_IP", 30, 60 * 60)
+ : this.rateLimitConfig("APP_ATTEST_CHALLENGE_IP", 60, 60 * 60);
+ const deviceLimit = endpoint === "register"
+ ? this.rateLimitConfig("APP_ATTEST_REGISTER_DEVICE", 5, 10 * 60)
+ : this.rateLimitConfig("APP_ATTEST_CHALLENGE_DEVICE", 10, 5 * 60);
+
+ const ipResult = await this.checkRateLimit(`attest:${endpoint}:ip`, ip, ipLimit);
+ if (!ipResult.ok) return rateLimitedResponse(ipResult);
+ const deviceResult = await this.checkRateLimit(`attest:${endpoint}:device`, deviceID, deviceLimit);
+ if (!deviceResult.ok) return rateLimitedResponse(deviceResult);
+ return null;
+ }
+
+ async checkPublishRateLimit({ credID, addressees, broadcast }) {
+ if (credID) {
+ const result = await this.checkRateLimit(
+ "publish:cred",
+ credID,
+ this.rateLimitConfig("PUBLISH_CRED", 60, 60)
+ );
+ return result.ok ? null : rateLimitedResponse(result);
+ }
+
+ if (broadcast === true) {
+ return badRequest("broadcast requires credID");
+ }
+
+ const seen = new Set();
+ for (const addressee of addressees || []) {
+ const address = addressee && typeof addressee.address === "string" ? addressee.address : "";
+ if (!address || seen.has(address)) continue;
+ seen.add(address);
+ const result = await this.checkRateLimit(
+ "publish:address",
+ address,
+ this.rateLimitConfig("PUBLISH_ADDRESS", 30, 60)
+ );
+ if (!result.ok) return rateLimitedResponse(result);
+ }
+ return null;
+ }
+
+ rateLimitConfig(prefix, defaultLimit, defaultWindowSeconds) {
+ const limit = Number(this.env[`${prefix}_LIMIT`] || String(defaultLimit));
+ const windowSeconds = Number(this.env[`${prefix}_WINDOW_SECONDS`] || String(defaultWindowSeconds));
+ return {
+ limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaultLimit,
+ windowSeconds: Number.isFinite(windowSeconds) && windowSeconds > 0
+ ? Math.floor(windowSeconds)
+ : defaultWindowSeconds
+ };
+ }
+
+ async checkRateLimit(bucket, identity, config) {
+ const now = Date.now();
+ const windowMillis = config.windowSeconds * 1000;
+ const cutoff = now - windowMillis;
+ const key = await rateLimitStorageKey(bucket, identity, this.rateLimitKeySecret());
+ const stored = await this.state.storage.get(key);
+ const recent = Array.isArray(stored)
+ ? stored.filter((timestamp) => typeof timestamp === "number" && timestamp > cutoff)
+ : [];
+ if (recent.length >= config.limit) {
+ const retryAfterSeconds = Math.max(1, Math.ceil((recent[0] + windowMillis - now) / 1000));
+ await this.state.storage.put(key, recent);
+ return { ok: false, retryAfterSeconds };
+ }
+ recent.push(now);
+ await this.state.storage.put(key, recent);
+ return { ok: true };
+ }
+
+ rateLimitKeySecret() {
+ return this.env.RATE_LIMIT_HASH_KEY || this.env.APNS_KEY || "crossmate-rate-limit-v1";
+ }
+
// Verifies the game-participation signature: HMAC, under the secret
// registered for `credID`, over the App Attest request's own body hash,
// timestamp, and nonce (already validated by `authenticate`, so they are
@@ -714,6 +800,28 @@ function badRequest(message) {
return new Response(message, { status: 400 });
}
+function rateLimitedResponse(result) {
+ return new Response("Rate limit exceeded", {
+ status: 429,
+ headers: {
+ "Retry-After": String(result.retryAfterSeconds)
+ }
+ });
+}
+
+async function rateLimitStorageKey(bucket, identity, secret) {
+ const key = await crypto.subtle.importKey(
+ "raw",
+ new TextEncoder().encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(String(identity || "")));
+ const digest = new Uint8Array(signature);
+ return `rate:${bucket}:${base64URLEncode(digest)}`;
+}
+
// Storage key for a device's registration under one address. A game address
// arrives as `{address, credID}` and is keyed under its credID so a publish
// can reach it only when signed with that game's secret; the account-scoped