0dd507815d8b
enforce sync structure limits on every decoded frame and stop preallocating 24 MiB per frame (v2.14.82)
- time
- 2026-09-01 00:19 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- resolve independent audit findings
- parent
- a9558300f7b0
↓ peach-android-0dd50781.tar.gzatlas at this save
files changed6 files
VERSION+1 −1app/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+1 −1app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+16 −6app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+12 −0app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+34 −0app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+81 −0VERSION+1 −1
@@ -1 +1 @@1-2.14.81\ No newline at end of file1+2.14.82\ No newline at end of fileapp/src/main/java/com/peachpasswords/sync/SyncCoordinator.kt+1 −1
@@ -332,7 +332,7 @@332332 PeachSyncLog.i("sync-stage: role=$role wait=$expected")333333 val bytes = receive()334334 requireSyncFrameSize(bytes.size)335- val msg = decodeSyncMessage(bytes)335+ val msg = decodeSyncMessage(bytes).also { it.requireBounded() }336336 return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")337337 }338338 app/src/main/java/com/peachpasswords/sync/SyncFrameTransport.kt+16 −6
@@ -41,6 +41,9 @@4141 */4242 const val FRAME_CONTINUATION_MARKER: Byte = 0x014343 44+/** One Noise plaintext, rounded up — every control frame fits in a single chunk. */45+private const val INITIAL_FRAME_BUFFER_BYTES: Int = 1 shl 1646+4447 /**4548 * Pad [plaintext], optionally chunk it across the Noise plaintext limit,4649 * encrypt each chunk, and hand each ciphertext to [sendCiphertext].@@ -103,12 +106,15 @@103106 receiveCiphertext: suspend () -> ByteArray,104107 decrypt: (ByteArray) -> ByteArray,105108 ): ByteArray {106- // Audit H-09: pre-allocated buffer (capped at MAX_SYNC_FRAME_BYTES) avoids107- // the O(n²) reallocation the old growing-array path suffered — one108- // allocation regardless of how many chunks the peer streams. Cumulative109- // size and chunk count are checked BEFORE writing so an authenticated peer110- // can't grow memory unboundedly or pin the loop on tiny marked chunks.111- val buffer = ByteArray(MAX_SYNC_FRAME_BYTES)109+ // The buffer doubles on demand up to MAX_SYNC_FRAME_BYTES rather than110+ // reserving that cap for every frame: most frames are small control111+ // messages, and a fixed 24 MiB allocation per frame is itself the112+ // availability problem (PAI-2026-003). Doubling keeps the old O(n²)113+ // copy cost away — at most ~18 copies to reach the cap. Cumulative size114+ // and chunk count are still checked BEFORE writing, so an authenticated115+ // peer can't grow memory unboundedly or pin the loop on tiny marked116+ // chunks.117+ var buffer = ByteArray(INITIAL_FRAME_BUFFER_BYTES)112118 var size = 0113119 var chunkCount = 0114120 var chunkedFrame = false@@ -130,6 +136,10 @@130136 throw SyncFrameTooLargeException(131137 "sync frame chunk count exceeded $MAX_SYNC_FRAME_BYTES",132138 )139+ }140+ if (size + dataSize > buffer.size) {141+ val grown = (buffer.size * 2).coerceAtMost(MAX_SYNC_FRAME_BYTES)142+ buffer = buffer.copyOf(maxOf(grown, size + dataSize))133143 }134144 if (!chunkedFrame) {135145 // First chunk. Marker ⇒ chunked frame; no marker ⇒ the entireapp/src/main/java/com/peachpasswords/sync/SyncLimits.kt+12 −0
@@ -12,6 +12,12 @@1212 message: String = "sync frame too large (WebSocket close 1009)",1313 ) : IOException(message)1414 15+/**16+ * A decoded frame whose declared structure exceeds the protocol's limits. An17+ * IOException so it lands in the same ERRORED outcome as a transport failure.18+ */19+class SyncStructureTooLargeException(what: String) : IOException("sync structure exceeds limits: $what")20+1521 fun requireSyncFrameSize(size: Int) {1622 if (size > MAX_SYNC_FRAME_BYTES) {1723 throw SyncFrameTooLargeException("sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes")@@ -25,6 +31,12 @@2531 object SyncLimits {2632 const val MAX_ENTRY_COUNT = 100_0002733 const val MAX_TOMBSTONE_COUNT = 100_00034+ /**35+ * Entry and device identifiers are UUID strings (36 chars). The cap exists36+ * so a peer cannot pad a map with multi-kilobyte keys — the count limits37+ * alone leave the cost of any single key unbounded.38+ */39+ const val MAX_ID_LENGTH = 1282840 const val MAX_ENTRY_PLAINTEXT_BYTES = 512 * 10242941 const val MAX_ENTRY_ENVELOPE_BYTES = (MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 643042 /**app/src/main/java/com/peachpasswords/sync/SyncMessage.kt+34 −0
@@ -132,3 +132,37 @@132132 133133 fun decodeSyncMessage(bytes: ByteArray): SyncMessage =134134 SyncJson.decodeFromString(SyncMessage.serializer(), String(bytes, Charsets.UTF_8))135+136+/**137+ * Reject a decoded frame whose declared structure exceeds the protocol's138+ * limits, before any diff or persistence work touches it (PAI-2026-003).139+ *140+ * [requireSyncFrameSize] bounds a frame's bytes, not its shape: a 24 MiB frame141+ * of one-character keys carries millions of map entries, and the String and142+ * Map allocation for those — plus the diff and apply passes that follow —143+ * dwarfs the frame itself. LockedSyncSpoolStore already enforced these same144+ * bounds on the locked spool; the unlocked coordinator had none.145+ */146+fun SyncMessage.requireBounded() {147+ fun checkIds(ids: Collection<String>, cap: Int, what: String) {148+ if (ids.size > cap) throw SyncStructureTooLargeException(what)149+ if (ids.any { it.length > SyncLimits.MAX_ID_LENGTH }) {150+ throw SyncStructureTooLargeException("$what identifier length")151+ }152+ }153+ when (this) {154+ is SyncMessage.Manifest -> {155+ checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "manifest entries")156+ checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "manifest tombstones")157+ }158+ is SyncMessage.Request -> checkIds(entryIds, SyncLimits.MAX_ENTRY_COUNT, "request ids")159+ is SyncMessage.Entries -> {160+ checkIds(entries.keys, SyncLimits.MAX_ENTRY_COUNT, "entries")161+ if (entries.values.any { it.length > SyncLimits.MAX_ENTRY_ENVELOPE_BYTES }) {162+ throw SyncStructureTooLargeException("entry envelope")163+ }164+ }165+ is SyncMessage.Tombstones -> checkIds(tombstones.keys, SyncLimits.MAX_TOMBSTONE_COUNT, "tombstones")166+ else -> Unit167+ }168+}app/src/test/java/com/peachpasswords/sync/SyncCoordinatorTest.kt+81 −0
@@ -332,4 +332,85 @@332332 // The data still crossed: b got x.333333 assertTrue(a.blobs.containsKey("y"))334334 }335+336+ /**337+ * PAI-2026-003: the 24 MiB byte cap bounds a frame's size, not the shape338+ * inside it. Each case below is a well-formed, small frame that a paired339+ * peer can send, and each must be refused before it reaches the diff or340+ * the vault rather than being diffed and applied.341+ */342+ private fun scriptedResponder(vararg frames: SyncMessage): SyncOutcome = runBlocking {343+ val queue = ArrayDeque(frames.map { it.encodeToBytes() })344+ val session = SyncSession(345+ remoteStaticPublicKey = ByteArray(32),346+ send = { },347+ receive = { queue.removeFirstOrNull() ?: throw java.io.IOException("WebSocket closed") },348+ close = { },349+ )350+ runSyncResponder(session, FakeAdapter("B", version = 2), nowMs = 0L)351+ }352+353+ private fun hello() = SyncMessage.Hello("VAULT", "A", version = 1, deviceTime = 0L)354+355+ @Test fun oversized_manifest_is_refused() {356+ val over = (0..SyncLimits.MAX_ENTRY_COUNT).associate { "id-$it" to 1L }357+ val out = scriptedResponder(hello(), SyncMessage.Manifest(over, emptyMap()))358+ assertEquals(SyncOutcomeStatus.ERRORED, out.status)359+ assertTrue(out.error!!.contains("sync structure exceeds limits"))360+ }361+362+ @Test fun oversized_tombstone_map_is_refused() {363+ val over = (0..SyncLimits.MAX_TOMBSTONE_COUNT).associate { "id-$it" to 1L }364+ val out = scriptedResponder(365+ hello(),366+ SyncMessage.Manifest(emptyMap(), emptyMap()),367+ SyncMessage.Request(emptyList()),368+ SyncMessage.Entries(emptyMap()),369+ SyncMessage.Tombstones(over),370+ )371+ assertEquals(SyncOutcomeStatus.ERRORED, out.status)372+ assertTrue(out.error!!.contains("tombstones"))373+ }374+375+ @Test fun oversized_request_is_refused() {376+ val over = (0..SyncLimits.MAX_ENTRY_COUNT).map { "id-$it" }377+ val out = scriptedResponder(378+ hello(),379+ SyncMessage.Manifest(emptyMap(), emptyMap()),380+ SyncMessage.Request(over),381+ )382+ assertEquals(SyncOutcomeStatus.ERRORED, out.status)383+ assertTrue(out.error!!.contains("request ids"))384+ }385+386+ @Test fun oversized_entry_envelope_is_refused() {387+ val out = scriptedResponder(388+ hello(),389+ SyncMessage.Manifest(mapOf("x" to 1L), emptyMap()),390+ SyncMessage.Request(emptyList()),391+ SyncMessage.Entries(mapOf("x" to "A".repeat(SyncLimits.MAX_ENTRY_ENVELOPE_BYTES + 1))),392+ )393+ assertEquals(SyncOutcomeStatus.ERRORED, out.status)394+ assertTrue(out.error!!.contains("entry envelope"))395+ }396+397+ @Test fun oversized_identifier_is_refused() {398+ val long = "i".repeat(SyncLimits.MAX_ID_LENGTH + 1)399+ val out = scriptedResponder(hello(), SyncMessage.Manifest(mapOf(long to 1L), emptyMap()))400+ assertEquals(SyncOutcomeStatus.ERRORED, out.status)401+ assertTrue(out.error!!.contains("identifier length"))402+ }403+404+ @Test fun a_peer_at_the_count_limit_still_syncs() = runBlocking {405+ val (sa, sb) = duplex()406+ val a = FakeAdapter("A", version = 1)407+ val b = FakeAdapter("B", version = 2).apply {408+ repeat(SyncLimits.MAX_ENTRY_COUNT) { put("id-$it", 1L) }409+ }410+ val ra = async { runSyncInitiator(sa, a, nowMs = 0) }411+ val rb = async { runSyncResponder(sb, b, nowMs = 0) }412+ assertEquals(SyncOutcomeStatus.COMPLETED, ra.await().status)413+ assertEquals(SyncOutcomeStatus.COMPLETED, rb.await().status)414+ assertEquals(SyncLimits.MAX_ENTRY_COUNT, a.blobs.size)415+ }335416 }