Skip to content

Commit c16eec2

Browse files
vanniktechxronyx
andauthored
Security: Added URI validation to prevent file system manipulation (fixes #613) (#680)
Co-authored-by: Rony Das <me@ronydas.com>
1 parent e75ee7e commit c16eec2

File tree

3 files changed

+178
-0
lines changed

3 files changed

+178
-0
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
Version 4.7.0 *(In development)*
44
--------------------------------
55

6+
- 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)
7+
68
Version 4.6.0 *(2024-08-05)*
79
----------------------------
810

cropper/src/main/kotlin/com/canhub/cropper/BitmapUtils.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,39 @@ internal object BitmapUtils {
425425
null
426426
}
427427

428+
/**
429+
* Validates the output URI for security purposes.
430+
* Only allows content:// URIs and validates file extension matches compress format.
431+
*
432+
* @throws SecurityException if URI scheme is not content:// or extension doesn't match format
433+
*/
434+
internal fun validateOutputUri(uri: Uri, compressFormat: CompressFormat) {
435+
// Only allow content:// URIs for security reasons
436+
if (uri.scheme != ContentResolver.SCHEME_CONTENT) {
437+
throw SecurityException(
438+
"Only content:// URIs are allowed for security reasons. Received: ${uri.scheme}://",
439+
)
440+
}
441+
442+
// Validate file extension matches compress format
443+
val path = uri.path ?: uri.toString()
444+
val expectedExtensions = when (compressFormat) {
445+
CompressFormat.JPEG -> listOf(".jpg", ".jpeg")
446+
CompressFormat.PNG -> listOf(".png")
447+
else -> listOf(".webp")
448+
}
449+
450+
val hasValidExtension = expectedExtensions.any { path.endsWith(it, ignoreCase = true) }
451+
if (!hasValidExtension) {
452+
throw SecurityException(
453+
"File extension does not match compress format. " +
454+
"Expected one of: ${expectedExtensions.joinToString(", ")}, " +
455+
"Format: $compressFormat, " +
456+
"Path: $path",
457+
)
458+
}
459+
}
460+
428461
/**
429462
* Write the given bitmap to the given uri using the given compression.
430463
*/
@@ -438,6 +471,11 @@ internal object BitmapUtils {
438471
): Uri {
439472
val newUri = customOutputUri ?: buildUri(context, compressFormat)
440473

474+
// Validate custom output URIs for security
475+
if (customOutputUri != null) {
476+
validateOutputUri(customOutputUri, compressFormat)
477+
}
478+
441479
return context.contentResolver.openOutputStream(newUri, WRITE_AND_TRUNCATE)!!.use {
442480
bitmap.compress(compressFormat, compressQuality, it)
443481
newUri

cropper/src/test/kotlin/com/canhub/cropper/BitmapUtilsTest.kt

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package com.canhub.cropper
22

3+
import android.graphics.Bitmap
4+
import android.net.Uri
35
import io.mockk.mockkObject
46
import io.mockk.unmockkObject
57
import org.junit.After
68
import org.junit.Assert.assertEquals
9+
import org.junit.Assert.assertTrue
710
import org.junit.Before
811
import org.junit.Test
912

@@ -131,4 +134,139 @@ class BitmapUtilsTest {
131134
fun `WHEN low rectangle points is provided getRectBottom, THEN resultArrayOutOfIndexException`() {
132135
BitmapUtils.getRectBottom(LOW_RECT_POINTS)
133136
}
137+
138+
@Test(expected = SecurityException::class)
139+
fun `WHEN file URI is provided for validation, THEN SecurityException is thrown`() {
140+
// GIVEN
141+
val fileUri = Uri.parse("file:///data/user/0/com.example/cache/image.jpg")
142+
val compressFormat = Bitmap.CompressFormat.JPEG
143+
144+
// WHEN
145+
BitmapUtils.validateOutputUri(fileUri, compressFormat)
146+
147+
// THEN - SecurityException expected
148+
}
149+
150+
@Test(expected = SecurityException::class)
151+
fun `WHEN file URI with malicious path is provided, THEN SecurityException is thrown`() {
152+
// GIVEN
153+
val maliciousUri = Uri.parse("file:///data/user/0/com.example/shared_prefs/SecureStore.xml")
154+
val compressFormat = Bitmap.CompressFormat.JPEG
155+
156+
// WHEN
157+
BitmapUtils.validateOutputUri(maliciousUri, compressFormat)
158+
159+
// THEN - SecurityException expected
160+
}
161+
162+
@Test(expected = SecurityException::class)
163+
fun `WHEN content URI with wrong extension for JPEG is provided, THEN SecurityException is thrown`() {
164+
// GIVEN
165+
val contentUri = Uri.parse("content://com.example.provider/images/image.png")
166+
val compressFormat = Bitmap.CompressFormat.JPEG
167+
168+
// WHEN
169+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
170+
171+
// THEN - SecurityException expected
172+
}
173+
174+
@Test(expected = SecurityException::class)
175+
fun `WHEN content URI with wrong extension for PNG is provided, THEN SecurityException is thrown`() {
176+
// GIVEN
177+
val contentUri = Uri.parse("content://com.example.provider/images/image.jpg")
178+
val compressFormat = Bitmap.CompressFormat.PNG
179+
180+
// WHEN
181+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
182+
183+
// THEN - SecurityException expected
184+
}
185+
186+
@Test(expected = SecurityException::class)
187+
fun `WHEN content URI with XML extension is provided, THEN SecurityException is thrown`() {
188+
// GIVEN
189+
val xmlUri = Uri.parse("content://com.example.provider/prefs/SecureStore.xml")
190+
val compressFormat = Bitmap.CompressFormat.JPEG
191+
192+
// WHEN
193+
BitmapUtils.validateOutputUri(xmlUri, compressFormat)
194+
195+
// THEN - SecurityException expected
196+
}
197+
198+
@Test
199+
fun `WHEN valid content URI with jpg extension for JPEG is provided, THEN validation passes`() {
200+
// GIVEN
201+
val contentUri = Uri.parse("content://com.example.provider/images/image.jpg")
202+
val compressFormat = Bitmap.CompressFormat.JPEG
203+
204+
// WHEN & THEN - No exception should be thrown
205+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
206+
}
207+
208+
@Test
209+
fun `WHEN valid content URI with jpeg extension for JPEG is provided, THEN validation passes`() {
210+
// GIVEN
211+
val contentUri = Uri.parse("content://com.example.provider/images/image.jpeg")
212+
val compressFormat = Bitmap.CompressFormat.JPEG
213+
214+
// WHEN & THEN - No exception should be thrown
215+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
216+
}
217+
218+
@Test
219+
fun `WHEN valid content URI with png extension for PNG is provided, THEN validation passes`() {
220+
// GIVEN
221+
val contentUri = Uri.parse("content://com.example.provider/images/image.png")
222+
val compressFormat = Bitmap.CompressFormat.PNG
223+
224+
// WHEN & THEN - No exception should be thrown
225+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
226+
}
227+
228+
@Test
229+
fun `WHEN valid content URI with webp extension for WEBP is provided, THEN validation passes`() {
230+
// GIVEN
231+
val contentUri = Uri.parse("content://com.example.provider/images/image.webp")
232+
val compressFormat = Bitmap.CompressFormat.WEBP
233+
234+
// WHEN & THEN - No exception should be thrown
235+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
236+
}
237+
238+
@Test
239+
fun `WHEN file URI validation fails, THEN exception message contains scheme information`() {
240+
// GIVEN
241+
val fileUri = Uri.parse("file:///path/to/image.jpg")
242+
val compressFormat = Bitmap.CompressFormat.JPEG
243+
244+
// WHEN
245+
try {
246+
BitmapUtils.validateOutputUri(fileUri, compressFormat)
247+
throw AssertionError("Expected SecurityException to be thrown")
248+
} catch (e: SecurityException) {
249+
// THEN
250+
assertTrue(e.message?.contains("content://") == true)
251+
assertTrue(e.message?.contains("file://") == true)
252+
}
253+
}
254+
255+
@Test
256+
fun `WHEN extension mismatch occurs, THEN exception message contains expected extensions`() {
257+
// GIVEN
258+
val contentUri = Uri.parse("content://com.example.provider/images/image.txt")
259+
val compressFormat = Bitmap.CompressFormat.JPEG
260+
261+
// WHEN
262+
try {
263+
BitmapUtils.validateOutputUri(contentUri, compressFormat)
264+
throw AssertionError("Expected SecurityException to be thrown")
265+
} catch (e: SecurityException) {
266+
// THEN
267+
assertTrue(e.message?.contains(".jpg") == true)
268+
assertTrue(e.message?.contains(".jpeg") == true)
269+
assertTrue(e.message?.contains("JPEG") == true)
270+
}
271+
}
134272
}

0 commit comments

Comments
 (0)