Align compressed payload send and receive bounds

This commit is contained in:
a1denvalu3 2026-07-24 23:47:27 +02:00
parent fc9abffe0c
commit 0d04653bb8
7 changed files with 59 additions and 43 deletions

View File

@ -183,6 +183,9 @@ object BinaryProtocol {
private const val SENDER_ID_SIZE = 8
private const val RECIPIENT_ID_SIZE = 8
private const val SIGNATURE_SIZE = 64
// Acquire before copying compressed input so queued decodes cannot each retain another
// near-limit payload while waiting for the inflater's single-flight lock.
private val compressedDecodeLock = Any()
object Flags {
const val HAS_RECIPIENT: UByte = 0x01u
@ -200,6 +203,15 @@ object BinaryProtocol {
fun encode(packet: BitchatPacket, padding: Boolean = true): ByteArray? {
try {
val maxPayloadLength = com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
if (packet.payload.size > maxPayloadLength) {
Log.w(
"BinaryProtocol",
"Cannot encode payload ${packet.payload.size} above receiver limit $maxPayloadLength"
)
return null
}
// Try to compress payload if beneficial
var payload = packet.payload
var originalPayloadSize: Int? = null
@ -466,9 +478,6 @@ object BinaryProtocol {
Log.w("BinaryProtocol", "Compressed payload has no deflate bytes")
return null
}
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
// Security check: Compression bomb protection
val ratio = originalSize.toDouble() / compressedSize.toDouble()
if (ratio > 50_000.0) {
@ -476,8 +485,13 @@ object BinaryProtocol {
return null
}
// Decompress
val expandedPayload = decompress(compressedPayload, originalSize) ?: return null
// Copy and inflate single-flight. Acquiring before the copy bounds both the
// compressed input copy and expanded output allocation across concurrent decodes.
val expandedPayload = synchronized(compressedDecodeLock) {
val compressedPayload = ByteArray(compressedSize)
buffer.get(compressedPayload)
decompress(compressedPayload, originalSize)
} ?: return null
if (expandedPayload.size != originalSize) {
Log.w(
"BinaryProtocol",

View File

@ -92,36 +92,41 @@ object CompressionUtil {
}
return synchronized(decompressionLock) {
// iOS COMPRESSION_ZLIB produces raw deflate format (no headers).
val rawResult = try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (e: Exception) {
Log.d(
"CompressionUtil",
"Raw deflate decompression failed: ${e.message}"
)
null
}
if (rawResult != null) {
rawResult
} else {
// Fallback after either a format error or an incomplete/wrong-sized raw stream:
// accept the zlib-wrapped form used by some older/mixed clients, but only when it
// independently satisfies the same exact-size and complete-stream checks.
if (looksLikeZlib(compressedData)) {
// A structurally valid zlib header selects the legacy wrapped format. Do not
// retry size/completion failures as raw deflate: that only doubles attacker work.
try {
inflateExact(compressedData, originalSize, nowrap = false)
} catch (fallbackException: Exception) {
Log.e(
"CompressionUtil",
"Both raw deflate and zlib decompression failed: ${fallbackException.message}"
)
} catch (zlibException: DataFormatException) {
// A raw stream can coincidentally begin with a valid-looking zlib header.
try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (rawException: DataFormatException) {
Log.d("CompressionUtil", "Invalid zlib/raw deflate stream")
null
}
}
} else {
try {
inflateExact(compressedData, originalSize, nowrap = true)
} catch (rawException: DataFormatException) {
Log.d("CompressionUtil", "Invalid raw deflate stream")
null
}
}
}
}
/** RFC 1950 header check used to avoid speculative double inflation. */
private fun looksLikeZlib(data: ByteArray): Boolean {
if (data.size < 2) return false
val cmf = data[0].toInt() and 0xFF
val flg = data[1].toInt() and 0xFF
return (cmf and 0x0F) == 8 &&
(cmf ushr 4) <= 7 &&
((cmf shl 8) or flg) % 31 == 0
}
/**
* Inflate one complete stream into exactly [originalSize] bytes.
*

View File

@ -23,7 +23,7 @@ class MediaSendingManager(
get() = getMeshService()
companion object {
private const val TAG = "MediaSendingManager"
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES // 50MB limit
private const val MAX_FILE_SIZE = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
}
// Track in-flight transfer progress: transferId -> messageId and reverse

View File

@ -129,7 +129,9 @@ object AppConstants {
}
object Media {
const val MAX_FILE_SIZE_BYTES: Long = 50L * 1024 * 1024
// A file is currently encoded into one protocol payload before BLE fragmentation.
// Reserve room for maximum filename/MIME TLVs and encryption envelope overhead.
const val MAX_FILE_SIZE_BYTES: Long = (10L * 1024 * 1024) - (132L * 1024)
}
object Services {

View File

@ -1259,7 +1259,7 @@ class BinaryProtocolTest {
}
@Test
fun `legacy 11 MiB public file transfer is explicitly rejected by bounded decoder`() {
fun `new sender refuses legacy 11 MiB public file transfer before transmission`() {
val content = ByteArray(11 * 1024 * 1024) { 0x41 }
val filePayload = BitchatFilePacket(
fileName = "legacy-11m.bin",
@ -1281,15 +1281,9 @@ class BinaryProtocolTest {
),
padding = false
)
assertNotNull("A legacy sender can produce the highly-compressible wire packet", encoded)
assertTrue(
"The compressed wire body remains below the normal 10 MiB input cap",
encoded!!.size < com.bitchat.android.util.AppConstants.Protocol.MAX_PAYLOAD_LENGTH
)
assertNull(
"The uniform bound intentionally rejects this legacy expansion until streaming admission exists",
BinaryProtocol.decode(encoded)
"Sender and receiver must enforce the same expanded-payload ceiling",
encoded
)
}

View File

@ -237,7 +237,7 @@ class FileTransferTest {
// Given: Large file size (simulated)
val largeFileSize = 100L * 1024 * 1024 // 100MB
val maxAllowedSize = 50L * 1024 * 1024 // 50MB
val maxAllowedSize = com.bitchat.android.util.AppConstants.Media.MAX_FILE_SIZE_BYTES
// When: Checking if file can be transferred
val isAllowed = largeFileSize <= maxAllowedSize

View File

@ -99,10 +99,11 @@ complete deflate stream. The `FILE_TRANSFER (0x22)` byte cannot safely grant a l
attacker-controlled before packet signature verification, and the current receive pipeline must
inflate before it can perform that verification.
This intentionally means a legacy Android sender can produce a highly compressible public file
between 10 MiB and the UI's 50 MiB send limit that the bounded decoder rejects. The hardening must
therefore remain a rollout HOLD rather than silently ship as backward compatible. Grandfathering
50 MiB also is not safe after only a type check: inflation allocates the declared payload and
New Android senders cap files just below 10 MiB (reserving envelope overhead) and refuse to encode
any payload above the receiver ceiling.
Legacy Android senders can still produce a highly compressible public file between 10 MiB and their
50 MiB UI limit that the bounded decoder rejects. Grandfathering 50 MiB is not safe after only a type
check: inflation allocates the declared payload and
`BitchatFilePacket.decode` currently copies the content again, creating a greater than 100 MiB peak
for a maximum-size transfer.