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
↓ peach-android-c6504350.tar.gzatlas at this save
files changed6 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 −0app/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 {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 }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")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). */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+}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 }