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:
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()