mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-09-19 04:59:59 +00:00
Align compressed payload send and receive bounds
This commit is contained in:
parent
fc9abffe0c
commit
0d04653bb8
@ -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",
|
||||
|
||||
@ -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.
|
||||
*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user