Skip to content

Commit bc0c22d

Browse files
authored
1 parent f7667a6 commit bc0c22d

File tree

7 files changed

+127
-9
lines changed

7 files changed

+127
-9
lines changed

packages/image_picker/image_picker_android/CHANGELOG.md

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

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

packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/FileUtils.java

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@
2929
import android.net.Uri;
3030
import android.provider.MediaStore;
3131
import android.webkit.MimeTypeMap;
32+
import androidx.annotation.NonNull;
33+
import androidx.annotation.Nullable;
3234
import io.flutter.Log;
3335
import java.io.File;
3436
import java.io.FileOutputStream;
@@ -42,6 +44,12 @@ class FileUtils {
4244
* Copies the file from the given content URI to a temporary directory, retaining the original
4345
* file name if possible.
4446
*
47+
* <p>If the filename contains path indirection or separators (.. or /), the end file name will be
48+
* the segment after the final separator, with indirection replaced by underscores. E.g.
49+
* "example/../..file.png" -> "_file.png". See: <a
50+
* href="https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename">Improperly
51+
* trusting ContentProvider-provided filename</a>.
52+
*
4553
* <p>Each file is placed in its own directory to avoid conflicts according to the following
4654
* scheme: {cacheDir}/{randomUuid}/{fileName}
4755
*
@@ -69,10 +77,11 @@ String getPathFromUri(final Context context, final Uri uri) {
6977
} else if (extension != null) {
7078
fileName = getBaseName(fileName) + extension;
7179
}
72-
File file = new File(targetDirectory, fileName);
73-
try (OutputStream outputStream = new FileOutputStream(file)) {
80+
String filePath = new File(targetDirectory, fileName).getPath();
81+
File outputFile = saferOpenFile(filePath, targetDirectory.getCanonicalPath());
82+
try (OutputStream outputStream = new FileOutputStream(outputFile)) {
7483
copy(inputStream, outputStream);
75-
return file.getPath();
84+
return outputFile.getPath();
7685
}
7786
} catch (IOException e) {
7887
// If closing the output stream fails, we cannot be sure that the
@@ -86,6 +95,10 @@ String getPathFromUri(final Context context, final Uri uri) {
8695
//
8796
// See https://github.com/flutter/flutter/issues/100025 for more details.
8897
return null;
98+
} catch (IllegalArgumentException e) {
99+
// This is likely a result of an IllegalArgumentException that we have thrown in
100+
// saferOpenFile(). TODO(gmackall): surface this error in dart.
101+
return null;
89102
}
90103
}
91104

@@ -110,14 +123,49 @@ private static String getImageExtension(Context context, Uri uriImage) {
110123
return null;
111124
}
112125

113-
return "." + extension;
126+
return "." + sanitizeFilename(extension);
127+
}
128+
129+
// From https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#sanitize-provided-filenames.
130+
protected static @Nullable String sanitizeFilename(@Nullable String displayName) {
131+
if (displayName == null) {
132+
return null;
133+
}
134+
135+
String[] badCharacters = new String[] {"..", "/"};
136+
String[] segments = displayName.split("/");
137+
String fileName = segments[segments.length - 1];
138+
for (String suspString : badCharacters) {
139+
fileName = fileName.replace(suspString, "_");
140+
}
141+
return fileName;
142+
}
143+
144+
/**
145+
* Use with file name sanitization and an non-guessable directory. From <a
146+
* href="https://developer.android.com/privacy-and-security/risks/path-traversal#path-traversal-mitigations">...</a>.
147+
*/
148+
protected static @NonNull File saferOpenFile(@NonNull String path, @NonNull String expectedDir)
149+
throws IllegalArgumentException, IOException {
150+
File f = new File(path);
151+
String canonicalPath = f.getCanonicalPath();
152+
if (!canonicalPath.startsWith(expectedDir)) {
153+
throw new IllegalArgumentException(
154+
"Trying to open path outside of the expected directory. File: "
155+
+ f.getCanonicalPath()
156+
+ " was expected to be within directory: "
157+
+ expectedDir
158+
+ ".");
159+
}
160+
return f;
114161
}
115162

116163
/** @return name of the image provided by ContentResolver; this may be null. */
117164
private static String getImageName(Context context, Uri uriImage) {
118165
try (Cursor cursor = queryImageName(context, uriImage)) {
119166
if (cursor == null || !cursor.moveToFirst() || cursor.getColumnCount() < 1) return null;
120-
return cursor.getString(0);
167+
String unsanitizedImageName = cursor.getString(0);
168+
return sanitizeFilename(unsanitizedImageName);
121169
}
122170
}
123171

packages/image_picker/image_picker_android/android/src/main/java/io/flutter/plugins/imagepicker/Messages.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.1), do not edit directly.
4+
// Autogenerated from Pigeon (v22.6.2), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66

77
package io.flutter.plugins.imagepicker;

packages/image_picker/image_picker_android/android/src/test/java/io/flutter/plugins/imagepicker/FileUtilTest.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import static java.nio.charset.StandardCharsets.UTF_8;
88
import static org.junit.Assert.assertEquals;
9+
import static org.junit.Assert.assertFalse;
10+
import static org.junit.Assert.assertNotNull;
911
import static org.junit.Assert.assertNull;
1012
import static org.junit.Assert.assertTrue;
1113
import static org.mockito.ArgumentMatchers.any;
@@ -129,6 +131,18 @@ public void FileUtil_getImageName_mismatchedType() throws IOException {
129131
assertTrue(path.endsWith("c.d.webp"));
130132
}
131133

134+
@Test
135+
public void getPathFromUri_sanitizesPathIndirection() {
136+
Uri uri = Uri.parse(MockMaliciousContentProvider.PNG_URI);
137+
Robolectric.buildContentProvider(MockMaliciousContentProvider.class).create("dummy");
138+
shadowContentResolver.registerInputStream(
139+
uri, new ByteArrayInputStream("fileStream".getBytes(UTF_8)));
140+
String path = fileUtils.getPathFromUri(context, uri);
141+
assertNotNull(path);
142+
assertTrue(path.endsWith("_bar.png"));
143+
assertFalse(path.contains(".."));
144+
}
145+
132146
@Test
133147
public void FileUtil_getImageName_unknownType() throws IOException {
134148
Uri uri = MockContentProvider.UNKNOWN_URI;
@@ -193,4 +207,56 @@ public int update(
193207
return 0;
194208
}
195209
}
210+
211+
// Mocks a malicious content provider attempting to use path indirection to modify files outside
212+
// of the intended directory.
213+
// See https://developer.android.com/privacy-and-security/risks/untrustworthy-contentprovider-provided-filename#don%27t-trust-user-input.
214+
private static class MockMaliciousContentProvider extends ContentProvider {
215+
public static String PNG_URI = "content://dummy/a.png";
216+
217+
@Override
218+
public boolean onCreate() {
219+
return true;
220+
}
221+
222+
@Nullable
223+
@Override
224+
public Cursor query(
225+
@NonNull Uri uri,
226+
@Nullable String[] projection,
227+
@Nullable String selection,
228+
@Nullable String[] selectionArgs,
229+
@Nullable String sortOrder) {
230+
MatrixCursor cursor = new MatrixCursor(new String[] {MediaStore.MediaColumns.DISPLAY_NAME});
231+
cursor.addRow(new Object[] {"foo/../..bar.png"});
232+
return cursor;
233+
}
234+
235+
@Nullable
236+
@Override
237+
public String getType(@NonNull Uri uri) {
238+
return "image/png";
239+
}
240+
241+
@Nullable
242+
@Override
243+
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
244+
return null;
245+
}
246+
247+
@Override
248+
public int delete(
249+
@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
250+
return 0;
251+
}
252+
253+
@Override
254+
public int update(
255+
@NonNull Uri uri,
256+
@Nullable ContentValues values,
257+
@Nullable String selection,
258+
@Nullable String[] selectionArgs) {
259+
return 0;
260+
}
261+
}
196262
}

packages/image_picker/image_picker_android/lib/src/messages.g.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.1), do not edit directly.
4+
// Autogenerated from Pigeon (v22.6.2), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// 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
77

packages/image_picker/image_picker_android/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: image_picker_android
22
description: Android implementation of the image_picker plugin.
33
repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_android
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22
5-
version: 0.8.12+17
5+
version: 0.8.12+18
66

77
environment:
88
sdk: ^3.5.0

packages/image_picker/image_picker_android/test/test_api.g.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright 2013 The Flutter Authors. All rights reserved.
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
4-
// Autogenerated from Pigeon (v22.4.1), do not edit directly.
4+
// Autogenerated from Pigeon (v22.6.2), do not edit directly.
55
// See also: https://pub.dev/packages/pigeon
66
// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, unnecessary_import, no_leading_underscores_for_local_identifiers
77
// ignore_for_file: avoid_relative_lib_imports

0 commit comments

Comments
 (0)