@@ -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