-
Notifications
You must be signed in to change notification settings - Fork 3.8k
[file_selector_android] Refactor interactions with ContentProvider provided filenames
#8184
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
923d124
6ea28a1
301eea6
0eae935
0c70fa0
6ca5f7f
5142fbc
8be370a
2f3fdf2
24f329d
4be758b
1631f9f
7b5897e
52324b7
88717fe
314621c
c3d1d5d
c9f7c82
30609b1
7332b4f
2f881f3
131afe6
7f61bbc
996eba0
18cb046
af479e9
3c4d7fb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,8 @@ public class FileUtils { | |
| /** 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 @@ public static String getPathFromUri(@NonNull Context context, @NonNull Uri uri) | |
| * 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 @@ public static String getPathFromUri(@NonNull Context context, @NonNull Uri uri) | |
| * 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,32 +131,21 @@ public static String getPathFromCopyOfFileFromUri(@NonNull Context context, @Non | |
|
|
||
| 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; | ||
| } | ||
| } else if (extension != null) { | ||
| fileName = getBaseName(fileName) + extension; | ||
| } | ||
|
|
||
| File file = new File(targetDirectory, fileName); | ||
| String filePath = new File(targetDirectory, fileName).getPath(); | ||
| File outputFile = saferOpenFile(filePath, targetDirectory.getCanonicalPath()); | ||
|
reidbaker marked this conversation as resolved.
|
||
|
|
||
| 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 @@ private static String getFileExtension(Context context, Uri uriFile) { | |
| 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 @@ private static String getBaseName(String fileName) { | |
| // 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) { | ||
|
gmackall marked this conversation as resolved.
|
||
| 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)) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In theory, this check would allow a path traversal into a directory that has String expectedDir = "/data/data/com.app/cache/123";
String path = "/data/data/com.app/cache/123/../12345/traversal.png";
String canonicalPath = "/data/data/com.app/cache/12345/traversal.png";
canonicalPath.startsWith(expectedDir); // trueHowever, because of the filename sanitization earlier, path traversal sequences are already prevented and this cannot occur. Additionally, the expected directory is an unknown UUID. So, this is not an issue here; I just wanted to mention it for next time. :)
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this would be an interesting comment to convey to the authors of that method/maintainers of the page, I can try to bring it to their attention. Presumably this would be fixed by making the check instead be canonicalPath.startsWith(expectedDir + "/")(after optionally stripping a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I found a specific GitHub CodeQL docs page about the Partial Path Traversal issue that explains a better mitigation using |
||
| throw new IllegalArgumentException( | ||
| "Trying to open path outside of the expected directory. File: " | ||
| + f.getCanonicalPath() | ||
| + " was expected to be within directory: " | ||
| + expectedDir | ||
| + "."); | ||
| } | ||
| return f; | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.