Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)*
----------------------------

Expand Down
38 changes: 38 additions & 0 deletions cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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
Expand Down
138 changes: 138 additions & 0 deletions cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
}
}
}