From 560518c960817baf88d462c9a827d9ffee2a65ba Mon Sep 17 00:00:00 2001 From: Rony Das Date: Mon, 17 Nov 2025 21:47:21 +0530 Subject: [PATCH] Security: Added URI validation to prevent file system manipulation (fixes #613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added validateOutputUri() function to validate custom output URIs - Only allow content:// URIs, reject file:// URIs to prevent file system attacks - Validate file extensions match compress format (JPEG→.jpg/.jpeg, PNG→.png, WEBP→.webp) - Added 11 comprehensive unit tests for URI validation - Update CHANGELOG.md with security fix documentation This fix prevents malicious apps from: - Using file:// URIs to overwrite sensitive files (e.g., SharedPreferences) - Writing arbitrary file types by extension mismatch - Exploiting the exported CropImageActivity as described in issue #613 --- CHANGELOG.md | 2 + .../kotlin/com/canhub/cropper/BitmapUtils.kt | 38 +++++ .../com/canhub/cropper/BitmapUtilsTest.kt | 138 ++++++++++++++++++ 3 files changed, 178 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f45d9a4..7cfe5760 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Version 4.7.0 *(In development)* -------------------------------- +- Security: Added URI validation to prevent file system manipulation attacks. Only content:// URIs are now allowed for customOutputUri, and file extensions must match the compress format. This prevents malicious apps from overwriting sensitive files or writing arbitrary file types. [\#613](https://github.com/CanHub/Android-Image-Cropper/issues/613) + Version 4.6.0 *(2024-08-05)* ---------------------------- diff --git a/cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt b/cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt index fed7e8c2..133c05ae 100644 --- a/cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt +++ b/cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt @@ -425,6 +425,39 @@ internal object BitmapUtils { null } + /** + * Validates the output URI for security purposes. + * Only allows content:// URIs and validates file extension matches compress format. + * + * @throws SecurityException if URI scheme is not content:// or extension doesn't match format + */ + internal fun validateOutputUri(uri: Uri, compressFormat: CompressFormat) { + // Only allow content:// URIs for security reasons + if (uri.scheme != ContentResolver.SCHEME_CONTENT) { + throw SecurityException( + "Only content:// URIs are allowed for security reasons. Received: ${uri.scheme}://" + ) + } + + // Validate file extension matches compress format + val path = uri.path ?: uri.toString() + val expectedExtensions = when (compressFormat) { + CompressFormat.JPEG -> listOf(".jpg", ".jpeg") + CompressFormat.PNG -> listOf(".png") + else -> listOf(".webp") + } + + val hasValidExtension = expectedExtensions.any { path.endsWith(it, ignoreCase = true) } + if (!hasValidExtension) { + throw SecurityException( + "File extension does not match compress format. " + + "Expected one of: ${expectedExtensions.joinToString(", ")}, " + + "Format: $compressFormat, " + + "Path: $path" + ) + } + } + /** * Write the given bitmap to the given uri using the given compression. */ @@ -438,6 +471,11 @@ internal object BitmapUtils { ): Uri { val newUri = customOutputUri ?: buildUri(context, compressFormat) + // Validate custom output URIs for security + if (customOutputUri != null) { + validateOutputUri(customOutputUri, compressFormat) + } + return context.contentResolver.openOutputStream(newUri, WRITE_AND_TRUNCATE)!!.use { bitmap.compress(compressFormat, compressQuality, it) newUri diff --git a/cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt b/cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt index 839e9c6e..8515ba1d 100644 --- a/cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt +++ b/cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt @@ -1,9 +1,12 @@ package com.canhub.cropper +import android.graphics.Bitmap +import android.net.Uri import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -131,4 +134,139 @@ class BitmapUtilsTest { fun `WHEN low rectangle points is provided getRectBottom, THEN resultArrayOutOfIndexException`() { BitmapUtils.getRectBottom(LOW_RECT_POINTS) } + + @Test(expected = SecurityException::class) + fun `WHEN file URI is provided for validation, THEN SecurityException is thrown`() { + // GIVEN + val fileUri = Uri.parse("file:///data/user/0/com.example/cache/image.jpg") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + BitmapUtils.validateOutputUri(fileUri, compressFormat) + + // THEN - SecurityException expected + } + + @Test(expected = SecurityException::class) + fun `WHEN file URI with malicious path is provided, THEN SecurityException is thrown`() { + // GIVEN + val maliciousUri = Uri.parse("file:///data/user/0/com.example/shared_prefs/SecureStore.xml") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + BitmapUtils.validateOutputUri(maliciousUri, compressFormat) + + // THEN - SecurityException expected + } + + @Test(expected = SecurityException::class) + fun `WHEN content URI with wrong extension for JPEG is provided, THEN SecurityException is thrown`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.png") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + BitmapUtils.validateOutputUri(contentUri, compressFormat) + + // THEN - SecurityException expected + } + + @Test(expected = SecurityException::class) + fun `WHEN content URI with wrong extension for PNG is provided, THEN SecurityException is thrown`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.jpg") + val compressFormat = Bitmap.CompressFormat.PNG + + // WHEN + BitmapUtils.validateOutputUri(contentUri, compressFormat) + + // THEN - SecurityException expected + } + + @Test(expected = SecurityException::class) + fun `WHEN content URI with XML extension is provided, THEN SecurityException is thrown`() { + // GIVEN + val xmlUri = Uri.parse("content://com.example.provider/prefs/SecureStore.xml") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + BitmapUtils.validateOutputUri(xmlUri, compressFormat) + + // THEN - SecurityException expected + } + + @Test + fun `WHEN valid content URI with jpg extension for JPEG is provided, THEN validation passes`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.jpg") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN & THEN - No exception should be thrown + BitmapUtils.validateOutputUri(contentUri, compressFormat) + } + + @Test + fun `WHEN valid content URI with jpeg extension for JPEG is provided, THEN validation passes`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.jpeg") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN & THEN - No exception should be thrown + BitmapUtils.validateOutputUri(contentUri, compressFormat) + } + + @Test + fun `WHEN valid content URI with png extension for PNG is provided, THEN validation passes`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.png") + val compressFormat = Bitmap.CompressFormat.PNG + + // WHEN & THEN - No exception should be thrown + BitmapUtils.validateOutputUri(contentUri, compressFormat) + } + + @Test + fun `WHEN valid content URI with webp extension for WEBP is provided, THEN validation passes`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.webp") + val compressFormat = Bitmap.CompressFormat.WEBP + + // WHEN & THEN - No exception should be thrown + BitmapUtils.validateOutputUri(contentUri, compressFormat) + } + + @Test + fun `WHEN file URI validation fails, THEN exception message contains scheme information`() { + // GIVEN + val fileUri = Uri.parse("file:///path/to/image.jpg") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + try { + BitmapUtils.validateOutputUri(fileUri, compressFormat) + throw AssertionError("Expected SecurityException to be thrown") + } catch (e: SecurityException) { + // THEN + assertTrue(e.message?.contains("content://") == true) + assertTrue(e.message?.contains("file://") == true) + } + } + + @Test + fun `WHEN extension mismatch occurs, THEN exception message contains expected extensions`() { + // GIVEN + val contentUri = Uri.parse("content://com.example.provider/images/image.txt") + val compressFormat = Bitmap.CompressFormat.JPEG + + // WHEN + try { + BitmapUtils.validateOutputUri(contentUri, compressFormat) + throw AssertionError("Expected SecurityException to be thrown") + } catch (e: SecurityException) { + // THEN + assertTrue(e.message?.contains(".jpg") == true) + assertTrue(e.message?.contains(".jpeg") == true) + assertTrue(e.message?.contains("JPEG") == true) + } + } }