crossmate

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

commit 163395f4d16132d9939c57b9396d62ddc69ff2db
parent 4e33d976f1909c8d92d1f17bee6225eb4b313ee0
Author: Michael Camilleri <[email protected]>
Date:   Sun, 12 Jul 2026 00:57:40 +0900

Bound realtime frame and batch size on relay and receipt

The engagement relay passed every WebSocket frame through verbatim, and
the receiving client materialised the whole message with JSONDecoder on
the main actor before any authenticity check. An authenticated co-player
— which, via a public share link, can be anyone holding the link — could
flood a room with maximum-size frames or a single enormous edit batch,
keeping every peer's app busy decoding and running large Core Data
mutations: the puzzle stayed intact, but the app was effectively
unusable while the frames kept coming, and each relayed frame billed
worker time.

This commit bounds the live channel at every layer. The room worker
refuses frames over ROOM_MAX_FRAME_BYTES (512 KiB, well under
Cloudflare's own 1 MiB cap) before any storage or fanout work, closing
the offending socket with 1009 rather than repeatedly dropping its
frames. On the client, the socket task's maximumMessageSize enforces the
same bound at the transport, and EngagementMessage.decode rejects
oversized data before JSON decoding, then bounds the decoded envelope:
at most maxBatchEdits (1,024) edits per batch, at most maxAuthTags (128)
per-recipient tags, and length caps on every string field — letters,
author and device identifiers, tags and debug text. Anything out of
bounds is dropped and left to converge over the durable Moves/CloudKit
path.

So a legitimate whole-grid gesture on a very large puzzle never trips
the new receive bounds, sendLocalCellEdits now splits bulk gestures into
cap-sized frames, each carrying its own MAC tags. A batch at the cap
encodes to roughly 300 KB, comfortably inside the frame limit even with
a full CloudKit share's worth of recipients.

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

Diffstat:
MCrossmate/Services/EngagementHost.swift | 4++++
MCrossmate/Services/EngagementLifecycle.swift | 22++++++++++++++--------
MCrossmate/Sync/EngagementCoordinator.swift | 60+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MTests/Unit/Sync/EngagementCoordinatorTests.swift | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Workers/room-worker.test.mjs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
MWorkers/room-worker.js | 22++++++++++++++++++++++
MWorkers/wrangler.room.toml | 1+
7 files changed, 250 insertions(+), 10 deletions(-)

diff --git a/Crossmate/Services/EngagementHost.swift b/Crossmate/Services/EngagementHost.swift @@ -48,6 +48,10 @@ final class EngagementHost: NSObject { throw EngagementHostError.missingEndpoint } let task = session.webSocketTask(with: url) + // The relay is untrusted; refuse oversized frames at the transport + // before they are buffered into the app at all. `EngagementMessage + // .decode` re-checks the same bound, but this one costs nothing. + task.maximumMessageSize = EngagementMessage.maxEncodedFrameBytes sockets[engagementID] = task engagementIDsByTask[ObjectIdentifier(task)] = engagementID task.resume() diff --git a/Crossmate/Services/EngagementLifecycle.swift b/Crossmate/Services/EngagementLifecycle.swift @@ -303,18 +303,24 @@ final class EngagementLifecycle { Task { await engagementCoordinator.sendCellEdit(edit, senderAuthorID: localAuthorID, auth: auth) } } - /// Batch companion to `sendLocalCellEdit`. + /// Batch companion to `sendLocalCellEdit`. A gesture larger than + /// `EngagementMessage.maxBatchEdits` (a whole-grid check on a very large + /// puzzle) is split into cap-sized frames, each MAC'd independently, so it + /// stays inside the receive bounds every peer now enforces. func sendLocalCellEdits(_ edits: [RealtimeCellEdit]) { guard let gameID = edits.first?.gameID else { return } guard engagementStatus.isLive(gameID: gameID) else { return } guard let localAuthorID = identity.currentID, !localAuthorID.isEmpty else { return } - let auth = EngagementMessageAuthenticator.authTags( - for: EngagementMessage(cellEdits: edits), - senderAuthorID: localAuthorID, - recipientAuthorIDs: store.participantAuthorIDs(gameID: gameID) - ) - guard !auth.isEmpty else { return } - Task { await engagementCoordinator.sendCellEdits(edits, senderAuthorID: localAuthorID, auth: auth) } + let recipientAuthorIDs = store.participantAuthorIDs(gameID: gameID) + for chunk in EngagementMessage.batchChunks(edits) { + let auth = EngagementMessageAuthenticator.authTags( + for: EngagementMessage(cellEdits: chunk), + senderAuthorID: localAuthorID, + recipientAuthorIDs: recipientAuthorIDs + ) + guard !auth.isEmpty else { return } + Task { await engagementCoordinator.sendCellEdits(chunk, senderAuthorID: localAuthorID, auth: auth) } + } } func handleEngagementEvent(_ event: EngagementHost.Event) { diff --git a/Crossmate/Sync/EngagementCoordinator.swift b/Crossmate/Sync/EngagementCoordinator.swift @@ -176,8 +176,66 @@ struct EngagementMessage: Codable, Equatable, Sendable { try JSONEncoder().encode(self) } + /// Ingress bounds for the live channel. The relay is a blind pass-through, + /// so a hostile participant controls frame size and batch shape; every + /// limit here is enforced *before* the frame reaches authentication or + /// Core Data. The worker mirrors `maxEncodedFrameBytes` (see + /// `ROOM_MAX_FRAME_BYTES` in room-worker.js) and the sender chunks bulk + /// gestures to `maxBatchEdits` (`EngagementLifecycle.sendLocalCellEdits`), + /// so a legitimate peer never trips them. + /// + /// Sizing: a `maxBatchEdits` batch encodes to ~300 KB, plus one ~120-byte + /// auth tag per recipient (CloudKit shares top out at 100 participants), + /// comfortably inside `maxEncodedFrameBytes`. Cloudflare's own WebSocket + /// cap is 1 MiB, so the worker limit is meaningfully tighter than the + /// platform's. + static let maxEncodedFrameBytes = 512 * 1024 + static let maxBatchEdits = 1024 + static let maxAuthTags = 128 + static let maxIdentifierLength = 128 + static let maxLetterLength = 16 + static let maxTextLength = 512 + static func decode(_ data: Data) -> EngagementMessage? { - try? JSONDecoder().decode(EngagementMessage.self, from: data) + guard data.count <= maxEncodedFrameBytes else { return nil } + guard let message = try? JSONDecoder().decode(EngagementMessage.self, from: data), + message.isWithinBounds + else { return nil } + return message + } + + /// Splits a bulk gesture into sendable batches. Order is preserved, but + /// chunks travel as independent frames, so per-cell last-writer-wins is + /// what guarantees convergence — same as any other frame reordering. + static func batchChunks(_ edits: [RealtimeCellEdit]) -> [[RealtimeCellEdit]] { + stride(from: 0, to: edits.count, by: maxBatchEdits).map { start in + Array(edits[start..<min(start + maxBatchEdits, edits.count)]) + } + } + + private var isWithinBounds: Bool { + func boundedID(_ id: String?) -> Bool { + (id ?? "").count <= Self.maxIdentifierLength + } + func bounded(_ edit: RealtimeCellEdit) -> Bool { + boundedID(edit.authorID) && boundedID(edit.deviceID) + && boundedID(edit.cellAuthorID) + && edit.letter.count <= Self.maxLetterLength + } + guard boundedID(senderAuthorID), text.count <= Self.maxTextLength else { return false } + guard auth.count <= Self.maxAuthTags, + auth.allSatisfy({ boundedID($0.key) && boundedID($0.value) }) + else { return false } + if let cellEdit, !bounded(cellEdit) { return false } + if let cellEdits { + guard cellEdits.count <= Self.maxBatchEdits, cellEdits.allSatisfy(bounded) else { + return false + } + } + if let selection { + guard boundedID(selection.authorID), boundedID(selection.deviceID) else { return false } + } + return true } } diff --git a/Tests/Unit/Sync/EngagementCoordinatorTests.swift b/Tests/Unit/Sync/EngagementCoordinatorTests.swift @@ -140,6 +140,61 @@ struct EngagementCoordinatorTests { #expect(decoded.sentAt == Date(timeIntervalSince1970: 789)) } + @Test("an oversized frame is rejected before JSON decoding") + func oversizedFrameIsRejected() { + #expect(EngagementMessage.decode(Data(count: EngagementMessage.maxEncodedFrameBytes + 1)) == nil) + } + + @Test("a boundary-size edit batch round trips and an over-count batch is rejected") + func batchEditCountIsBounded() throws { + let atCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits)) + let atCapData = try atCap.encodedData() + #expect(atCapData.count <= EngagementMessage.maxEncodedFrameBytes) + #expect(EngagementMessage.decode(atCapData)?.cellEdits?.count == EngagementMessage.maxBatchEdits) + + let overCap = EngagementMessage(cellEdits: makeEdits(count: EngagementMessage.maxBatchEdits + 1)) + #expect(EngagementMessage.decode(try overCap.encodedData()) == nil) + } + + @Test("frames with oversized strings or auth maps are rejected") + func stringAndAuthBoundsAreEnforced() throws { + func rejected(_ message: EngagementMessage) throws -> Bool { + try EngagementMessage.decode(message.encodedData()) == nil + } + + var longLetter = makeEdits(count: 1)[0] + longLetter.letter = String(repeating: "A", count: EngagementMessage.maxLetterLength + 1) + #expect(try rejected(EngagementMessage(cellEdit: longLetter))) + + var longAuthor = makeEdits(count: 1)[0] + longAuthor.authorID = String(repeating: "a", count: EngagementMessage.maxIdentifierLength + 1) + #expect(try rejected(EngagementMessage(cellEdit: longAuthor))) + + let longText = String(repeating: "x", count: EngagementMessage.maxTextLength + 1) + #expect(try rejected(EngagementMessage(text: longText))) + + let manyTags = Dictionary(uniqueKeysWithValues: (0...EngagementMessage.maxAuthTags).map { + ("author-\($0)", "tag") + }) + #expect(try rejected(EngagementMessage(text: "hi", auth: manyTags))) + + let longTag = ["bob": String(repeating: "t", count: EngagementMessage.maxIdentifierLength + 1)] + #expect(try rejected(EngagementMessage(text: "hi", auth: longTag))) + } + + @Test("bulk gestures chunk at the batch cap with order preserved") + func batchChunking() { + let edits = makeEdits(count: EngagementMessage.maxBatchEdits * 2 + 5) + let chunks = EngagementMessage.batchChunks(edits) + + #expect(chunks.map(\.count) == [EngagementMessage.maxBatchEdits, EngagementMessage.maxBatchEdits, 5]) + #expect(chunks.flatMap { $0 } == edits) + #expect(EngagementMessage.batchChunks([]).isEmpty) + + let small = makeEdits(count: 3) + #expect(EngagementMessage.batchChunks(small) == [small]) + } + @Test("reconcile connects to the advertised room when a peer is present") @MainActor func reconcileConnectsToCreds() async throws { @@ -326,6 +381,22 @@ struct EngagementCoordinatorTests { ) } + private func makeEdits(count: Int) -> [RealtimeCellEdit] { + (0..<count).map { index in + RealtimeCellEdit( + gameID: UUID(uuidString: "12121212-1212-1212-1212-121212121212")!, + authorID: "alice", + deviceID: "deviceA", + row: index / 128, + col: index % 128, + letter: "A", + mark: .pen(checked: nil), + updatedAt: Date(timeIntervalSince1970: 456), + cellAuthorID: "alice" + ) + } + } + private func roomCredentials( roomID: UUID = UUID(uuidString: "88888888-8888-8888-8888-888888888888")!, expiresAt: Date = .distantFuture diff --git a/Tests/Workers/room-worker.test.mjs b/Tests/Workers/room-worker.test.mjs @@ -1,8 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { EngagementRegisterLimiter } from "../../Workers/room-worker.js"; +import { EngagementRegisterLimiter, EngagementRoom } from "../../Workers/room-worker.js"; import { StubStorage } from "./helpers.mjs"; +// Workers-runtime global used by EngagementRoom's constructor for keepalive +// auto-responses; the pair itself is inert under test. +globalThis.WebSocketRequestResponsePair ??= class { + constructor(request, response) { + this.request = request; + this.response = response; + } +}; + function makeLimiter(env = {}) { const storage = new StubStorage(); return { limiter: new EngagementRegisterLimiter({ storage }, env), storage }; @@ -69,6 +78,75 @@ test("registering arms the sweep alarm once", async () => { assert.equal(storage.alarmAt, armedAt, "arm-if-unarmed must not reschedule"); }); +class StubSocket { + constructor() { + this.sent = []; + this.closed = null; + } + + send(data) { + this.sent.push(data); + } + + close(code, reason) { + this.closed = { code, reason }; + } +} + +function makeRoom(env = {}) { + const storage = new StubStorage(); + const sockets = []; + const state = { + storage, + getWebSockets: () => sockets, + setWebSocketAutoResponse: () => {} + }; + return { room: new EngagementRoom(state, env), sockets, storage }; +} + +test("an oversized frame is not relayed and closes the sender", async () => { + const { room, sockets } = makeRoom({ ROOM_MAX_FRAME_BYTES: "1024" }); + const alice = new StubSocket(); + const bob = new StubSocket(); + sockets.push(alice, bob); + + await room.webSocketMessage(alice, new ArrayBuffer(1025)); + + assert.deepEqual(bob.sent, []); + assert.equal(alice.closed?.code, 1009); +}); + +test("a boundary-size frame is relayed to every peer but the sender", async () => { + const { room, sockets } = makeRoom({ ROOM_MAX_FRAME_BYTES: "1024" }); + const alice = new StubSocket(); + const bob = new StubSocket(); + const carol = new StubSocket(); + sockets.push(alice, bob, carol); + + await room.webSocketMessage(alice, new ArrayBuffer(1024)); + + assert.equal(bob.sent.length, 1); + assert.equal(carol.sent.length, 1); + assert.deepEqual(alice.sent, []); + assert.equal(alice.closed, null); +}); + +test("the frame limit defaults to 512 KiB when unset or malformed", async () => { + for (const env of [{}, { ROOM_MAX_FRAME_BYTES: "not-a-number" }, { ROOM_MAX_FRAME_BYTES: "-1" }]) { + const { room, sockets } = makeRoom(env); + const alice = new StubSocket(); + const bob = new StubSocket(); + sockets.push(alice, bob); + + await room.webSocketMessage(alice, new ArrayBuffer(512 * 1024)); + assert.equal(bob.sent.length, 1, `boundary frame relays under ${JSON.stringify(env)}`); + + await room.webSocketMessage(alice, new ArrayBuffer(512 * 1024 + 1)); + assert.equal(bob.sent.length, 1, `oversized frame drops under ${JSON.stringify(env)}`); + assert.equal(alice.closed?.code, 1009); + } +}); + test("sweep prunes stale IP keys, keeps live ones, and re-arms accordingly", async () => { const { limiter, storage } = makeLimiter({ ROOM_REGISTER_IP_WINDOW_SECONDS: "60" }); await limiter.fetch(registerRequest("room-1", "203.0.113.9")); diff --git a/Workers/room-worker.js b/Workers/room-worker.js @@ -240,6 +240,21 @@ export class EngagementRoom { } async webSocketMessage(ws, message) { + // Bound the relay before any storage or fanout work. The client enforces + // the same limit (EngagementMessage.maxEncodedFrameBytes) on send and + // receive, so only a hostile or badly broken peer trips this; closing the + // socket (1009: message too big) is cheaper for the room than repeatedly + // dropping its frames. String frames are measured in UTF-16 code units, + // a lower bound on their UTF-8 size — real payloads travel as binary. + const frameBytes = typeof message === "string" ? message.length : message.byteLength; + if (frameBytes > maxFrameBytes(this.env)) { + try { + ws.close(1009, "Frame exceeds size limit"); + } catch { + // Already closing; the runtime will reap it. + } + return; + } const data = typeof message === "string" ? message : message.slice(0); await this.touch(); for (const peer of this.state.getWebSockets()) { @@ -353,6 +368,13 @@ async function checkRegisterRateLimit(request, env, roomID) { return response.status === 204 ? null : response; } +// Mirrors the client's EngagementMessage.maxEncodedFrameBytes (512 KiB); +// Cloudflare's own per-message cap (1 MiB) is the outer backstop. +function maxFrameBytes(env) { + const parsed = Number(env.ROOM_MAX_FRAME_BYTES || "524288"); + return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 524288; +} + function rateLimitConfig(env, prefix, defaultLimit, defaultWindowSeconds) { const limit = Number(env[`${prefix}_LIMIT`] || String(defaultLimit)); const windowSeconds = Number(env[`${prefix}_WINDOW_SECONDS`] || String(defaultWindowSeconds)); diff --git a/Workers/wrangler.room.toml b/Workers/wrangler.room.toml @@ -7,6 +7,7 @@ workers_dev = true ROOM_TTL_SECONDS = "600" NONCE_TTL_SECONDS = "300" MAX_AUTH_SKEW_SECONDS = "120" +ROOM_MAX_FRAME_BYTES = "524288" ROOM_REGISTER_IP_LIMIT = "60" ROOM_REGISTER_IP_WINDOW_SECONDS = "3600"