Skip to content

Commit f7667a6

Browse files

File tree

12 files changed

+616
-54
lines changed

12 files changed

+616
-54
lines changed

packages/file_selector/file_selector_android/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.5.1+12
2+
3+
* Fixes a security issue related to improperly trusting filenames provided by a `ContentProvider`.
4+
15
## 0.5.1+11
26

37
* Bumps androidx.annotation:annotation from 1.9.0 to 1.9.1.

packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileSelectorApiImpl.java

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
package dev.flutter.packages.file_selector_android;
66

7+
import static dev.flutter.packages.file_selector_android.FileUtils.FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH;
8+
79
import android.annotation.TargetApi;
810
import android.app.Activity;
911
import android.content.ClipData;
@@ -357,15 +359,40 @@ GeneratedFileSelectorApi.FileResponse toFileResponse(@NonNull Uri uri) {
357359
return null;
358360
}
359361

360-
final String uriPath =
361-
FileUtils.getPathFromCopyOfFileFromUri(activityPluginBinding.getActivity(), uri);
362+
String uriPath;
363+
GeneratedFileSelectorApi.FileSelectorNativeException nativeError = null;
364+
365+
try {
366+
uriPath = FileUtils.getPathFromCopyOfFileFromUri(activityPluginBinding.getActivity(), uri);
367+
} catch (IOException e) {
368+
// If closing the output stream fails, we cannot be sure that the
369+
// target file was written in full. Flushing the stream merely moves
370+
// the bytes into the OS, not necessarily to the file.
371+
uriPath = null;
372+
} catch (SecurityException e) {
373+
// Calling `ContentResolver#openInputStream()` has been reported to throw a
374+
// `SecurityException` on some devices in certain circumstances. Instead of crashing, we
375+
// return `null`.
376+
//
377+
// See https://github.com/flutter/flutter/issues/100025 for more details.
378+
uriPath = null;
379+
} catch (IllegalArgumentException e) {
380+
uriPath = FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH;
381+
nativeError =
382+
new GeneratedFileSelectorApi.FileSelectorNativeException.Builder()
383+
.setMessage(e.getMessage() == null ? "" : e.getMessage())
384+
.setFileSelectorExceptionCode(
385+
GeneratedFileSelectorApi.FileSelectorExceptionCode.ILLEGAL_ARGUMENT_EXCEPTION)
386+
.build();
387+
}
362388

363389
return new GeneratedFileSelectorApi.FileResponse.Builder()
364390
.setName(name)
365391
.setBytes(bytes)
366392
.setPath(uriPath)
367393
.setMimeType(contentResolver.getType(uri))
368394
.setSize(size.longValue())
395+
.setFileSelectorNativeException(nativeError)
369396
.build();
370397
}
371398
}

packages/file_selector/file_selector_android/android/src/main/java/dev/flutter/packages/file_selector_android/FileUtils.java

Lines changed: 55 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ public class FileUtils {
4545
/** URI authority that represents access to external storage providers. */
4646
public static final String EXTERNAL_DOCUMENT_AUTHORITY = "com.android.externalstorage.documents";
4747

48+
public static final String FILE_SELECTOR_EXCEPTION_PLACEHOLDER_PATH = "FILE_SELECTOR_EXCEPTION";
49+
4850
/**
4951
* Retrieves path of directory represented by the specified {@code Uri}.
5052
*
@@ -98,6 +100,12 @@ public static String getPathFromUri(@NonNull Context context, @NonNull Uri uri)
98100
* Copies the file from the given content URI to a temporary directory, retaining the original
99101
* file name if possible.
100102
*
103+
* <p>If the filename contains path indirection or separators (.. or /), the end file name will be
104+
* the segment after the final separator, with indirection replaced by underscores. E.g.
105+
* "example/../..file.png" -> "_file.png". See: <a
106+
* href="https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename">Improperly
107+
* trusting ContentProvider-provided filename</a>.
108+
*
101109
* <p>Each file is placed in its own directory to avoid conflicts according to the following
102110
* scheme: {cacheDir}/{randomUuid}/{fileName}
103111
*
@@ -111,7 +119,8 @@ public static String getPathFromUri(@NonNull Context context, @NonNull Uri uri)
111119
* or if a security exception is encountered when opening the input stream to start the copying.
112120
*/
113121
@Nullable
114-
public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @NonNull Uri uri) {
122+
public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @NonNull Uri uri)
123+
throws IOException, SecurityException, IllegalArgumentException {
115124
try (InputStream inputStream = context.getContentResolver().openInputStream(uri)) {
116125
String uuid = UUID.nameUUIDFromBytes(uri.toString().getBytes()).toString();
117126
File targetDirectory = new File(context.getCacheDir(), uuid);
@@ -122,32 +131,21 @@ public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @Non
122131

123132
if (fileName == null) {
124133
if (extension == null) {
125-
throw new IllegalArgumentException("No name nor extension found for file.");
134+
throw new IllegalStateException("No name nor extension found for file.");
126135
} else {
127136
fileName = "file_selector" + extension;
128137
}
129138
} else if (extension != null) {
130139
fileName = getBaseName(fileName) + extension;
131140
}
132141

133-
File file = new File(targetDirectory, fileName);
142+
String filePath = new File(targetDirectory, fileName).getPath();
143+
File outputFile = saferOpenFile(filePath, targetDirectory.getCanonicalPath());
134144

135-
try (OutputStream outputStream = new FileOutputStream(file)) {
145+
try (OutputStream outputStream = new FileOutputStream(outputFile)) {
136146
copy(inputStream, outputStream);
137-
return file.getPath();
147+
return outputFile.getPath();
138148
}
139-
} catch (IOException e) {
140-
// If closing the output stream fails, we cannot be sure that the
141-
// target file was written in full. Flushing the stream merely moves
142-
// the bytes into the OS, not necessarily to the file.
143-
return null;
144-
} catch (SecurityException e) {
145-
// Calling `ContentResolver#openInputStream()` has been reported to throw a
146-
// `SecurityException` on some devices in certain circumstances. Instead of crashing, we
147-
// return `null`.
148-
//
149-
// See https://github.com/flutter/flutter/issues/100025 for more details.
150-
return null;
151149
}
152150
}
153151

@@ -172,14 +170,17 @@ private static String getFileExtension(Context context, Uri uriFile) {
172170
return null;
173171
}
174172

175-
return "." + extension;
173+
return "." + sanitizeFilename(extension);
176174
}
177175

178176
/** Returns the name of the file provided by ContentResolver; this may be null. */
179177
private static String getFileName(Context context, Uri uriFile) {
180178
try (Cursor cursor = queryFileName(context, uriFile)) {
181-
if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null;
182-
return cursor.getString(0);
179+
if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) {
180+
return null;
181+
}
182+
String unsanitizedFileName = cursor.getString(0);
183+
return sanitizeFilename(unsanitizedFileName);
183184
}
184185
}
185186

@@ -206,4 +207,38 @@ private static String getBaseName(String fileName) {
206207
// Basename is everything before the last '.'.
207208
return fileName.substring(0, lastDotIndex);
208209
}
210+
211+
// From https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#sanitize-provided-filenames.
212+
protected static @Nullable String sanitizeFilename(@Nullable String displayName) {
213+
if (displayName == null) {
214+
return null;
215+
}
216+
217+
String[] badCharacters = new String[] {"..", "/"};
218+
String[] segments = displayName.split("/");
219+
String fileName = segments[segments.length - 1];
220+
for (String suspString : badCharacters) {
221+
fileName = fileName.replace(suspString, "_");
222+
}
223+
return fileName;
224+
}
225+
226+
/**
227+
* Use with file name sanatization and an non-guessable directory. From
228+
* https://developer.android.com/privacy-and-security/risks/path-traversal#path-traversal-mitigations.
229+
*/
230+
protected static @NonNull File saferOpenFile(@NonNull String path, @NonNull String expectedDir)
231+
throws IllegalArgumentException, IOException {
232+
File f = new File(path);
233+
String canonicalPath = f.getCanonicalPath();
234+
if (!canonicalPath.startsWith(expectedDir)) {
235+
throw new IllegalArgumentException(
236+
"Trying to open path outside of the expected directory. File: "
237+
+ f.getCanonicalPath()
238+
+ " was expected to be within directory: "
239+
+ expectedDir
240+
+ ".");
241+
}
242+
return f;
243+
}
209244
}

0 commit comments

Comments
 (0)