[file_selector_android] Refactor interactions with `ContentProvider` provided filenames (#8184)
https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#don%27t-trust-user-input
Adapted from this option
https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#sanitize-provided-filenames
diff --git a/packages/file_selector/file_selector_android/CHANGELOG.md b/packages/file_selector/file_selector_android/CHANGELOG.md
index ca30679..534c75f 100644
--- a/packages/file_selector/file_selector_android/CHANGELOG.md
+++ b/packages/file_selector/file_selector_android/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.5.1+12
+
+* Fixes a security issue related to improperly trusting filenames provided by a `ContentProvider`.
+
## 0.5.1+11
* Bumps androidx.annotation:annotation from 1.9.0 to 1.9.1.
diff --git a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java
index 555318b..37d38c2 100644
--- a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java
+++ b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java
@@ -4,6 +4,8 @@
package dev.flutter.packages.file_selector_android;
+import static dev.flutter.packages.file_selector_android.FileUtils.FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH;
+
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.ClipData;
@@ -357,8 +359,32 @@
return null;
}
- final String uriPath =
- FileUtils.getPathFromCopyOfFileFromUri(activityPluginBinding.getActivity(), uri);
+ String uriPath;
+ GeneratedFileSelectorApi.FileSelectorNativeException nativeError = null;
+
+ try {
+ uriPath = FileUtils.getPathFromCopyOfFileFromUri(activityPluginBinding.getActivity(), uri);
+ } catch (IOException e) {
+ // If closing the output stream fails, we cannot be sure that the
+ // target file was written in full. Flushing the stream merely moves
+ // the bytes into the OS, not necessarily to the file.
+ uriPath = null;
+ } catch (SecurityException e) {
+ // Calling `ContentResolver#openInputStream()` has been reported to throw a
+ // `SecurityException` on some devices in certain circumstances. Instead of crashing, we
+ // return `null`.
+ //
+ // See https://github.com/flutter/flutter/issues/100025 for more details.
+ uriPath = null;
+ } catch (IllegalArgumentException e) {
+ uriPath = FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH;
+ nativeError =
+ new GeneratedFileSelectorApi.FileSelectorNativeException.Builder()
+ .setMessage(e.getMessage() == null ? "" : e.getMessage())
+ .setFileSelectorExceptionCode(
+ GeneratedFileSelectorApi.FileSelectorExceptionCode.ILLEGAL_ARGUMENT_EXCEPTION)
+ .build();
+ }
return new GeneratedFileSelectorApi.FileResponse.Builder()
.setName(name)
@@ -366,6 +392,7 @@
.setPath(uriPath)
.setMimeType(contentResolver.getType(uri))
.setSize(size.longValue())
+ .setFileSelectorNativeException(nativeError)
.build();
}
}
diff --git a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java
index 5d0b613..e3cd812 100644
--- a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java
+++ b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java
@@ -45,6 +45,8 @@
/** URI authority that represents access to external storage providers. */
public static final String EXTERNAL_DOCUMENT_AUTHORITY = "com.android.externalstorage.documents";
+ public static final String FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH = "FILE_SELECTOR_EXCEPTION";
+
/**
* Retrieves path of directory represented by the specified {@code Uri}.
*
@@ -98,6 +100,12 @@
* Copies the file from the given content URI to a temporary directory, retaining the original
* file name if possible.
*
+ * <p>If the filename contains path indirection or separators (.. or /), the end file name will be
+ * the segment after the final separator, with indirection replaced by underscores. E.g.
+ * "example/../..file.png" -> "_file.png". See: <a
+ * href="https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename">Improperly
+ * trusting ContentProvider-provided filename</a>.
+ *
* <p>Each file is placed in its own directory to avoid conflicts according to the following
* scheme: {cacheDir}/{randomUuid}/{fileName}
*
@@ -111,7 +119,8 @@
* or if a security exception is encountered when opening the input stream to start the copying.
*/
@Nullable
- public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @NonNull Uri uri) {
+ public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @NonNull Uri uri)
+ throws IOException, SecurityException, IllegalArgumentException {
try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) {
String uuid = UUID.nameUUIDFromBytes(uri.toString().getBytes()).toString();
File targetDirectory = new File(context.getCacheDir(), uuid);
@@ -122,7 +131,7 @@
if (fileName == null) {
if (extension == null) {
- throw new IllegalArgumentException("No name nor extension found for file.");
+ throw new IllegalStateException("No name nor extension found for file.");
} else {
fileName = "file_selector" + extension;
}
@@ -130,24 +139,13 @@
fileName = getBaseName(fileName) + extension;
}
- File file = new File(targetDirectory, fileName);
+ String filePath = new File(targetDirectory, fileName).getPath();
+ File outputFile = saferOpenFile(filePath, targetDirectory.getCanonicalPath());
- try (OutputStream outputStream = new FileOutputStream(file)) {
+ try (OutputStream outputStream = new FileOutputStream(outputFile)) {
copy(inputStream, outputStream);
- return file.getPath();
+ return outputFile.getPath();
}
- } catch (IOException e) {
- // If closing the output stream fails, we cannot be sure that the
- // target file was written in full. Flushing the stream merely moves
- // the bytes into the OS, not necessarily to the file.
- return null;
- } catch (SecurityException e) {
- // Calling `ContentResolver#openInputStream()` has been reported to throw a
- // `SecurityException` on some devices in certain circumstances. Instead of crashing, we
- // return `null`.
- //
- // See https://github.com/flutter/flutter/issues/100025 for more details.
- return null;
}
}
@@ -172,14 +170,17 @@
return null;
}
- return "." + extension;
+ return "." + sanitizeFilename(extension);
}
/** Returns the name of the file provided by ContentResolver; this may be null. */
private static String getFileName(Context context, Uri uriFile) {
try (Cursor cursor = queryFileName(context, uriFile)) {
- if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null;
- return cursor.getString(0);
+ if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) {
+ return null;
+ }
+ String unsanitizedFileName = cursor.getString(0);
+ return sanitizeFilename(unsanitizedFileName);
}
}
@@ -206,4 +207,38 @@
// Basename is everything before the last '.'.
return fileName.substring(0, lastDotIndex);
}
+
+ // From https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#sanitize-provided-filenames.
+ protected static @Nullable String sanitizeFilename(@Nullable String displayName) {
+ if (displayName == null) {
+ return null;
+ }
+
+ String[] badCharacters = new String[] {"..", "/"};
+ String[] segments = displayName.split("/");
+ String fileName = segments[segments.length - 1];
+ for (String suspString : badCharacters) {
+ fileName = fileName.replace(suspString, "_");
+ }
+ return fileName;
+ }
+
+ /**
+ * Use with file name sanatization and an non-guessable directory. From
+ * https://developer.android.com/privacy-and-security/risks/path-traversal#path-traversal-mitigations.
+ */
+ protected static @NonNull File saferOpenFile(@NonNull String path, @NonNull String expectedDir)
+ throws IllegalArgumentException, IOException {
+ File f = new File(path);
+ String canonicalPath = f.getCanonicalPath();
+ if (!canonicalPath.startsWith(expectedDir)) {
+ throw new IllegalArgumentException(
+ "Trying to open path outside of the expected directory. File: "
+ + f.getCanonicalPath()
+ + " was expected to be within directory: "
+ + expectedDir
+ + ".");
+ }
+ return f;
+ }
}
diff --git a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/GeneratedFileSelectorApi.java b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/GeneratedFileSelectorApi.java
index de595ef..ee744f5 100644
--- a/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/GeneratedFileSelectorApi.java
+++ b/packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/GeneratedFileSelectorApi.java
@@ -1,7 +1,7 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-// Autogenerated from Pigeon (v22.4.2), do not edit directly.
+// Autogenerated from Pigeon (v22.6.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
package dev.flutter.packages.file_selector_android;
@@ -66,6 +66,115 @@
@Retention(CLASS)
@interface CanIgnoreReturnValue {}
+ public enum FileSelectorExceptionCode {
+ SECURITY_EXCEPTION(0),
+ IO_EXCEPTION(1),
+ ILLEGAL_ARGUMENT_EXCEPTION(2),
+ ILLEGAL_STATE_EXCEPTION(3);
+
+ final int index;
+
+ FileSelectorExceptionCode(final int index) {
+ this.index = index;
+ }
+ }
+
+ /** Generated class from Pigeon that represents data sent in messages. */
+ public static final class FileSelectorNativeException {
+ private @NonNull FileSelectorExceptionCode fileSelectorExceptionCode;
+
+ public @NonNull FileSelectorExceptionCode getFileSelectorExceptionCode() {
+ return fileSelectorExceptionCode;
+ }
+
+ public void setFileSelectorExceptionCode(@NonNull FileSelectorExceptionCode setterArg) {
+ if (setterArg == null) {
+ throw new IllegalStateException("Nonnull field \"fileSelectorExceptionCode\" is null.");
+ }
+ this.fileSelectorExceptionCode = setterArg;
+ }
+
+ private @NonNull String message;
+
+ public @NonNull String getMessage() {
+ return message;
+ }
+
+ public void setMessage(@NonNull String setterArg) {
+ if (setterArg == null) {
+ throw new IllegalStateException("Nonnull field \"message\" is null.");
+ }
+ this.message = setterArg;
+ }
+
+ /** Constructor is non-public to enforce null safety; use Builder. */
+ FileSelectorNativeException() {}
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ FileSelectorNativeException that = (FileSelectorNativeException) o;
+ return fileSelectorExceptionCode.equals(that.fileSelectorExceptionCode)
+ && message.equals(that.message);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(fileSelectorExceptionCode, message);
+ }
+
+ public static final class Builder {
+
+ private @Nullable FileSelectorExceptionCode fileSelectorExceptionCode;
+
+ @CanIgnoreReturnValue
+ public @NonNull Builder setFileSelectorExceptionCode(
+ @NonNull FileSelectorExceptionCode setterArg) {
+ this.fileSelectorExceptionCode = setterArg;
+ return this;
+ }
+
+ private @Nullable String message;
+
+ @CanIgnoreReturnValue
+ public @NonNull Builder setMessage(@NonNull String setterArg) {
+ this.message = setterArg;
+ return this;
+ }
+
+ public @NonNull FileSelectorNativeException build() {
+ FileSelectorNativeException pigeonReturn = new FileSelectorNativeException();
+ pigeonReturn.setFileSelectorExceptionCode(fileSelectorExceptionCode);
+ pigeonReturn.setMessage(message);
+ return pigeonReturn;
+ }
+ }
+
+ @NonNull
+ ArrayList<Object> toList() {
+ ArrayList<Object> toListResult = new ArrayList<>(2);
+ toListResult.add(fileSelectorExceptionCode);
+ toListResult.add(message);
+ return toListResult;
+ }
+
+ static @NonNull FileSelectorNativeException fromList(
+ @NonNull ArrayList<Object> pigeonVar_list) {
+ FileSelectorNativeException pigeonResult = new FileSelectorNativeException();
+ Object fileSelectorExceptionCode = pigeonVar_list.get(0);
+ pigeonResult.setFileSelectorExceptionCode(
+ (FileSelectorExceptionCode) fileSelectorExceptionCode);
+ Object message = pigeonVar_list.get(1);
+ pigeonResult.setMessage((String) message);
+ return pigeonResult;
+ }
+ }
+
/** Generated class from Pigeon that represents data sent in messages. */
public static final class FileResponse {
private @NonNull String path;
@@ -127,6 +236,16 @@
this.bytes = setterArg;
}
+ private @Nullable FileSelectorNativeException fileSelectorNativeException;
+
+ public @Nullable FileSelectorNativeException getFileSelectorNativeException() {
+ return fileSelectorNativeException;
+ }
+
+ public void setFileSelectorNativeException(@Nullable FileSelectorNativeException setterArg) {
+ this.fileSelectorNativeException = setterArg;
+ }
+
/** Constructor is non-public to enforce null safety; use Builder. */
FileResponse() {}
@@ -143,12 +262,13 @@
&& Objects.equals(mimeType, that.mimeType)
&& Objects.equals(name, that.name)
&& size.equals(that.size)
- && Arrays.equals(bytes, that.bytes);
+ && Arrays.equals(bytes, that.bytes)
+ && Objects.equals(fileSelectorNativeException, that.fileSelectorNativeException);
}
@Override
public int hashCode() {
- int pigeonVar_result = Objects.hash(path, mimeType, name, size);
+ int pigeonVar_result = Objects.hash(path, mimeType, name, size, fileSelectorNativeException);
pigeonVar_result = 31 * pigeonVar_result + Arrays.hashCode(bytes);
return pigeonVar_result;
}
@@ -195,6 +315,15 @@
return this;
}
+ private @Nullable FileSelectorNativeException fileSelectorNativeException;
+
+ @CanIgnoreReturnValue
+ public @NonNull Builder setFileSelectorNativeException(
+ @Nullable FileSelectorNativeException setterArg) {
+ this.fileSelectorNativeException = setterArg;
+ return this;
+ }
+
public @NonNull FileResponse build() {
FileResponse pigeonReturn = new FileResponse();
pigeonReturn.setPath(path);
@@ -202,18 +331,20 @@
pigeonReturn.setName(name);
pigeonReturn.setSize(size);
pigeonReturn.setBytes(bytes);
+ pigeonReturn.setFileSelectorNativeException(fileSelectorNativeException);
return pigeonReturn;
}
}
@NonNull
ArrayList<Object> toList() {
- ArrayList<Object> toListResult = new ArrayList<>(5);
+ ArrayList<Object> toListResult = new ArrayList<>(6);
toListResult.add(path);
toListResult.add(mimeType);
toListResult.add(name);
toListResult.add(size);
toListResult.add(bytes);
+ toListResult.add(fileSelectorNativeException);
return toListResult;
}
@@ -229,6 +360,9 @@
pigeonResult.setSize((Long) size);
Object bytes = pigeonVar_list.get(4);
pigeonResult.setBytes((byte[]) bytes);
+ Object fileSelectorNativeException = pigeonVar_list.get(5);
+ pigeonResult.setFileSelectorNativeException(
+ (FileSelectorNativeException) fileSelectorNativeException);
return pigeonResult;
}
}
@@ -334,8 +468,17 @@
protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) {
switch (type) {
case (byte) 129:
- return FileResponse.fromList((ArrayList<Object>) readValue(buffer));
+ {
+ Object value = readValue(buffer);
+ return value == null
+ ? null
+ : FileSelectorExceptionCode.values()[((Long) value).intValue()];
+ }
case (byte) 130:
+ return FileSelectorNativeException.fromList((ArrayList<Object>) readValue(buffer));
+ case (byte) 131:
+ return FileResponse.fromList((ArrayList<Object>) readValue(buffer));
+ case (byte) 132:
return FileTypes.fromList((ArrayList<Object>) readValue(buffer));
default:
return super.readValueOfType(type, buffer);
@@ -344,11 +487,17 @@
@Override
protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) {
- if (value instanceof FileResponse) {
+ if (value instanceof FileSelectorExceptionCode) {
stream.write(129);
+ writeValue(stream, value == null ? null : ((FileSelectorExceptionCode) value).index);
+ } else if (value instanceof FileSelectorNativeException) {
+ stream.write(130);
+ writeValue(stream, ((FileSelectorNativeException) value).toList());
+ } else if (value instanceof FileResponse) {
+ stream.write(131);
writeValue(stream, ((FileResponse) value).toList());
} else if (value instanceof FileTypes) {
- stream.write(130);
+ stream.write(132);
writeValue(stream, ((FileTypes) value).toList());
} else {
super.writeValue(stream, value);
diff --git a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java
index 00533c2..b213e85 100644
--- a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java
+++ b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileSelectorAndroidPluginTest.java
@@ -5,6 +5,8 @@
package dev.flutter.packages.file_selector_android;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -212,6 +214,206 @@
}
}
+ // This test was created when error handling was moved from FileUtils.java to FileSelectorApiImpl.java
+ // in https://github.com/flutter/packages/pull/8184, so as to maintain the existing test.
+ // The behavior is actually an error case and should be fixed,
+ // see: https://github.com/flutter/flutter/issues/159568.
+ // Remove when fixed!
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ @Test
+ public void
+ openFileThrowsIllegalStateException_whenSecurityExceptionInGetPathFromCopyOfFileFromUri()
+ throws FileNotFoundException {
+
+ try (MockedStatic<FileUtils> mockedFileUtils = mockStatic(FileUtils.class)) {
+
+ final ContentResolver mockContentResolver = mock(ContentResolver.class);
+
+ final Uri mockUri = mock(Uri.class);
+ final String mockUriPath = "some/path/";
+ mockedFileUtils
+ .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri)))
+ .thenThrow(SecurityException.class);
+ mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain");
+
+ final Uri mockUri2 = mock(Uri.class);
+ final String mockUri2Path = "some/other/path/";
+ mockedFileUtils
+ .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri2)))
+ .thenAnswer((Answer<String>) invocation -> mockUri2Path);
+ mockContentResolver(mockContentResolver, mockUri2, "filename2", 40, "image/jpg");
+
+ when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent);
+ when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class));
+ when(mockActivity.getContentResolver()).thenReturn(mockContentResolver);
+ when(mockActivityBinding.getActivity()).thenReturn(mockActivity);
+ final FileSelectorApiImpl fileSelectorApi =
+ new FileSelectorApiImpl(
+ mockActivityBinding,
+ mockObjectFactory,
+ (version) -> Build.VERSION.SDK_INT >= version);
+
+ final GeneratedFileSelectorApi.Result mockResult =
+ mock(GeneratedFileSelectorApi.Result.class);
+ fileSelectorApi.openFiles(
+ null,
+ new GeneratedFileSelectorApi.FileTypes.Builder()
+ .setMimeTypes(Collections.emptyList())
+ .setExtensions(Collections.emptyList())
+ .build(),
+ mockResult);
+ verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE);
+ verify(mockIntent).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+
+ verify(mockActivity).startActivityForResult(mockIntent, 222);
+
+ final ArgumentCaptor<PluginRegistry.ActivityResultListener> listenerArgumentCaptor =
+ ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class);
+ verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture());
+
+ final Intent resultMockIntent = mock(Intent.class);
+ final ClipData mockClipData = mock(ClipData.class);
+ when(mockClipData.getItemCount()).thenReturn(2);
+
+ final ClipData.Item mockClipDataItem = mock(ClipData.Item.class);
+ when(mockClipDataItem.getUri()).thenReturn(mockUri);
+ when(mockClipData.getItemAt(0)).thenReturn(mockClipDataItem);
+
+ final ClipData.Item mockClipDataItem2 = mock(ClipData.Item.class);
+ when(mockClipDataItem2.getUri()).thenReturn(mockUri2);
+ when(mockClipData.getItemAt(1)).thenReturn(mockClipDataItem2);
+
+ when(resultMockIntent.getClipData()).thenReturn(mockClipData);
+
+ assertThrows(
+ IllegalStateException.class,
+ () ->
+ listenerArgumentCaptor
+ .getValue()
+ .onActivityResult(222, Activity.RESULT_OK, resultMockIntent));
+ }
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ @Test
+ public void
+ openFileReturnsNativeException_whenIllegalArgumentExceptionInGetPathFromCopyOfFileFromUri()
+ throws FileNotFoundException {
+ try (MockedStatic<FileUtils> mockedFileUtils = mockStatic(FileUtils.class)) {
+ final ContentResolver mockContentResolver = mock(ContentResolver.class);
+
+ final Uri mockUri = mock(Uri.class);
+ mockedFileUtils
+ .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri)))
+ .thenThrow(IllegalArgumentException.class);
+ mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain");
+
+ when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent);
+ when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class));
+ when(mockActivity.getContentResolver()).thenReturn(mockContentResolver);
+ when(mockActivityBinding.getActivity()).thenReturn(mockActivity);
+ final FileSelectorApiImpl fileSelectorApi =
+ new FileSelectorApiImpl(
+ mockActivityBinding,
+ mockObjectFactory,
+ (version) -> Build.VERSION.SDK_INT >= version);
+
+ final GeneratedFileSelectorApi.NullableResult mockResult =
+ mock(GeneratedFileSelectorApi.NullableResult.class);
+ fileSelectorApi.openFile(
+ null,
+ new GeneratedFileSelectorApi.FileTypes.Builder()
+ .setMimeTypes(Collections.emptyList())
+ .setExtensions(Collections.emptyList())
+ .build(),
+ mockResult);
+ verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE);
+
+ verify(mockActivity).startActivityForResult(mockIntent, 221);
+
+ final ArgumentCaptor<PluginRegistry.ActivityResultListener> listenerArgumentCaptor =
+ ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class);
+ verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture());
+
+ final Intent resultMockIntent = mock(Intent.class);
+ when(resultMockIntent.getData()).thenReturn(mockUri);
+ listenerArgumentCaptor.getValue().onActivityResult(221, Activity.RESULT_OK, resultMockIntent);
+
+ final ArgumentCaptor<GeneratedFileSelectorApi.FileResponse> fileCaptor =
+ ArgumentCaptor.forClass(GeneratedFileSelectorApi.FileResponse.class);
+ verify(mockResult).success(fileCaptor.capture());
+
+ final GeneratedFileSelectorApi.FileResponse file = fileCaptor.getValue();
+ assertNotNull(file.getFileSelectorNativeException());
+ assertEquals(file.getPath(), FileUtils.FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH);
+ }
+ }
+
+ @SuppressWarnings({"rawtypes", "unchecked"})
+ @Test
+ public void
+ openFilesReturnsNativeException_whenIllegalArgumentExceptionInGetPathFromCopyOfFileFromUri()
+ throws FileNotFoundException {
+ try (MockedStatic<FileUtils> mockedFileUtils = mockStatic(FileUtils.class)) {
+
+ final ContentResolver mockContentResolver = mock(ContentResolver.class);
+
+ final Uri mockUri = mock(Uri.class);
+ final String mockUriPath = "some/path/";
+ mockedFileUtils
+ .when(() -> FileUtils.getPathFromCopyOfFileFromUri(any(Context.class), eq(mockUri)))
+ .thenThrow(IllegalArgumentException.class);
+ mockContentResolver(mockContentResolver, mockUri, "filename", 30, "text/plain");
+
+ when(mockObjectFactory.newIntent(Intent.ACTION_OPEN_DOCUMENT)).thenReturn(mockIntent);
+ when(mockObjectFactory.newDataInputStream(any())).thenReturn(mock(DataInputStream.class));
+ when(mockActivity.getContentResolver()).thenReturn(mockContentResolver);
+ when(mockActivityBinding.getActivity()).thenReturn(mockActivity);
+ final FileSelectorApiImpl fileSelectorApi =
+ new FileSelectorApiImpl(
+ mockActivityBinding,
+ mockObjectFactory,
+ (version) -> Build.VERSION.SDK_INT >= version);
+
+ final GeneratedFileSelectorApi.Result mockResult =
+ mock(GeneratedFileSelectorApi.Result.class);
+ fileSelectorApi.openFiles(
+ null,
+ new GeneratedFileSelectorApi.FileTypes.Builder()
+ .setMimeTypes(Collections.emptyList())
+ .setExtensions(Collections.emptyList())
+ .build(),
+ mockResult);
+ verify(mockIntent).addCategory(Intent.CATEGORY_OPENABLE);
+ verify(mockIntent).putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
+
+ verify(mockActivity).startActivityForResult(mockIntent, 222);
+
+ final ArgumentCaptor<PluginRegistry.ActivityResultListener> listenerArgumentCaptor =
+ ArgumentCaptor.forClass(PluginRegistry.ActivityResultListener.class);
+ verify(mockActivityBinding).addActivityResultListener(listenerArgumentCaptor.capture());
+
+ final Intent resultMockIntent = mock(Intent.class);
+ final ClipData mockClipData = mock(ClipData.class);
+ when(mockClipData.getItemCount()).thenReturn(1);
+
+ final ClipData.Item mockClipDataItem = mock(ClipData.Item.class);
+ when(mockClipDataItem.getUri()).thenReturn(mockUri);
+ when(mockClipData.getItemAt(0)).thenReturn(mockClipDataItem);
+
+ when(resultMockIntent.getClipData()).thenReturn(mockClipData);
+
+ listenerArgumentCaptor.getValue().onActivityResult(222, Activity.RESULT_OK, resultMockIntent);
+
+ final ArgumentCaptor<List> fileListCaptor = ArgumentCaptor.forClass(List.class);
+ verify(mockResult).success(fileListCaptor.capture());
+
+ final List<GeneratedFileSelectorApi.FileResponse> fileList = fileListCaptor.getValue();
+ assertEquals(fileList.get(0).getPath(), FileUtils.FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH);
+ assertNotNull(fileList.get(0).getFileSelectorNativeException());
+ }
+ }
+
@SuppressWarnings({"rawtypes", "unchecked"})
@Test
public void getDirectoryPathReturnsSuccessfully() {
diff --git a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java
index 7608743..a6d0f57 100644
--- a/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java
+++ b/packages/file_selector/file_selector_android/android/src/test/java/dev/flutter/packages/file_selector_android/FileUtilsTest.java
@@ -6,14 +6,12 @@
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.spy;
-import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import android.content.ContentProvider;
@@ -126,22 +124,6 @@
}
@Test
- public void getPathFromCopyOfFileFromUri_returnsNullPathWhenSecurityExceptionThrown()
- throws IOException {
- Uri uri = Uri.parse("content://dummy/dummy.png");
-
- ContentResolver mockContentResolver = mock(ContentResolver.class);
- when(mockContentResolver.openInputStream(any(Uri.class))).thenThrow(SecurityException.class);
-
- Context mockContext = mock(Context.class);
- when(mockContext.getContentResolver()).thenReturn(mockContentResolver);
-
- String path = FileUtils.getPathFromCopyOfFileFromUri(mockContext, uri);
-
- assertNull(path);
- }
-
- @Test
public void getFileExtension_returnsExpectedFileExtension() throws IOException {
Uri uri = MockContentProvider.TXT_URI;
Robolectric.buildContentProvider(MockContentProvider.class).create("dummy");
@@ -196,6 +178,18 @@
assertTrue(path.endsWith("e.f.g"));
}
+ @Test
+ public void getPathFromCopyOfFileFromUri_sanitizesPathIndirection() throws IOException {
+ Uri uri = Uri.parse(MockMaliciousContentProvider.PNG_URI);
+ Robolectric.buildContentProvider(MockMaliciousContentProvider.class).create("dummy");
+ shadowContentResolver.registerInputStream(
+ uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8)));
+ String path = FileUtils.getPathFromCopyOfFileFromUri(context, uri);
+ assertNotNull(path);
+ assertTrue(path.endsWith("_bar.png"));
+ assertFalse(path.contains(".."));
+ }
+
private static class MockContentProvider extends ContentProvider {
public static final Uri TXT_URI = Uri.parse("content://dummy/dummydocument");
public static final Uri PNG_URI = Uri.parse("content://dummy/a.b.png");
@@ -252,4 +246,56 @@
return 0;
}
}
+
+ // Mocks a malicious content provider attempting to use path indirection to modify files outside
+ // of the intended directory.
+ // See https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#don%27t-trust-user-input.
+ private static class MockMaliciousContentProvider extends ContentProvider {
+ public static String PNG_URI = "content://dummy/a.png";
+
+ @Override
+ public boolean onCreate() {
+ return true;
+ }
+
+ @Nullable
+ @Override
+ public Cursor query(
+ @NonNull Uri uri,
+ @Nullable String[] projection,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs,
+ @Nullable String sortOrder) {
+ MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME});
+ cursor.addRow(new Object[] {"foo/../..bar.png"});
+ return cursor;
+ }
+
+ @Nullable
+ @Override
+ public String getType(@NonNull Uri uri) {
+ return "image/png";
+ }
+
+ @Nullable
+ @Override
+ public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
+ return null;
+ }
+
+ @Override
+ public int delete(
+ @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
+ return 0;
+ }
+
+ @Override
+ public int update(
+ @NonNull Uri uri,
+ @Nullable ContentValues values,
+ @Nullable String selection,
+ @Nullable String[] selectionArgs) {
+ return 0;
+ }
+ }
}
diff --git a/packages/file_selector/file_selector_android/lib/file_selector_android.dart b/packages/file_selector/file_selector_android/lib/file_selector_android.dart
index c86f2da..6775131 100644
--- a/packages/file_selector/file_selector_android/lib/file_selector_android.dart
+++ b/packages/file_selector/file_selector_android/lib/file_selector_android.dart
@@ -3,3 +3,4 @@
// found in the LICENSE file.
export 'src/file_selector_android.dart';
+export 'src/types/native_illegal_argument_exception.dart';
diff --git a/packages/file_selector/file_selector_android/lib/src/file_selector_android.dart b/packages/file_selector/file_selector_android/lib/src/file_selector_android.dart
index bc11695..8f9e46b 100644
--- a/packages/file_selector/file_selector_android/lib/src/file_selector_android.dart
+++ b/packages/file_selector/file_selector_android/lib/src/file_selector_android.dart
@@ -8,6 +8,7 @@
import 'package:flutter/cupertino.dart';
import 'file_selector_api.g.dart';
+import 'types/native_illegal_argument_exception.dart';
/// An implementation of [FileSelectorPlatform] for Android.
class FileSelectorAndroid extends FileSelectorPlatform {
@@ -56,6 +57,9 @@
}
XFile _xFileFromFileResponse(FileResponse file) {
+ if (file.fileSelectorNativeException != null) {
+ _resolveErrorCodeAndMaybeThrow(file.fileSelectorNativeException!);
+ }
return XFile.fromData(
file.bytes,
// Note: The name parameter is not used by XFile. The XFile.name returns
@@ -95,4 +99,19 @@
extensions: extensions.toList(),
);
}
+
+ /// Translates a [FileSelectorExceptionCode] to its corresponding error and
+ /// handles throwing.
+ void _resolveErrorCodeAndMaybeThrow(
+ FileSelectorNativeException fileSelectorNativeException) {
+ switch (fileSelectorNativeException.fileSelectorExceptionCode) {
+ case FileSelectorExceptionCode.illegalArgumentException:
+ throw NativeIllegalArgumentException(
+ fileSelectorNativeException.message);
+ case (FileSelectorExceptionCode.illegalStateException ||
+ FileSelectorExceptionCode.ioException ||
+ FileSelectorExceptionCode.securityException):
+ // unused for now
+ }
+ }
}
diff --git a/packages/file_selector/file_selector_android/lib/src/file_selector_api.g.dart b/packages/file_selector/file_selector_android/lib/src/file_selector_api.g.dart
index 558ab5f..7334142 100644
--- a/packages/file_selector/file_selector_android/lib/src/file_selector_api.g.dart
+++ b/packages/file_selector/file_selector_android/lib/src/file_selector_api.g.dart
@@ -1,7 +1,7 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
-// Autogenerated from Pigeon (v22.4.2), do not edit directly.
+// Autogenerated from Pigeon (v22.6.2), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers
@@ -18,6 +18,39 @@
);
}
+enum FileSelectorExceptionCode {
+ securityException,
+ ioException,
+ illegalArgumentException,
+ illegalStateException,
+}
+
+class FileSelectorNativeException {
+ FileSelectorNativeException({
+ required this.fileSelectorExceptionCode,
+ required this.message,
+ });
+
+ FileSelectorExceptionCode fileSelectorExceptionCode;
+
+ String message;
+
+ Object encode() {
+ return <Object?>[
+ fileSelectorExceptionCode,
+ message,
+ ];
+ }
+
+ static FileSelectorNativeException decode(Object result) {
+ result as List<Object?>;
+ return FileSelectorNativeException(
+ fileSelectorExceptionCode: result[0]! as FileSelectorExceptionCode,
+ message: result[1]! as String,
+ );
+ }
+}
+
class FileResponse {
FileResponse({
required this.path,
@@ -25,6 +58,7 @@
this.name,
required this.size,
required this.bytes,
+ this.fileSelectorNativeException,
});
String path;
@@ -37,6 +71,8 @@
Uint8List bytes;
+ FileSelectorNativeException? fileSelectorNativeException;
+
Object encode() {
return <Object?>[
path,
@@ -44,6 +80,7 @@
name,
size,
bytes,
+ fileSelectorNativeException,
];
}
@@ -55,6 +92,7 @@
name: result[2] as String?,
size: result[3]! as int,
bytes: result[4]! as Uint8List,
+ fileSelectorNativeException: result[5] as FileSelectorNativeException?,
);
}
}
@@ -92,11 +130,17 @@
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
- } else if (value is FileResponse) {
+ } else if (value is FileSelectorExceptionCode) {
buffer.putUint8(129);
+ writeValue(buffer, value.index);
+ } else if (value is FileSelectorNativeException) {
+ buffer.putUint8(130);
+ writeValue(buffer, value.encode());
+ } else if (value is FileResponse) {
+ buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is FileTypes) {
- buffer.putUint8(130);
+ buffer.putUint8(132);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
@@ -107,8 +151,13 @@
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
- return FileResponse.decode(readValue(buffer)!);
+ final int? value = readValue(buffer) as int?;
+ return value == null ? null : FileSelectorExceptionCode.values[value];
case 130:
+ return FileSelectorNativeException.decode(readValue(buffer)!);
+ case 131:
+ return FileResponse.decode(readValue(buffer)!);
+ case 132:
return FileTypes.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);
diff --git a/packages/file_selector/file_selector_android/lib/src/types/native_illegal_argument_exception.dart b/packages/file_selector/file_selector_android/lib/src/types/native_illegal_argument_exception.dart
new file mode 100644
index 0000000..b22be80
--- /dev/null
+++ b/packages/file_selector/file_selector_android/lib/src/types/native_illegal_argument_exception.dart
@@ -0,0 +1,17 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/// A representation of a Java IllegalArgumentException in dart.
+class NativeIllegalArgumentException implements Exception {
+ /// Creates a [NativeIllegalArgumentException].
+ NativeIllegalArgumentException(this.message);
+
+ /// The message provided by the native error.
+ final String message;
+
+ @override
+ String toString() {
+ return 'NativeIllegalArgumentException($message)';
+ }
+}
diff --git a/packages/file_selector/file_selector_android/pigeons/file_selector_api.dart b/packages/file_selector/file_selector_android/pigeons/file_selector_api.dart
index 3a6c522..409eec5 100644
--- a/packages/file_selector/file_selector_android/pigeons/file_selector_api.dart
+++ b/packages/file_selector/file_selector_android/pigeons/file_selector_api.dart
@@ -16,12 +16,25 @@
copyrightHeader: 'pigeons/copyright.txt',
),
)
+enum FileSelectorExceptionCode {
+ securityException, // unused
+ ioException, // unused
+ illegalArgumentException,
+ illegalStateException, //unused
+}
+
+class FileSelectorNativeException implements Exception {
+ late final FileSelectorExceptionCode fileSelectorExceptionCode;
+ late final String message;
+}
+
class FileResponse {
late final String path;
late final String? mimeType;
late final String? name;
late final int size;
late final Uint8List bytes;
+ late final FileSelectorNativeException? fileSelectorNativeException;
}
class FileTypes {
diff --git a/packages/file_selector/file_selector_android/pubspec.yaml b/packages/file_selector/file_selector_android/pubspec.yaml
index 76087f6..e70379f 100644
--- a/packages/file_selector/file_selector_android/pubspec.yaml
+++ b/packages/file_selector/file_selector_android/pubspec.yaml
@@ -2,7 +2,7 @@
description: Android implementation of the file_selector package.
repository: https://github.com/flutter/packages/tree/main/packages/file_selector/file_selector_android
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+file_selector%22
-version: 0.5.1+11
+version: 0.5.1+12
environment:
sdk: ^3.5.0