crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

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:
MWorkers/push-worker.js | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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