diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index a5021ae54c..5cf17d304d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -214,13 +214,14 @@ private fun decodeSampledBitmapFromByteArray(data: ByteArray, reqWidth: Int, req // First decode with inJustDecodeBounds=true to check dimensions return BitmapFactory.Options().run { inJustDecodeBounds = true - BitmapFactory.decodeByteArray(data, 0, data.size) + BitmapFactory.decodeByteArray(data, 0, data.size, this) // Calculate inSampleSize inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight) // Decode bitmap with inSampleSize set inJustDecodeBounds = false - BitmapFactory.decodeByteArray(data, 0, data.size) + BitmapFactory.decodeByteArray(data, 0, data.size, this) + ?: throw IOException("Unable to decode image") } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 8d69607c62..22005bbfc4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -13,8 +13,10 @@ import chat.simplex.common.model.readCryptoFile import chat.simplex.common.platform.* import chat.simplex.common.simplexWindowState import kotlinx.coroutines.delay +import java.awt.image.BufferedImage import java.io.ByteArrayInputStream import java.io.File +import java.io.IOException import java.net.URI import java.util.* import javax.imageio.ImageIO @@ -24,6 +26,12 @@ import kotlin.io.encoding.ExperimentalEncodingApi private val bStyle = SpanStyle(fontWeight = FontWeight.Bold) private val iStyle = SpanStyle(fontStyle = FontStyle.Italic) private val uStyle = SpanStyle(textDecoration = TextDecoration.Underline) +// Full-screen view downsamples to fit within this on each side +private const val MAX_IMAGE_DIMENSION = 4320 +// Chat render (getLoadedImage) downsamples to this, matching Android's getLoadedImage target +private const val MAX_THUMBNAIL_DIMENSION = 1000 +// Source images larger than this on either side are rejected (bounds the decoder's per-scanline buffer) +private const val MAX_SOURCE_IMAGE_DIMENSION = 16384 private fun fontStyle(color: String) = SpanStyle(color = Color(color.replace("#", "ff").toLongOrNull(16) ?: Color.White.toArgb().toLong())) @@ -146,8 +154,8 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? return if (filePath != null) { loadedImageCache[filePath] ?: try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() - val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null + val bitmap = decodeBoundedImage(data, MAX_THUMBNAIL_DIMENSION) + (bitmap to data).also { loadedImageCache[filePath] = it } } catch (e: Exception) { Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) null @@ -165,6 +173,8 @@ actual fun getFileSize(uri: URI): Long? = uri.toFile().length() actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitmap? = try { + // No dimension cap here: this path decodes user-picked local files (image picker, compose, save), + // not untrusted received attachments. The cap is applied in getBitmapFromByteArray (auto-rendered content). uri.inputStream().use { ImageIO.read(it).toComposeImageBitmap() } @@ -177,7 +187,7 @@ actual fun getBitmapFromUri(uri: URI, withAlertOnException: Boolean): ImageBitma actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean): ImageBitmap? = try { - ImageIO.read(ByteArrayInputStream(data)).toComposeImageBitmap() + decodeBoundedImage(data, MAX_IMAGE_DIMENSION) } catch (e: Exception) { Log.e(TAG, "Error while encoding bitmap from byte array: ${e.stackTraceToString()}") if (withAlertOnException) showImageDecodingException() @@ -185,6 +195,47 @@ actual fun getBitmapFromByteArray(data: ByteArray, withAlertOnException: Boolean null } +private fun decodeBoundedImage(data: ByteArray, maxDimension: Int): ImageBitmap = + decodeBoundedBufferedImage(data, maxDimension).toComposeImageBitmap() + +// Decodes downloaded/auto-rendered image bytes with bounded memory: rejects absurd source dimensions, +// then downsamples (like Android's inSampleSize) so even large legitimate images decode to a bounded raster. +internal fun decodeBoundedBufferedImage(data: ByteArray, maxDimension: Int): BufferedImage { + val stream = ImageIO.createImageInputStream(ByteArrayInputStream(data)) + ?: throw IOException("Unsupported image format") + stream.use { + val readers = ImageIO.getImageReaders(it) + if (!readers.hasNext()) throw IOException("Unsupported image format") + + val reader = readers.next() + try { + reader.input = it + val width = reader.getWidth(0) + val height = reader.getHeight(0) + if (!sourceDimensionsWithinLimits(width, height)) throw IOException("Image dimensions exceed limit") + val sampleSize = imageSampleSize(width, height, maxDimension) + val param = reader.defaultReadParam.apply { + if (sampleSize > 1) setSourceSubsampling(sampleSize, sampleSize, 0, 0) + } + return reader.read(0, param) ?: throw IOException("Unable to decode image") + } finally { + reader.dispose() + } + } +} + +internal fun sourceDimensionsWithinLimits(width: Int, height: Int): Boolean = + width in 1..MAX_SOURCE_IMAGE_DIMENSION && height in 1..MAX_SOURCE_IMAGE_DIMENSION + +// Smallest power-of-two subsampling factor so both dimensions fit within maxDimension (mirrors Android inSampleSize) +internal fun imageSampleSize(width: Int, height: Int, maxDimension: Int): Int { + var sampleSize = 1 + while (width / sampleSize > maxDimension || height / sampleSize > maxDimension) { + sampleSize *= 2 + } + return sampleSize +} + // LALAL implement to support animated drawable actual fun getDrawableFromUri(uri: URI, withAlertOnException: Boolean): Any? = null diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt new file mode 100644 index 0000000000..4c4b8c3ab6 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ImageDecodeBoundsTest.kt @@ -0,0 +1,53 @@ +package chat.simplex.app + +import chat.simplex.common.views.helpers.decodeBoundedBufferedImage +import chat.simplex.common.views.helpers.imageSampleSize +import chat.simplex.common.views.helpers.sourceDimensionsWithinLimits +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.IOException +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ImageDecodeBoundsTest { + @Test + fun testSampleSizeKeepsImagesWithinTargetDimension() { + assertEquals(1, imageSampleSize(4320, 4320, 4320)) // at the target -> no subsampling + assertEquals(2, imageSampleSize(4321, 100, 4320)) // one side just over -> halved + assertEquals(2, imageSampleSize(8640, 8640, 4320)) + assertEquals(4, imageSampleSize(16384, 16384, 4320)) + } + + @Test + fun testSourceDimensionsRejectAbsurdSizes() { + assertTrue(sourceDimensionsWithinLimits(16384, 16384)) // generous upper bound, downsampled later + assertFalse(sourceDimensionsWithinLimits(16385, 1)) + assertFalse(sourceDimensionsWithinLimits(0, 1)) + } + + @Test + fun testDecodeRejectsImageExceedingSourceLimit() { + // Rejected at the dimension check before any full-size allocation + assertFailsWith { decodeBoundedBufferedImage(encodePng(16385, 1), 4320) } + } + + @Test + fun testDecodeDownsamplesLargeDimensionImageInsteadOfRejecting() { + // Image-bomb shaped: large declared width (within source cap), tiny encoded size. + // Must decode (not reject) AND be downsampled - proves subsampling is honored, so memory stays bounded. + val image = decodeBoundedBufferedImage(encodePng(16384, 8), 4320) + assertTrue(image.width <= 4320, "expected downsampled width, got ${image.width}") + } + + private fun encodePng(width: Int, height: Int): ByteArray { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB) + return ByteArrayOutputStream().use { out -> + ImageIO.write(image, "png", out) + out.toByteArray() + } + } +}