crossmate

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

commit 6634e575133a1cefe331300c3ffa01a66e394b1d
parent 07aae65cd596b8a66a4d16e05c1f805305f95764
Author: Michael Camilleri <[email protected]>
Date:   Sat, 18 Jul 2026 07:52:16 +0900

Bound replay and archive asset ingress

A co-player controls the Journal and Archive assets a device downloads,
and both paths read them into memory unchecked: fetchReplay loaded each
device's entries blob in full before decoding, and Archive.payload read
the puzzle, cells, and journals assets with no file-size gate. Unlike
the Game puzzleSource asset — which is size-gated on disk before it is
read — a hostile finished-game replay or archive could force an
arbitrarily large read, decode, and allocation.

This commit gates every one of those reads. A new
RecordSerializer.boundedAssetData helper checks a CKAsset staging file's
on-disk size before Data(contentsOf:), failing closed when the size is
unavailable, and both ingress paths read through it. JournalCodec.decode
now refuses a blob over 4 MiB or 20,000 entries — each entry is a single
keystroke, so the caps sit far beyond any real solve — and rejects the
blob whole rather than truncating it, since a partial journal would
silently corrupt the merged replay timeline. A journal rejected during
replay is simply absent, so the existing completeness gate keeps replay
unavailable instead of playing a partial history.

Archive assets carry equivalent bounds derived from the XD grid limits:
byte caps on the cells and journals assets, the puzzleSource cap the
Game record already uses, and count caps on decoded cells and per-device
journals. A rejected asset degrades to the same empty default as a
missing one — materialize already refuses an empty puzzleSource, so a
hostile archive fails closed while a legitimate record with one bad
asset fails soft, and one device's over-limit journal degrades to an
empty log without sinking the rest of the archive. Rejections are
reported through a new onDiagnostic hook on Archive.payload, threaded
from both sync appliers into the batch traces so they reach the
on-device diagnostics log.

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

Diffstat:
MCrossmate/Persistence/Journal.swift | 39+++++++++++++++++++++++++++++++++++++++
MCrossmate/Sync/Archive.swift | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
MCrossmate/Sync/CloudQuery.swift | 11++++++++++-
MCrossmate/Sync/RecordApplier.swift | 12+++++++++---
MCrossmate/Sync/RecordSerializer.swift | 32++++++++++++++++++++++++++++++++
MCrossmate/Sync/SyncEngine.swift | 6+++++-
MTests/Unit/ArchiveTests.swift | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
MTests/Unit/JournalUploadTests.swift | 30++++++++++++++++++++++++++++++
MTests/Unit/RecordSerializerTests.swift | 22++++++++++++++++++++++
9 files changed, 367 insertions(+), 19 deletions(-)

diff --git a/Crossmate/Persistence/Journal.swift b/Crossmate/Persistence/Journal.swift @@ -515,6 +515,39 @@ final class MovesJournal { /// dump by `timestamp` reconstructs the whole game for replay. The mark is /// carried as the single lossless `markCode` (`CellMark.code`). enum JournalCodec { + /// Upper bound on one device's encoded `entries` blob. Each entry is a + /// single keystroke at ~200 bytes of JSON, so 4 MiB is over 20,000 moves — + /// far beyond any real solve — while keeping a peer-controlled asset from + /// forcing an arbitrarily large read and decode. Callers that read the + /// blob from a `CKAsset` file must also gate on the on-disk size *before* + /// loading it (`RecordSerializer.boundedAssetData`); this bound caps the + /// decode work itself. + static let maxAssetBytes = 4_194_304 + + /// Upper bound on decoded entries per device journal. Redundant with the + /// byte cap for honest JSON, but bounds the `JournalValue` allocation and + /// every later per-entry merge/replay pass independently of encoding + /// tricks. + static let maxEntryCount = 20_000 + + /// A journal blob that exceeds the decode bounds. The whole blob is + /// rejected (not truncated): a partial journal would silently corrupt the + /// merged replay timeline, and the caller already treats a missing device + /// journal as "replay unavailable". + enum LimitError: Error, CustomStringConvertible { + case oversized(bytes: Int) + case tooManyEntries(count: Int) + + var description: String { + switch self { + case .oversized(let bytes): + return "journal blob exceeds \(maxAssetBytes) bytes (\(bytes))" + case .tooManyEntries(let count): + return "journal blob exceeds \(maxEntryCount) entries (\(count))" + } + } + } + struct Payload: Codable, Equatable { struct Entry: Codable, Equatable { let seq: Int64 @@ -608,7 +641,13 @@ enum JournalCodec { } static func decode(_ data: Data) throws -> [JournalValue] { + guard data.count <= maxAssetBytes else { + throw LimitError.oversized(bytes: data.count) + } let payload = try JSONDecoder().decode(Payload.self, from: data) + guard payload.entries.count <= maxEntryCount else { + throw LimitError.tooManyEntries(count: payload.entries.count) + } return payload.entries.compactMap { entry in let position = GridPosition(row: entry.row, col: entry.col) // A remote journal's coordinates are attacker-controlled; entries diff --git a/Crossmate/Sync/Archive.swift b/Crossmate/Sync/Archive.swift @@ -26,6 +26,29 @@ import Foundation enum Archive { static let recordType = "Archive" + // MARK: - Inbound asset bounds + + /// Byte cap on the `cells` asset, checked on disk before it is read. The + /// largest admissible grid (`XD.maxGridDimension`²) at ~120 bytes of JSON + /// per cell is under 2 MiB; real puzzles are a few kilobytes. + static let maxCellsAssetBytes = 2_097_152 + + /// Byte cap on the merged `journals` asset, checked on disk before it is + /// read. Wraps per-device `JournalCodec` blobs (each independently capped + /// at `JournalCodec.maxAssetBytes`) in base64; a real finished game's + /// merged log is a few hundred kilobytes, so 8 MiB rejects nothing + /// genuine. + static let maxJournalsAssetBytes = 8_388_608 + + /// Upper bound on decoded final-grid cells; `XD.maxGridDimension`² is the + /// largest cell count any admissible puzzle can produce. + static let maxCellCount = XD.maxGridDimension * XD.maxGridDimension + + /// Upper bound on per-device journals in one archive. Every participant + /// device that wrote grid state contributes one; real games have a + /// handful. + static let maxJournalDeviceCount = 64 + /// Namespace for deriving the archive's game id. A fixed random UUID used as /// the v5 namespace so `archiveGameID(for:)` is stable across the /// participant's own devices yet distinct from the original game id. @@ -96,8 +119,29 @@ enum Archive { }) } + /// A decoded archive asset that exceeds its entry-count bound. The asset + /// is rejected whole — a truncated grid or journal set would materialize a + /// silently incomplete game. + enum LimitError: Error, CustomStringConvertible { + case tooManyCells(count: Int) + case tooManyDeviceJournals(count: Int) + + var description: String { + switch self { + case .tooManyCells(let count): + return "cells asset exceeds \(maxCellCount) cells (\(count))" + case .tooManyDeviceJournals(let count): + return "journals asset exceeds \(maxJournalDeviceCount) device journals (\(count))" + } + } + } + private static func decodeCells(_ data: Data) throws -> [Cell] { - try JSONDecoder().decode([Cell].self, from: data) + let cells = try JSONDecoder().decode([Cell].self, from: data) + guard cells.count <= maxCellCount else { + throw LimitError.tooManyCells(count: cells.count) + } + return cells } // MARK: - Per-device journal wire format @@ -125,9 +169,16 @@ enum Archive { } private static func decodeJournals(_ data: Data) throws -> [DeviceJournal] { - try JSONDecoder().decode([DeviceJournalWire].self, from: data).map { + let wire = try JSONDecoder().decode([DeviceJournalWire].self, from: data) + guard wire.count <= maxJournalDeviceCount else { + throw LimitError.tooManyDeviceJournals(count: wire.count) + } + return wire.map { DeviceJournal( key: JournalDeviceKey(authorID: $0.authorID, deviceID: $0.deviceID), + // `JournalCodec.decode` enforces its own byte/entry bounds, so + // one device's over-limit blob degrades to an empty log rather + // than sinking the whole archive. entries: (try? JournalCodec.decode($0.entries)) ?? [] ) } @@ -347,7 +398,10 @@ enum Archive { ) } - static func payload(from record: CKRecord) -> Payload? { + static func payload( + from record: CKRecord, + onDiagnostic: ((String) -> Void)? = nil + ) -> Payload? { guard record.recordType == recordType, let originalString = record["originalGameID"] as? String, let originalGameID = UUID(uuidString: originalString), @@ -356,17 +410,35 @@ enum Archive { let completedAt = record["completedAt"] as? Date else { return nil } - let puzzleSource = (record["puzzleSource"] as? CKAsset) - .flatMap { $0.fileURL } - .flatMap { try? String(contentsOf: $0, encoding: .utf8) } ?? "" - let cells = (record["cells"] as? CKAsset) - .flatMap { $0.fileURL } - .flatMap { try? Data(contentsOf: $0) } - .flatMap { try? decodeCells($0) } ?? [] - let journal = (record["journals"] as? CKAsset) - .flatMap { $0.fileURL } - .flatMap { try? Data(contentsOf: $0) } - .flatMap { try? decodeJournals($0) } ?? [] + // Each asset is size-gated on disk before it is read, then count-gated + // on decode. A rejected asset degrades to the same empty default as a + // missing one: `materialize` refuses an empty `puzzleSource`, so a + // hostile blob can't smuggle an unbounded read in through the archive + // path, while a legitimate record with one bad asset still fails soft. + func decoded<T>( + _ key: String, + limit: Int, + _ decode: (Data) throws -> T + ) -> T? { + guard let asset = record[key] as? CKAsset, let url = asset.fileURL + else { return nil } + do { + let data = try RecordSerializer.boundedAssetData(at: url, limit: limit) + return try decode(data) + } catch { + onDiagnostic?( + "archive \(key) rejected for " + + "\(record.recordID.recordName): \(error)" + ) + return nil + } + } + + let puzzleSource = decoded("puzzleSource", limit: XD.maxSourceBytes) { + String(data: $0, encoding: .utf8) ?? "" + } ?? "" + let cells = decoded("cells", limit: maxCellsAssetBytes, decodeCells) ?? [] + let journal = decoded("journals", limit: maxJournalsAssetBytes, decodeJournals) ?? [] return Payload( originalGameID: originalGameID, diff --git a/Crossmate/Sync/CloudQuery.swift b/Crossmate/Sync/CloudQuery.swift @@ -1113,7 +1113,16 @@ extension SyncEngine { let url = asset.fileURL else { continue } do { - let entries = try JournalCodec.decode(Data(contentsOf: url)) + // A peer controls this asset; gate its on-disk size before + // reading, then let the codec's own entry-count bound apply. + // A rejected journal is simply absent, so the completeness + // gate (`expectedDevices`) keeps replay unavailable rather + // than replaying a partial timeline. + let data = try RecordSerializer.boundedAssetData( + at: url, + limit: JournalCodec.maxAssetBytes + ) + let entries = try JournalCodec.decode(data) journals.append( DeviceJournal( key: JournalDeviceKey(authorID: authorID, deviceID: deviceID), diff --git a/Crossmate/Sync/RecordApplier.swift b/Crossmate/Sync/RecordApplier.swift @@ -146,7 +146,11 @@ extension SyncEngine { effects.rosterRelevant.insert(gameID) } case Archive.recordType: - if let id = self.applyArchiveRecord(record, in: ctx) { + if let id = self.applyArchiveRecord( + record, + in: ctx, + onDiagnostic: { effects.traces.append($0) } + ) { effects.rosterRelevant.insert(id) effects.visibilityCandidateGameIDs.insert(id) } @@ -634,9 +638,11 @@ extension SyncEngine { @discardableResult nonisolated func applyArchiveRecord( _ record: CKRecord, - in ctx: NSManagedObjectContext + in ctx: NSManagedObjectContext, + onDiagnostic: ((String) -> Void)? = nil ) -> UUID? { - guard let payload = Archive.payload(from: record) else { return nil } + guard let payload = Archive.payload(from: record, onDiagnostic: onDiagnostic) + else { return nil } let liveReq = NSFetchRequest<GameEntity>(entityName: "GameEntity") liveReq.predicate = NSPredicate( diff --git a/Crossmate/Sync/RecordSerializer.swift b/Crossmate/Sync/RecordSerializer.swift @@ -940,6 +940,38 @@ enum RecordSerializer { return (gameID, authorID, deviceID) } + // MARK: - Bounded asset reads + + /// A `CKAsset` staging file rejected before it was read into memory. + enum AssetReadError: Error, CustomStringConvertible { + case oversized(bytes: Int, limit: Int) + case unknownSize + + var description: String { + switch self { + case .oversized(let bytes, let limit): + return "asset exceeds \(limit) bytes (\(bytes))" + case .unknownSize: + return "asset file size unavailable" + } + } + } + + /// Reads an untrusted `CKAsset` staging file only after its on-disk size + /// passes `limit`. A co-player controls the asset's content, and an asset + /// is an external file that can dwarf the ~1 MB record limit — so never + /// `Data(contentsOf:)` one unchecked. Fails closed when the size can't be + /// determined. + static func boundedAssetData(at url: URL, limit: Int) throws -> Data { + guard let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize else { + throw AssetReadError.unknownSize + } + guard size <= limit else { + throw AssetReadError.oversized(bytes: size, limit: limit) + } + return try Data(contentsOf: url) + } + // MARK: - Applying incoming CKRecords to Core Data /// Returns the `GameEntity` for `gameID`, creating an unpopulated stub if diff --git a/Crossmate/Sync/SyncEngine.swift b/Crossmate/Sync/SyncEngine.swift @@ -1637,7 +1637,11 @@ actor SyncEngine { // private DB. Inert where the live original still exists; // hydrated into a standalone completed game on a device that // lacks it (fresh install / after the original was revoked). - if let id = self.applyArchiveRecord(record, in: ctx) { + if let id = self.applyArchiveRecord( + record, + in: ctx, + onDiagnostic: { effects.traces.append($0) } + ) { effects.rosterRelevant.insert(id) effects.visibilityCandidateGameIDs.insert(id) } diff --git a/Tests/Unit/ArchiveTests.swift b/Tests/Unit/ArchiveTests.swift @@ -177,6 +177,140 @@ struct ArchiveTests { #expect(Set(assetURLs.compactMap { $0 }) == Set(package.temporaryAssetFileURLs)) } + // MARK: - Inbound asset bounds + + /// Mirrors the private `DeviceJournalWire` shape so tests can hand-build + /// hostile `journals` asset payloads. + private struct WireJournal: Codable { + let authorID: String + let deviceID: String + let entries: Data + } + + /// Builds a valid archive record, then replaces the asset at `key` with a + /// file containing `data`, and runs `body` over the resulting payload and + /// any diagnostics `Archive.payload` reported. + private func withTamperedPayload<T>( + key: String, + data: Data, + _ body: (Archive.Payload, [String]) throws -> T + ) throws -> T { + let package = try Archive.recordPackage(from: sampleSnapshot(originalGameID: UUID())) + let tampered = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try data.write(to: tampered) + defer { + for url in package.temporaryAssetFileURLs + [tampered] { + try? FileManager.default.removeItem(at: url) + } + } + package.record[key] = CKAsset(fileURL: tampered) + var diagnostics: [String] = [] + let payload = try #require( + Archive.payload(from: package.record, onDiagnostic: { diagnostics.append($0) }) + ) + return try body(payload, diagnostics) + } + + @Test("an oversized puzzleSource asset is rejected before reading, failing materialization closed") + func oversizedPuzzleSourceAssetRejected() throws { + try withTamperedPayload( + key: "puzzleSource", + data: Data(count: XD.maxSourceBytes + 1) + ) { payload, diagnostics in + #expect(payload.puzzleSource.isEmpty) + #expect(diagnostics.count == 1) + #expect(diagnostics.first?.contains("puzzleSource") == true) + #expect(diagnostics.first?.contains("exceeds") == true) + // An empty puzzleSource is exactly what materialize refuses. + let persistence = makeTestPersistence() + #expect(Archive.materialize(payload, in: persistence.viewContext) == nil) + } + } + + @Test("an oversized cells asset is rejected before reading") + func oversizedCellsAssetRejected() throws { + try withTamperedPayload( + key: "cells", + data: Data(count: Archive.maxCellsAssetBytes + 1) + ) { payload, diagnostics in + #expect(payload.cells.isEmpty) + #expect(diagnostics.first?.contains("cells") == true) + #expect(diagnostics.first?.contains("exceeds") == true) + // The rest of the payload still lands. + #expect(!payload.puzzleSource.isEmpty) + #expect(!payload.journal.isEmpty) + } + } + + @Test("an oversized journals asset is rejected before reading") + func oversizedJournalsAssetRejected() throws { + try withTamperedPayload( + key: "journals", + data: Data(count: Archive.maxJournalsAssetBytes + 1) + ) { payload, diagnostics in + #expect(payload.journal.isEmpty) + #expect(diagnostics.first?.contains("journals") == true) + #expect(diagnostics.first?.contains("exceeds") == true) + } + } + + @Test("a cells asset over the cell-count cap is rejected whole") + func cellCountExhaustionRejected() throws { + let cells = (0...Archive.maxCellCount).map { + Archive.Cell( + row: Int16($0 % 128), col: Int16($0 / 128 % 128), + letter: "A", markCode: 0, letterAuthorID: nil + ) + } + let data = try JSONEncoder().encode(cells) + // Under the byte cap, so the count gate is what fires. + #expect(data.count <= Archive.maxCellsAssetBytes) + try withTamperedPayload(key: "cells", data: data) { payload, diagnostics in + #expect(payload.cells.isEmpty) + #expect(diagnostics.first?.contains("cells") == true) + } + } + + @Test("a journals asset over the device-count cap is rejected whole") + func journalDeviceCountExhaustionRejected() throws { + let empty = try JournalCodec.encode([]) + let wire = (0...Archive.maxJournalDeviceCount).map { + WireJournal(authorID: "author\($0)", deviceID: "device", entries: empty) + } + let data = try JSONEncoder().encode(wire) + #expect(data.count <= Archive.maxJournalsAssetBytes) + try withTamperedPayload(key: "journals", data: data) { payload, diagnostics in + #expect(payload.journal.isEmpty) + #expect(diagnostics.first?.contains("journals") == true) + } + } + + @Test("one device's over-limit journal blob degrades to an empty log without sinking the archive") + func perDeviceJournalOverLimitDegradesEmpty() throws { + let hostile = (0...JournalCodec.maxEntryCount).map { + journalValue(seq: Int64($0), row: 0, col: 0, letter: "A", actingAuthorID: "alice") + } + let wire = [ + WireJournal( + authorID: "alice", deviceID: "deviceA", + entries: try JournalCodec.encode(hostile) + ), + WireJournal( + authorID: "bob", deviceID: "deviceB", + entries: try JournalCodec.encode([ + journalValue(seq: 0, row: 0, col: 1, letter: "B", actingAuthorID: "bob"), + ]) + ), + ] + let data = try JSONEncoder().encode(wire) + try withTamperedPayload(key: "journals", data: data) { payload, _ in + let journals = normalized(payload.journal) + #expect(journals[aliceKey]?.isEmpty == true) + #expect(journals[bobKey]?.count == 1) + } + } + // MARK: - Convergence merge @Test("merging unions peer devices and keeps the local copy of shared keys") diff --git a/Tests/Unit/JournalUploadTests.swift b/Tests/Unit/JournalUploadTests.swift @@ -98,6 +98,36 @@ struct JournalCodecTests { #expect(entry.prevSeqAtCell == nil) #expect(entry.direction == nil) } + + @Test("decode rejects a blob over the byte cap before parsing") + func decodeRejectsOversizedBlob() { + let oversized = Data(count: JournalCodec.maxAssetBytes + 1) + #expect(throws: JournalCodec.LimitError.self) { + _ = try JournalCodec.decode(oversized) + } + } + + @Test("decode rejects a blob over the entry-count cap") + func decodeRejectsTooManyEntries() throws { + let values = (0...JournalCodec.maxEntryCount).map { + value(seq: Int64($0), row: 0, col: 0, letter: "A", mark: .none, kind: .input) + } + let data = try JournalCodec.encode(values) + // Stays under the byte cap so the entry-count gate is what fires. + #expect(data.count <= JournalCodec.maxAssetBytes) + #expect(throws: JournalCodec.LimitError.self) { + _ = try JournalCodec.decode(data) + } + } + + @Test("decode accepts a blob at the entry-count cap") + func decodeAcceptsEntryCountAtCap() throws { + let values = (0..<JournalCodec.maxEntryCount).map { + value(seq: Int64($0), row: 0, col: 0, letter: "A", mark: .none, kind: .input) + } + let decoded = try JournalCodec.decode(try JournalCodec.encode(values)) + #expect(decoded.count == JournalCodec.maxEntryCount) + } } // MARK: - Record naming + building diff --git a/Tests/Unit/RecordSerializerTests.swift b/Tests/Unit/RecordSerializerTests.swift @@ -586,6 +586,28 @@ struct RecordSerializerTests { #expect(diagnostics.first?.contains("exceeds") == true) } + @Test("boundedAssetData reads a file at the limit and rejects one over it") + func boundedAssetDataEnforcesLimit() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: url) } + + try Data(count: 16).write(to: url) + #expect(try RecordSerializer.boundedAssetData(at: url, limit: 16).count == 16) + #expect(throws: RecordSerializer.AssetReadError.self) { + _ = try RecordSerializer.boundedAssetData(at: url, limit: 15) + } + } + + @Test("boundedAssetData fails closed on a missing file") + func boundedAssetDataFailsClosedOnMissingFile() { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + #expect(throws: (any Error).self) { + _ = try RecordSerializer.boundedAssetData(at: url, limit: 16) + } + } + @Test("applyGameRecord reports a failed puzzleSource asset read via onDiagnostic") @MainActor func applyGameRecordReportsFailedAssetRead() throws { let persistence = makeTestPersistence()