96e60825c044

fix: cap CodexCodec.inflate at 16 MiB against decompression bombs (v2.10.18)

Security scan finding F4 (low), slice 3 of the verified-fix queue. CodexCodec.inflate grew its output buffer without bound from attacker-controlled code data. Now capped at MAX_INFLATED_BYTES = 16 MiB — the same cap PeachPayloadCodec applies to PeachScript sections, since a codex payload is the same vault JSON. Violations throw CodexException(PAYLOAD_TOO_LARGE) with a clear message (new CodexErrorCode value). Tests: exact-cap round-trip passes; cap+1 bomb throws PAYLOAD_TOO_LARGE.

time
2026-08-03 23:45 UTC · 2 months ago
machine
koh2
parent
6eb341d43801
4 files VERSION+1 −1app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+14 −0app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+1 −0app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+17 −0
VERSION+1 −1
@@ -1 +1 @@1-2.10.171+2.10.18

view file

app/src/main/java/com/peachpasswords/codex/CodexCodec.kt+14 −0
@@ -18,6 +18,13 @@1818     private val urlEncoder = Base64.getUrlEncoder().withoutPadding()1919     private val urlDecoder = Base64.getUrlDecoder()2020 21+    /**22+     * F4: decompression bomb guard — mirrors PeachPayloadCodec's23+     * MAX_SECTION_RAW_BYTES (16 MiB). A codex payload is the same vault JSON24+     * the PeachScript sections carry, so the cap is identical by definition.25+     */26+    const val MAX_INFLATED_BYTES = 16L * 1024 * 102427+2128     fun base64Url(bytes: ByteArray): String = urlEncoder.encodeToString(bytes)2229     fun fromBase64Url(s: String): ByteArray = urlDecoder.decode(s)2330 @@ -46,6 +53,13 @@4653             val n = inflater.inflate(buf)4754             if (n == 0 && inflater.needsInput()) break // trailing zero-pad reached4855             out.write(buf, 0, n)56+            if (out.size() > MAX_INFLATED_BYTES) {57+                inflater.end()58+                throw CodexException(59+                    CodexErrorCode.PAYLOAD_TOO_LARGE,60+                    "Inflated payload exceeds the ${MAX_INFLATED_BYTES / (1024 * 1024)} MiB cap",61+                )62+            }4963         }5064         inflater.end()5165         return out.toByteArray()

view file

app/src/main/java/com/peachpasswords/codex/CodexTypes.kt+1 −0
@@ -86,6 +86,7 @@8686     VAULT_LOCKED, VAULT_EMPTY, COMPRESSION_ROUNDTRIP_FAILED, ERASURE_VERIFICATION_FAILED,8787     AZTEC_GENERATION_FAILED, INSUFFICIENT_CHUNKS, RECONSTRUCTION_VERIFY_CODE_MISMATCH,8888     RECONSTRUCTION_FINGERPRINT_MISMATCH, PDF_GENERATION_FAILED, INVALID_VAULT_BLOB, RESTORE_FAILED,89+    PAYLOAD_TOO_LARGE,8990 }9091 9192 class CodexException(val code: CodexErrorCode, message: String) : Exception(message)

view file

app/src/test/java/com/peachpasswords/codex/CodexCodecTest.kt+17 −0
@@ -60,4 +60,21 @@6060         val tampered = envelopes.first().copy(d = CodexCodec.base64Url("tampered".toByteArray()))6161         assertTrue(!CodexCodec.verifyChunkIntegrity(tampered))6262     }63+64+    @Test fun inflate_exactly_at_the_cap_round_trips() {65+        val data = ByteArray(CodexCodec.MAX_INFLATED_BYTES.toInt()) // zeros compress to ~17 KB66+        assertArrayEquals(data, CodexCodec.inflate(CodexCodec.deflate(data)))67+    }68+69+    @Test fun inflate_refuses_decompression_bombs() {70+        val bomb = CodexCodec.deflate(ByteArray(CodexCodec.MAX_INFLATED_BYTES.toInt() + 1))71+        assertTrue("bomb compresses tiny", bomb.size < 64 * 1024)72+        val error = try {73+            CodexCodec.inflate(bomb)74+            null75+        } catch (e: CodexException) {76+            e77+        }78+        assertEquals(CodexErrorCode.PAYLOAD_TOO_LARGE, error?.code)79+    }6380 }

view file