crossmate

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

commit 07aae65cd596b8a66a4d16e05c1f805305f95764
parent 28e1e0444a3ba4a95be6823aed0b6bb2f3fda289
Author: Michael Camilleri <[email protected]>
Date:   Sat, 18 Jul 2026 06:58:31 +0900

Bound push worker ingress and APNs fanout

An authenticated participant could make a single valid push-worker
request arbitrarily expensive: the worker buffered every request body
before authentication, accepted unbounded address lists, strings, and
opaque payloads, scanned storage for each submitted address, and
broadcast to every registration under a credential without a target cap.
The per-credential rate limit bounded how often the worker could be
called, not how much work one call could buy, and an oversized
notification failed only after all of that work, when APNs refused it.

This commit refuses oversized input ahead of the work it would otherwise
trigger. Request bodies are read through a byte cap that rejects on
Content-Length or cancels the stream mid-read, before any route —
attestation included — buffers them. Every key-forming identifier
(addresses, credIDs, device IDs, tokens, nonces) must fit a bounded
base64url/hex/UUID alphabet, so a hostile value can no longer forge a
`:`-separated storage-key segment or APNs URL syntax, and forwarded
metadata, address lists, and muted kinds carry length and count caps. A
game's registration set is capped at write time — which also bounds the
broadcast storage scan — with a fanout cap backstopping rooms that
predate the gate. A publish now measures the exact APNs payload sendOne
would build and refuses one that cannot fit the 4 KB ceiling before
signature, storage, or delivery work, so the sender gets a cheap 413
instead of a late delivery failure. The counts and byte budgets are
env-overridable under the same convention as the rate limits.

On the receiving side, PushPayloadCipher.open now rejects encoded blobs
over 8 KiB before any base64 or AES-GCM work. Every genuine payload
arrives through APNs' own 4 KB ceiling, so only hostile or corrupt input
is affected.

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MShared/PushPayloadCipher.swift | 10++++++++--
MTests/Unit/PushPayloadCipherTests.swift | 12++++++++++++
MTests/Workers/push-worker.test.mjs | 226+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MWorkers/push-worker.js | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
4 files changed, 493 insertions(+), 37 deletions(-)

diff --git a/Shared/PushPayloadCipher.swift b/Shared/PushPayloadCipher.swift @@ -21,6 +21,11 @@ import Foundation /// app hasn't mirrored it into the App Group yet) gets `nil` and falls back to /// the generic cleartext body the sender always ships. enum PushPayloadCipher { + /// Ceiling on an encoded sealed box. APNs caps a whole notification + /// payload at 4 KB, so any genuine `enc` is well under this; refusing + /// longer input bounds the base64/AES work a hostile value can demand. + static let maxEncodedLength = 8 * 1024 + /// Builds the symmetric key from the stored base64 `contentKey`. The Game /// record mints exactly 32 bytes; anything shorter is treated as absent. static func key(fromBase64 string: String) -> SymmetricKey? { @@ -40,9 +45,10 @@ enum PushPayloadCipher { } /// Opens a sealed payload. Returns `nil` on any failure — an absent or - /// wrong key, a corrupt box, or plaintext this build can't decode. + /// wrong key, an oversized blob, a corrupt box, or plaintext this build + /// can't decode. static func open(_ encoded: String?, key: SymmetricKey) -> PushPayload? { - guard let encoded, + guard let encoded, encoded.count <= maxEncodedLength, let combined = Data(base64Encoded: encoded), let box = try? AES.GCM.SealedBox(combined: combined), let plaintext = try? AES.GCM.open(box, using: key), diff --git a/Tests/Unit/PushPayloadCipherTests.swift b/Tests/Unit/PushPayloadCipherTests.swift @@ -49,6 +49,18 @@ struct PushPayloadCipherTests { #expect(PushPayloadCipher.open("YWJjZA==", key: key) == nil) // valid base64, not a box } + @Test("open rejects an oversized sealed blob before any decoding") + func oversizedOpen() { + let key = makeKey() + let oversized = String(repeating: "A", count: PushPayloadCipher.maxEncodedLength + 1) + #expect(PushPayloadCipher.open(oversized, key: key) == nil) + // A genuine payload sealed at the boundary still opens. + let payload = PushPayload(event: .win, puzzleTitle: "X", playerName: "Y") + let sealed = PushPayloadCipher.seal(payload, key: key)! + #expect(sealed.count <= PushPayloadCipher.maxEncodedLength) + #expect(PushPayloadCipher.open(sealed, key: key) == payload) + } + @Test("key rejects material shorter than 32 bytes") func keyLength() { #expect(PushPayloadCipher.key(fromBase64: Data(repeating: 0, count: 16).base64EncodedString()) == nil) diff --git a/Tests/Workers/push-worker.test.mjs b/Tests/Workers/push-worker.test.mjs @@ -265,3 +265,229 @@ test("after rotation, the old credential cannot bind under the new credID", asyn assert.equal(ok.status, 204); assert.deepEqual(storage.keys("addr:"), ["addr:cred-new:addr-a:device-current"]); }); + +// --- Ingress and fanout bounds (M3): request bodies, lists, strings, and +// APNs payloads are all refused before the work they would otherwise buy. + +test("an oversized request body is refused before auth", async () => { + const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" }); + const response = await registry.fetch(new Request("https://push.example/publish", { + method: "POST", + body: "x".repeat(2048) + })); + assert.equal(response.status, 413); +}); + +test("a body exactly at the limit passes the size gate", async () => { + const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" }); + const response = await registry.fetch(new Request("https://push.example/publish", { + method: "POST", + body: "x".repeat(1024) + })); + // Past the size gate; refused by auth, not by size. + assert.equal(response.status, 401); +}); + +test("the body cap also covers the pre-auth attestation routes", async () => { + const { registry } = makeRegistry({ MAX_BODY_BYTES: "1024" }); + const response = await registry.fetch(new Request("https://push.example/attest/challenge", { + method: "POST", + body: "x".repeat(2048) + })); + assert.equal(response.status, 413); +}); + +test("register refuses oversized address and mutedKinds lists", async () => { + const { registry } = makeRegistry({ MAX_ADDRESS_COUNT: "2" }); + const tooMany = await registry.handleRegister( + registerRequest(null), + registerBody([{ address: "a1" }, { address: "a2" }, { address: "a3" }]), + { deviceID: "device1" } + ); + assert.equal(tooMany.status, 400); + + const mutedOverflow = await registry.handleRegister( + registerRequest(null), + JSON.stringify({ + deviceID: "device1", + token: "apns-token", + environment: "production", + addresses: [{ address: "a1" }], + mutedKinds: Array.from({ length: 33 }, (_, i) => `kind${i}`) + }), + { deviceID: "device1" } + ); + assert.equal(mutedOverflow.status, 400); +}); + +test("register refuses a malformed token and skips malformed addresses", async () => { + const { registry, storage } = makeRegistry(); + const badToken = await registry.handleRegister( + registerRequest(null), + JSON.stringify({ + deviceID: "device1", + token: "x".repeat(200), + environment: "production", + addresses: [{ address: "a1" }] + }), + { deviceID: "device1" } + ); + assert.equal(badToken.status, 400); + + // An address that could forge a `:`-separated storage-key segment is + // dropped; the well-formed sibling in the same request still registers. + const mixed = await registry.handleRegister( + registerRequest(null), + registerBody([{ address: "addr:forged" }, { address: "addr-ok" }]), + { deviceID: "device1" } + ); + assert.equal(mixed.status, 204); + assert.deepEqual(storage.keys("addr:"), ["addr:addr-ok:device1"]); +}); + +test("a game's registration set is capped at write time", async () => { + const { registry, storage } = makeRegistry({ MAX_REGISTRATIONS_PER_CRED: "2" }); + await registerGameCred(registry, "cred-1"); + for (const device of ["device1", "device2"]) { + const response = await registry.handleRegister( + registerRequest(gameSignature(gameSecret, "cred-1")), + registerBody([{ address: `addr-${device}`, credID: "cred-1" }], device), + { deviceID: device } + ); + assert.equal(response.status, 204); + } + + const over = await registry.handleRegister( + registerRequest(gameSignature(gameSecret, "cred-1")), + registerBody([{ address: "addr-device3", credID: "cred-1" }], "device3"), + { deviceID: "device3" } + ); + assert.equal(over.status, 400); + assert.equal(storage.keys("addr:cred-1:").length, 2); + + // Re-registering an existing binding is an overwrite, not growth. + const again = await registry.handleRegister( + registerRequest(gameSignature(gameSecret, "cred-1")), + registerBody([{ address: "addr-device1", credID: "cred-1" }], "device1"), + { deviceID: "device1" } + ); + assert.equal(again.status, 204); +}); + +test("game credential registration bounds credID and secret", async () => { + const { registry } = makeRegistry(); + const badCredID = await registry.handleGameRegister( + "cred:forged", + JSON.stringify({ secret: gameSecret }) + ); + assert.equal(badCredID.status, 400); + + const hugeSecret = await registry.handleGameRegister( + "cred-1", + JSON.stringify({ secret: "A".repeat(1000) }) + ); + assert.equal(hugeSecret.status, 400); +}); + +function publishRequest() { + return new Request("https://push.example/publish", { method: "POST" }); +} + +function publishBody(overrides = {}) { + return JSON.stringify({ + kind: "win", + addressees: [{ address: "addr-a" }], + ...overrides + }); +} + +test("a bounded publish with no registered targets succeeds cheaply", async () => { + const { registry } = makeRegistry(); + const response = await registry.handlePublish( + publishRequest(), + publishBody(), + { deviceID: "device1" } + ); + assert.equal(response.status, 200); + assert.deepEqual(await response.json(), { delivered: 0, removed: 0, muted: 0, failed: 0 }); +}); + +test("publish refuses oversized addressee lists and fields", async () => { + const { registry } = makeRegistry({ MAX_ADDRESS_COUNT: "2" }); + const tooMany = await registry.handlePublish( + publishRequest(), + publishBody({ addressees: [{ address: "a1" }, { address: "a2" }, { address: "a3" }] }), + { deviceID: "device1" } + ); + assert.equal(tooMany.status, 400); + + const hugeEnc = await registry.handlePublish( + publishRequest(), + publishBody({ addressees: [{ address: "a1", enc: "A".repeat(5000) }] }), + { deviceID: "device1" } + ); + assert.equal(hugeEnc.status, 400); + + const hugeBody = await registry.handlePublish( + publishRequest(), + publishBody({ alertBody: "x".repeat(1000) }), + { deviceID: "device1" } + ); + assert.equal(hugeBody.status, 400); + + const badCollapse = await registry.handlePublish( + publishRequest(), + publishBody({ collapseID: "c".repeat(65) }), + { deviceID: "device1" } + ); + assert.equal(badCollapse.status, 400); + + const badAddress = await registry.handlePublish( + publishRequest(), + publishBody({ addressees: [{ address: "addr:forged" }] }), + { deviceID: "device1" } + ); + assert.equal(badAddress.status, 400); +}); + +test("a publish whose APNs payload cannot fit is refused before any fanout", async () => { + const { registry } = makeRegistry(); + // Each field is within its own cap, but the assembled APNs payload + // (aps + kind + title + enc as JSON) exceeds the 4 KB APNs ceiling. + const response = await registry.handlePublish( + publishRequest(), + publishBody({ + title: "t".repeat(400), + alertBody: "b".repeat(500), + addressees: [{ address: "a1", enc: "A".repeat(4000) }] + }), + { deviceID: "device1" } + ); + assert.equal(response.status, 413); + + // The same shape with a small enc fits and proceeds. + const fits = await registry.handlePublish( + publishRequest(), + publishBody({ + title: "t".repeat(400), + alertBody: "b".repeat(500), + addressees: [{ address: "a1", enc: "A".repeat(500) }] + }), + { deviceID: "device1" } + ); + assert.equal(fits.status, 200); +}); + +test("broadcast fanout is capped even for an over-cap room", async () => { + const { registry, storage } = makeRegistry({ MAX_BROADCAST_TARGETS: "2" }); + for (let index = 0; index < 4; index += 1) { + await storage.put(`addr:cred-1:addr-${index}:device-${index}`, { + token: "t", + environment: "production" + }); + } + const targets = await registry.resolveBroadcastTargets( + "cred-1", null, null, "body", undefined, undefined + ); + assert.equal(targets.length, 2); +}); diff --git a/Workers/push-worker.js b/Workers/push-worker.js @@ -10,6 +10,32 @@ const RATE_LIMIT_DEFAULTS = { PUBLISH_ADDRESS: { limit: 30, windowSeconds: 60 } }; +// Ingress bounds: a single authenticated request must not be able to buy +// unbounded buffering, storage scanning, or APNs fanout, so every body, list, +// and string is capped before the work it would trigger. These counts and +// byte budgets are env-overridable (same convention as the rate limits); the +// per-field character caps below are structural and fixed. +const INGRESS_LIMIT_DEFAULTS = { + MAX_BODY_BYTES: 131072, + MAX_ADDRESS_COUNT: 64, + MAX_REGISTRATIONS_PER_CRED: 128, + MAX_BROADCAST_TARGETS: 128 +}; + +// Key-forming identifiers (addresses, credIDs, device IDs, APNs tokens, +// nonces) are base64url/hex/UUID strings; anything longer or outside that +// alphabet cannot be genuine and would otherwise flow into storage keys and +// the APNs request URL. +const MAX_ID_CHARS = 128; +const MAX_KIND_CHARS = 64; +const MAX_MUTED_KIND_COUNT = 32; +const MAX_TEXT_CHARS = 512; // title / alert body +const MAX_OPAQUE_CHARS = 4096; // forwarded base64 `payload` / `enc` blobs +const MAX_COLLAPSE_ID_CHARS = 64; // APNs' own apns-collapse-id ceiling +const MAX_SECRET_CHARS = 256; // clients mint 32 bytes → 43 base64url chars +const MAX_ATTESTATION_OBJECT_CHARS = 32768; // genuine objects are ~8 KB base64 +const MAX_APNS_PAYLOAD_BYTES = 4096; // APNs payload ceiling (alert + background) + export class PushRegistry { constructor(state, env) { this.state = state; @@ -21,14 +47,22 @@ export class PushRegistry { async fetch(request) { const url = new URL(request.url); + // Bound the body before any route (attestation included) buffers it: an + // oversized request is refused for a few header bytes instead of being + // materialized just to fail JSON or auth validation. + const body = await readBodyWithinLimit(request, this.ingressLimit("MAX_BODY_BYTES")); + if (!body.ok) { + return new Response("Body too large", { status: 413 }); + } + const bodyText = body.text; + if (url.pathname === "/attest/challenge" && request.method === "POST") { - return this.handleAttestationChallenge(request); + return this.handleAttestationChallenge(request, bodyText); } if (url.pathname === "/attest/register" && request.method === "POST") { - return this.handleAttestationRegister(request); + return this.handleAttestationRegister(request, bodyText); } - const bodyText = await request.text(); const auth = await this.authenticate(request, bodyText); if (!auth.ok) { console.warn("Push worker auth failed", { @@ -81,6 +115,10 @@ export class PushRegistry { if (!deviceID || !keyID || !timestamp || !nonce || !bodyHash || !assertionBase64) { return { ok: false, status: 401, message: "Incomplete App Attest auth" }; } + // These three form storage keys below; bound them before any storage touch. + if (!isValidID(deviceID) || !isValidID(keyID) || !isValidID(nonce)) { + return { ok: false, status: 401, message: "Malformed App Attest auth" }; + } const nowSeconds = Math.floor(Date.now() / 1000); const timestampSeconds = Number(timestamp); @@ -152,14 +190,18 @@ export class PushRegistry { return { ok: true, deviceID }; } - async handleAttestationChallenge(request) { - const body = await readJSONText(await request.text()); + async handleAttestationChallenge(request, bodyText) { + const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); const deviceID = body.deviceID || ""; const keyID = body.keyID || ""; if (!deviceID || !keyID) { return badRequest("deviceID and keyID required"); } + // Both form storage keys; bound them before rate-limit or challenge writes. + if (!isValidID(deviceID) || !isValidID(keyID)) { + return badRequest("Malformed deviceID or keyID"); + } const limited = await this.checkAttestationRateLimit(request, "challenge", deviceID); if (limited) return limited; const ttlSeconds = this.appAttestChallengeTTLSeconds(); @@ -175,13 +217,21 @@ export class PushRegistry { return Response.json({ challenge }); } - async handleAttestationRegister(request) { - const body = await readJSONText(await request.text()); + async handleAttestationRegister(request, bodyText) { + const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); const { deviceID, keyID, challenge, attestationObject } = body; if (!deviceID || !keyID || !challenge || !attestationObject) { return badRequest("deviceID, keyID, challenge, attestationObject required"); } + if (!isValidID(deviceID) || !isValidID(keyID) || !isValidID(challenge)) { + return badRequest("Malformed deviceID, keyID, or challenge"); + } + // Attestation runs pre-auth, so keep the base64/CBOR/certificate work it + // can demand tighter than the general body cap. + if (typeof attestationObject !== "string" || attestationObject.length > MAX_ATTESTATION_OBJECT_CHARS) { + return badRequest("attestationObject too large"); + } const limited = await this.checkAttestationRateLimit(request, "register", deviceID); if (limited) return limited; const challengeKey = this.appAttestChallengeKey(deviceID, challenge); @@ -355,12 +405,21 @@ export class PushRegistry { if (environment !== "sandbox" && environment !== "production") { return badRequest("environment must be 'sandbox' or 'production'"); } + if (!isValidID(deviceID) || !isValidID(token)) { + return badRequest("Malformed deviceID or token"); + } + if (addresses.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { + return badRequest("Too many addresses"); + } + if (Array.isArray(mutedKinds) && mutedKinds.length > MAX_MUTED_KIND_COUNT) { + return badRequest("Too many mutedKinds"); + } // Notification preferences ride along as a denylist of `kind` strings the // device does not want delivered. The worker only string-matches them at // publish time — it never interprets them — so a missing field (older // clients) and a kind invented after registration both mean "deliver". const muted = Array.isArray(mutedKinds) - ? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0) + ? mutedKinds.filter((kind) => typeof kind === "string" && kind.length > 0 && kind.length <= MAX_KIND_CHARS) : []; // A game-scoped binding is a subscription to that game's pushes, so it // must prove current participation the same way a publish does: a game @@ -377,7 +436,7 @@ export class PushRegistry { // one unsigned request keeps its account binding. const credIDs = new Set( addresses - .filter((entry) => entry && typeof entry === "object" && typeof entry.credID === "string") + .filter((entry) => entry && typeof entry === "object" && isValidID(entry.credID)) .map((entry) => entry.credID) ); let verifiedCredID = null; @@ -386,6 +445,22 @@ export class PushRegistry { const verification = await this.verifyGameSignature(request, credID); if (verification.ok) verifiedCredID = credID; } + // A room's registration set is exactly what a broadcast fans out to, so + // cap it at write time — that also bounds the broadcast storage scan. + // Checked before any write so a refused request stores nothing; + // re-registering an existing binding always succeeds. + if (verifiedCredID) { + const credPrefix = `addr:${verifiedCredID}:`; + const existing = await this.state.storage.list({ prefix: credPrefix }); + const incoming = new Set(); + for (const entry of addresses) { + const key = addressStorageKey(entry, deviceID); + if (key && key.startsWith(credPrefix) && !existing.has(key)) incoming.add(key); + } + if (existing.size + incoming.size > this.ingressLimit("MAX_REGISTRATIONS_PER_CRED")) { + return badRequest("Too many registrations for game"); + } + } // Bind this device's APNs token to each address it knows. A game address // carries the game's `credID` and is stored under it so a publish can only // reach it when signed with that game's secret; the account-scoped sibling @@ -416,6 +491,12 @@ export class PushRegistry { if (auth.deviceID !== deviceID) { return new Response("Authenticated device mismatch", { status: 403 }); } + if (!isValidID(deviceID)) { + return badRequest("Malformed deviceID"); + } + if (addresses.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { + return badRequest("Too many addresses"); + } for (const entry of addresses) { const key = addressStorageKey(entry, deviceID); if (!key) continue; @@ -432,7 +513,10 @@ export class PushRegistry { async handleGameRegister(credID, bodyText) { const body = await readJSONText(bodyText); if (!body) return badRequest("Body must be JSON"); - const secret = typeof body.secret === "string" ? body.secret : ""; + if (!isValidID(credID)) return badRequest("Malformed credID"); + const secret = typeof body.secret === "string" && body.secret.length <= MAX_SECRET_CHARS + ? body.secret + : ""; if (!isAcceptableSecret(secret)) return badRequest("Invalid secret"); const key = `gamecred:${credID}`; const stored = await this.state.storage.get(key); @@ -475,17 +559,81 @@ export class PushRegistry { payload, enc } = body; - if (!kind) { + if (!kind || typeof kind !== "string" || kind.length > MAX_KIND_CHARS) { return badRequest("kind required"); } + // Bound every field before the signature, rate-limit, storage, and APNs + // work it would otherwise buy. Forwarded metadata only needs a length cap; + // the credID additionally forms storage keys and gets the ID alphabet. + if (!isAbsentOrBounded(gameID, MAX_ID_CHARS) + || !isAbsentOrBounded(fromAuthorID, MAX_ID_CHARS) + || !isAbsentOrBounded(senderDeviceID, MAX_ID_CHARS) + || !isAbsentOrBounded(readAt, MAX_ID_CHARS) + || !isAbsentOrBounded(excludeAddress, MAX_ID_CHARS) + || !isAbsentOrBounded(title, MAX_TEXT_CHARS) + || !isAbsentOrBounded(alertBody, MAX_TEXT_CHARS) + || !isAbsentOrBounded(payload, MAX_OPAQUE_CHARS) + || !isAbsentOrBounded(enc, MAX_OPAQUE_CHARS)) { + return badRequest("Field too large"); + } + if (credID != null && credID !== "" && !isValidID(credID)) { + return badRequest("Malformed credID"); + } + // The collapse ID travels as an APNs header (64-byte APNs ceiling), so it + // must also stay printable ASCII. + if (collapseID != null + && !(typeof collapseID === "string" + && collapseID.length <= MAX_COLLAPSE_ID_CHARS + && /^[\x20-\x7e]*$/.test(collapseID))) { + return badRequest("Malformed collapseID"); + } // A broadcast fans out to every device registered under the game's credID // (the whole room), so it carries no addressees but must be game-scoped — // the credID is both the delivery scope and, via its signature, the // participation proof. A non-broadcast publish names its recipients. if (broadcast === true) { if (!credID) return badRequest("broadcast requires credID"); - } else if (!Array.isArray(addressees) || addressees.length === 0) { - return badRequest("non-empty addressees required"); + } else { + if (!Array.isArray(addressees) || addressees.length === 0) { + return badRequest("non-empty addressees required"); + } + if (addressees.length > this.ingressLimit("MAX_ADDRESS_COUNT")) { + return badRequest("Too many addressees"); + } + for (const addressee of addressees) { + if (!addressee || typeof addressee !== "object" || !isValidID(addressee.address)) { + return badRequest("Malformed addressee"); + } + if (!isAbsentOrBounded(addressee.body, MAX_TEXT_CHARS) + || !isAbsentOrBounded(addressee.payload, MAX_OPAQUE_CHARS) + || !isAbsentOrBounded(addressee.enc, MAX_OPAQUE_CHARS)) { + return badRequest("Addressee field too large"); + } + } + } + + // APNs enforces its payload ceiling only after the worker has spent + // signature, storage, and delivery work; measure the exact payload + // `sendOne` would build and refuse oversized publishes up front instead. + const encoder = new TextEncoder(); + const fits = (body, forwardedPayload, forwardedEnc) => + encoder.encode(JSON.stringify(buildAPNsPayload({ + kind, + gameID, + fromAuthorID, + senderDeviceID, + readAt, + title, + body, + payload: forwardedPayload, + enc: forwardedEnc, + background: background === true + }))).length <= MAX_APNS_PAYLOAD_BYTES; + const oversized = broadcast === true + ? !fits(alertBody, payload, enc) + : addressees.some((addressee) => !fits(addressee.body || alertBody, addressee.payload, addressee.enc)); + if (oversized) { + return new Response("Notification payload too large", { status: 413 }); } if (credID) { @@ -667,6 +815,13 @@ export class PushRegistry { return this.env.RATE_LIMIT_HASH_KEY || this.env.APNS_KEY || "crossmate-rate-limit-v1"; } + // Resolves an env-overridable ingress cap, falling back to the table default + // on a missing or malformed override — same convention as the rate limits. + ingressLimit(name) { + const parsed = Number(this.env[name] || ""); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : INGRESS_LIMIT_DEFAULTS[name]; + } + // 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 @@ -745,8 +900,12 @@ export class PushRegistry { const forwardedEnc = typeof enc === "string" ? enc : undefined; const prefix = `addr:${credID}:`; const map = await this.state.storage.list({ prefix }); + // The register-time per-credential cap bounds this scan; this cap bounds + // the sequential APNs sends if an over-cap room predates that gate. + const maxTargets = this.ingressLimit("MAX_BROADCAST_TARGETS"); const targets = []; for (const [key, value] of map) { + if (targets.length >= maxTargets) break; const rest = key.slice(prefix.length); const sep = rest.indexOf(":"); if (sep < 0) continue; @@ -773,25 +932,7 @@ export class PushRegistry { ? "api.sandbox.push.apple.com" : "api.push.apple.com"; const jwt = await this.providerJWT(); - const alert = {}; - if (message.title) alert.title = message.title; - if (message.body) alert.body = message.body; - const apnsPayload = { - aps: message.background - ? { "content-available": 1 } - : { alert, sound: "default", "mutable-content": 1 }, - kind: message.kind - }; - if (message.gameID) apnsPayload.gameID = message.gameID; - if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; - if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; - if (message.readAt) apnsPayload.readAt = message.readAt; - // Forward the opaque app payload verbatim when present. `enc` is the - // encrypted payload current clients send; `payload` is the legacy cleartext - // form an older client may still send. Both are absent for older app builds, - // which the extension handles by falling back to `kind`. - if (message.enc) apnsPayload.enc = message.enc; - if (message.payload) apnsPayload.payload = message.payload; + const apnsPayload = buildAPNsPayload(message); // A "nudge" rouse is ephemeral: deliver now or discard, since "come play" // delivered hours later is stale noise. `accountSeen` is also @@ -869,8 +1010,76 @@ export default { } }; -async function readJSON(request) { - return readJSONText(await request.text()); +// Buffers a request body only up to maxBytes: a Content-Length that already +// exceeds the cap is refused for free, and a stream that grows past it is +// cancelled mid-read instead of being materialized. +async function readBodyWithinLimit(request, maxBytes) { + const declared = Number(request.headers.get("content-length") || ""); + if (Number.isFinite(declared) && declared > maxBytes) { + return { ok: false }; + } + if (!request.body) { + return { ok: true, text: "" }; + } + const reader = request.body.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) { + try { + await reader.cancel(); + } catch { + // The stream is already errored/closed; nothing left to release. + } + return { ok: false }; + } + chunks.push(value); + } + return { ok: true, text: new TextDecoder().decode(concatBytes(...chunks)) }; +} + +// Key-forming identifier: bounded and confined to the base64url/hex/UUID +// alphabet every genuine address, credID, device ID, token, and nonce uses — +// so it can never smuggle a `:` storage-key separator or APNs URL syntax. +function isValidID(value) { + return typeof value === "string" + && value.length > 0 + && value.length <= MAX_ID_CHARS + && /^[A-Za-z0-9._-]+$/.test(value); +} + +// Forwarded metadata: absent (or null) is fine, anything present must be a +// string within the cap. +function isAbsentOrBounded(value, maxChars) { + return value == null || (typeof value === "string" && value.length <= maxChars); +} + +// The exact APNs payload for one target, shared by the publish-time size +// check and `sendOne` so the two can never disagree. +function buildAPNsPayload(message) { + const alert = {}; + if (message.title) alert.title = message.title; + if (message.body) alert.body = message.body; + const apnsPayload = { + aps: message.background + ? { "content-available": 1 } + : { alert, sound: "default", "mutable-content": 1 }, + kind: message.kind + }; + if (message.gameID) apnsPayload.gameID = message.gameID; + if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; + if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; + if (message.readAt) apnsPayload.readAt = message.readAt; + // Forward the opaque app payload verbatim when present. `enc` is the + // encrypted payload current clients send; `payload` is the legacy cleartext + // form an older client may still send. Both are absent for older app builds, + // which the extension handles by falling back to `kind`. + if (message.enc) apnsPayload.enc = message.enc; + if (message.payload) apnsPayload.payload = message.payload; + return apnsPayload; } async function readJSONText(text) { @@ -913,10 +1122,13 @@ async function rateLimitStorageKey(bucket, identity, secret) { // address arrives without a credID and uses the bare key. function addressStorageKey(entry, deviceID) { const address = entry && typeof entry === "object" ? entry.address : entry; - if (typeof address !== "string" || address.length === 0) return null; + // The address and credID become `:`-separated storage-key segments, so both + // must pass the ID alphabet or the key's structure could be forged. + if (!isValidID(address)) return null; const credID = entry && typeof entry === "object" && typeof entry.credID === "string" ? entry.credID : ""; + if (credID && !isValidID(credID)) return null; return credID ? `addr:${credID}:${address}:${deviceID}` : `addr:${address}:${deviceID}`;