c6504350b98c

fix: enforce relay-safe sync frame limits (v2.3.70)

time
2026-07-26 02:27 UTC · 3 months ago
machine
koh2
agent
gpt-5
session
peach relay sync implementation
parent
d0174f9b05d3
6 files app/build.gradle+2 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+8 −2app/src/main/java/com/peachpasswords/sync/ChirpSyncTransport.kt+14 −1app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+30 −3app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+19 −0app/src/test/java/com/peachpasswords/sync/PeachWebSocketTest.kt+26 −0
app/build.gradle+2 −2
@@ -16,8 +16,8 @@1616         applicationId "com.peachpasswords"1717         minSdk 261818         targetSdk 3519-        versionCode 23690020-        versionName "2.3.69"19+        versionCode 23700020+        versionName "2.3.70"2121 2222         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"2323         vectorDrawables {

view file

app/src/main/java/com/peachpasswords/sync/ChirpSyncCoordinator.kt+8 −2
@@ -277,9 +277,15 @@277277     }278278 }279279 280-private suspend fun ChirpSyncSession.sendMsg(msg: SyncMessage) = send(msg.encodeToBytes())280+private suspend fun ChirpSyncSession.sendMsg(msg: SyncMessage) {281+    val bytes = msg.encodeToBytes()282+    requireSyncFrameSize(bytes.size)283+    send(bytes)284+}281285 282286 private suspend inline fun <reified T : SyncMessage> ChirpSyncSession.recvAs(expected: String): T {283-    val msg = decodeSyncMessage(receive())287+    val bytes = receive()288+    requireSyncFrameSize(bytes.size)289+    val msg = decodeSyncMessage(bytes)284290     return msg as? T ?: throw IllegalStateException("expected $expected, got ${msg::class.simpleName}")285291 }

view file

app/src/main/java/com/peachpasswords/sync/ChirpSyncTransport.kt+14 −1
@@ -164,6 +164,15 @@164164             val connection = RawWebSocketConnection()165165             val listener = object : WebSocketListener() {166166                 override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) {167+                    if (bytes.size > MAX_SYNC_FRAME_BYTES) {168+                        webSocket.close(1009, "sync frame too large")169+                        connection.onClosed(170+                            SyncFrameTooLargeException(171+                                "sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes",172+                            ),173+                        )174+                        return175+                    }167176                     connection.onMessage(bytes)168177                 }169178 @@ -173,7 +182,10 @@173182                 }174183 175184                 override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {176-                    connection.onClosed(IOException("WebSocket closed"))185+                    connection.onClosed(186+                        if (code == 1009) SyncFrameTooLargeException()187+                        else IOException("WebSocket closed"),188+                    )177189                 }178190 179191                 override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {@@ -221,6 +233,7 @@221233     }222234 223235     fun send(bytes: ByteArray) {236+        requireSyncFrameSize(bytes.size)224237         val socket = ws ?: throw IOException("WebSocket not open")225238         if (!socket.send(bytes.toByteString())) {226239             throw IOException("WebSocket send failed")

view file

app/src/main/java/com/peachpasswords/sync/PeachWebSocket.kt+30 −3
@@ -61,7 +61,15 @@6161 6262     fun sendText(text: String) = writeFrame(0x1, text.toByteArray(Charsets.UTF_8))6363 64-    fun send(bytes: ByteArray) = writeFrame(0x2, bytes)64+    fun send(bytes: ByteArray) {65+        try {66+            requireSyncFrameSize(bytes.size)67+        } catch (error: SyncFrameTooLargeException) {68+            close(1009, "sync frame too large")69+            throw error70+        }71+        writeFrame(0x2, bytes)72+    }6573 6674     /** Blocks until a whole message arrives. [WsMessage.Closed] ends the conversation. */6775     fun receive(): WsMessage {@@ -70,7 +78,16 @@7078             if (b0 < 0) return WsMessage.Closed7179             val fin = (b0 and 0x80) != 07280             when (val opcode = b0 and 0x0f) {73-                0x8 -> return WsMessage.Closed81+                0x8 -> {82+                    val payload = readPayload()83+                    val code = if (payload.size >= 2) {84+                        ((payload[0].toInt() and 0xff) shl 8) or (payload[1].toInt() and 0xff)85+                    } else {86+                        null87+                    }88+                    if (code == 1009) throw SyncFrameTooLargeException()89+                    return WsMessage.Closed90+                }7491                 0x9 -> { writeFrame(0xA, readPayload()); continue } // ping → pong7592                 0xA -> { readPayload(); continue }                  // pong, ignored7693                 0x1, 0x2 -> {@@ -126,7 +143,10 @@126143         }127144         // A vault sync frame is large but bounded; refuse an absurd length128145         // rather than trying to allocate it.129-        require(len in 0..(16L * 1024 * 1024)) { "ws frame too large: $len" }146+        if (len !in 0..MAX_SYNC_FRAME_BYTES.toLong()) {147+            close(1009, "sync frame too large")148+            throw SyncFrameTooLargeException("sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes")149+        }130150 131151         val mask = if (masked) ByteArray(4) { readByte().toByte() } else null132152         val payload = ByteArray(len.toInt())@@ -146,6 +166,13 @@146166         val b = input.read()147167         if (b < 0) throw java.io.EOFException("websocket closed mid-frame")148168         return b169+    }170+171+    fun close(code: Int = 1000, reason: String = "") {172+        val reasonBytes = reason.toByteArray(Charsets.UTF_8).take(123).toByteArray()173+        val payload = byteArrayOf((code ushr 8).toByte(), code.toByte()) + reasonBytes174+        runCatching { writeFrame(0x8, payload) }175+        runCatching { socket.close() }149176     }150177 151178     /** Server-to-client frames are never masked (RFC 6455 §5.1). */

view file

app/src/main/java/com/peachpasswords/sync/SyncLimits.kt+19 −0
@@ -0,0 +1,19 @@1+package com.peachpasswords.sync2+3+import java.io.IOException4+5+/**6+ * Cloudflare Workers caps one WebSocket message at 32 MiB. Keep a generous7+ * margin for the Noise authentication tag and platform framing overhead.8+ */9+const val MAX_SYNC_FRAME_BYTES: Int = 24 * 1024 * 102410+11+class SyncFrameTooLargeException(12+    message: String = "sync frame too large (WebSocket close 1009)",13+) : IOException(message)14+15+fun requireSyncFrameSize(size: Int) {16+    if (size > MAX_SYNC_FRAME_BYTES) {17+        throw SyncFrameTooLargeException("sync frame exceeds $MAX_SYNC_FRAME_BYTES bytes")18+    }19+}

view file

app/src/test/java/com/peachpasswords/sync/PeachWebSocketTest.kt+26 −0
@@ -113,4 +113,30 @@113113             }114114         }115115     }116+117+    @Test fun `close code 1009 surfaces a distinct frame too large error`() {118+        ServerSocket(0).use { server ->119+            val surfaced = CompletableFuture<Boolean>()120+            thread {121+                val client = server.accept()122+                val ws = WebSocketConnection(client, client.getInputStream(), client.getOutputStream())123+                if (!ws.accept()) {124+                    surfaced.complete(false)125+                    return@thread126+                }127+                surfaced.complete(128+                    runCatching { ws.receive() }.exceptionOrNull() is SyncFrameTooLargeException,129+                )130+            }131+132+            val ws = connect(server.localPort, Received())133+            assertTrue(ws.close(1009, "sync frame too large"))134+            assertTrue(surfaced.get(5, TimeUnit.SECONDS))135+        }136+    }137+138+    @Test fun `sync frame cap is 24 MiB`() {139+        assertEquals(24 * 1024 * 1024, MAX_SYNC_FRAME_BYTES)140+        assertTrue(runCatching { requireSyncFrameSize(MAX_SYNC_FRAME_BYTES + 1) }.isFailure)141+    }116142 }

view file