CVE-2026-118309
Overview
Background
- `SelectFileDialog` (Android)
- Blink/Chrome’s Android file picker (
ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java). When a chooser returns acontent://URI, Chrome must decide whether the returned file is safe to hand to the web page or whether it points back into Chrome’s own app-private storage (which would be an exfiltration of app-internal files). - `isContentUriUnderAppDir(Uri, Context)`
- Helper that decides whether a content URI resolves to a file inside the app’s own directory. It opens the URI, resolves the real path via
/proc/self/fd/<fd>symlink, and (per its callers) treats “under app dir” as something to reject/abort. Returningtruemeans “block this URI”. - `Uri` object vs `Uri.parse(String)`
- Android
Uribuilt viaUri.Buildercan carry opaque parts, odd authorities, or encodings that differ from what you get by round-tripping throughtoString()+Uri.parse(). Native code later opens the URI from its string form viaUri.parse(), so the Java object the check inspects and the URI the native layer actually opens can differ. - `ContentUriUtils.isUriFromThisApp` / `normalizeScheme()`
- Helpers to normalize the scheme (e.g.
CONTENT→content) and to detect a provider owned by Chrome’s own UID/package.
Root Cause Analysis
The vulnerability is a parser-differential / TOCTOU-style check bypass, not “SVG” and only loosely “input validation in UI” as the advisory states. The old isContentUriUnderAppDir immediately called context.getContentResolver().openFileDescriptor(uri, "r") on the original Uri object and resolved the fd to a path. Because the native side re-parses the URI from its string form (Uri.parse(normalized.toString())), an attacker could craft a Uri whose object form resolves to a benign/external location (passing the check) while its string form re-parses to a different authority — e.g. a Chrome-owned content provider — so native opens an app-private file the check believed it had cleared. Opaque content URIs (isOpaque()), empty authorities, and case-variant schemes (CONTENT://) produce exactly this object-vs-string divergence.
The patch aligns the check with how native actually resolves the URI: it normalizeScheme()s the URI, re-parses the normalized string via Uri.parse(normalized.toString()), and then rejects (returns true = block) if the original or re-parsed URI is opaque, if the re-parsed authority is empty, if the normalized and re-parsed authorities disagree, or if ContentUriUtils.isUriFromThisApp(parsed, context) is true (the provider belongs to Chrome itself). Only after these gates does it openFileDescriptor(parsed, "r") on the re-parsed URI, and it now returns false on a null pfd instead of asserting. The new testContentSchemeRejectsOpaqueAndSameAppUris test enumerates the bypass vectors (opaque encoded parts, CONTENT case, empty authority content:///…, same-app provider) and asserts the picker aborts for each while a genuinely external content://com.example.external/… still succeeds.
Uri.parse(uri.toString())); an opaque, empty-authority, case-variant, or same-app content URI could pass the “not under app dir” check yet be re-parsed by native into a Chrome-owned provider, letting the page read app-private files.Attack Path
- Entry
Attacker-influenced content supplies a crafted
content://URI to the Android file picker result (viaACTION_VIEWdata orClipData), as exercised byonIntentCompleted. (reachability consistent with the test harness) - Craft the differential
Build a URI whose
Uriobject form resolves to an allowed/external location but whosetoString()+Uri.parse()form yields a different authority — e.g. an opaque encoded part, empty authority, mixed-caseCONTENT, or a Chrome-owned provider authority. - Pass the old check
isContentUriUnderAppDiropens the object form, resolves a benign path, and reports “not under app dir”, so the picker accepts the file. - Native opens the real target
Native re-parses the string form via
Uri.parse()and opens the different, app-private file. - Primitive The web origin receives the contents of a Chrome-app-internal file it was never authorized to read (cross-privilege file disclosure / sandbox policy bypass).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
forui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java |
modified |
Files Changed
ui/android/java/src/org/chromium/ui/base/SelectFileDialog.javaui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java
Audit Directions
- URI representation consistencyAudit every place Chrome decides a security property on a
Uriobject but later acts onUri.parse(uri.toString())(or passes the string to native); the check and the use must operate on the same normalized, re-parsed form. - Opaque / authority edge casesReview other content-URI gatekeepers (sharing, downloads, intent handling) for failure to reject
isOpaque()URIs, empty/variant authorities, or same-UID providers. - File picker result handlingTrace all
onIntentCompleted/ClipDatapaths that resolve returned content URIs to paths, ensuring app-private directories and Chrome-owned providers are consistently excluded.
Patch
From f90a000c395c8b07ea085cd23122bc3b6eb24515 Mon Sep 17 00:00:00 2001 From: Calder Kitagawa <[email protected]> Date: Thu, 22 Oct 2026 14:43:25 -0700 Subject: [PATCH] [Android] Normalize content URI handling in SelectFileDialog Align SelectFileDialog.isContentUriUnderAppDir() content URI checks with ContentUriUtils. Fixed: 576133777 Change-Id: I6961adb7d30175b31ba8697b096156886a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8631697 Reviewed-by: Charles Cai <[email protected]> Reviewed-by: Sky Malice <[email protected]> Commit-Queue: Calder Kitagawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1725217} --- diff --git a/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java b/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java index 35262c3..34b68ed6 100644 --- a/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java +++ b/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java @@ -1059,8 +1059,19 @@ public static boolean isContentUriUnderAppDir(Uri uri, Context context) { assert !ThreadUtils.runningOnUiThread(); - try (ParcelFileDescriptor pfd = context.getContentResolver().openFileDescriptor(uri, "r")) { - assumeNonNull(pfd); + // Re-parse the URI string since native opens the string form via Uri.parse(). + Uri normalized = uri.normalizeScheme(); + Uri parsed = Uri.parse(normalized.toString()); + if (uri.isOpaque() + || parsed.isOpaque() + || TextUtils.isEmpty(parsed.getAuthority()) + || !TextUtils.equals(normalized.getAuthority(), parsed.getAuthority()) + || ContentUriUtils.isUriFromThisApp(parsed, context)) { + return true; + } + try (ParcelFileDescriptor pfd = + context.getContentResolver().openFileDescriptor(parsed, "r")) { + if (pfd == null) return false; int fd = pfd.getFd(); // Use the file descriptor to find out the read file path thru symbolic link. Path fdPath = Paths.get("/proc/self/fd/" + fd); diff --git a/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java b/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java index cca293a..2ea936b 100644 --- a/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java +++ b/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java @@ -21,11 +21,14 @@ import android.app.Activity; import android.content.ClipData; import android.content.ContentResolver; +import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.pm.ProviderInfo; import android.database.Cursor; import android.net.Uri; import android.os.Build; +import android.os.Process; import android.provider.DocumentsContract; import android.provider.MediaStore; import android.webkit.MimeTypeMap; @@ -47,6 +50,7 @@ import org.robolectric.Shadows; import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowMimeTypeMap; +import org.robolectric.shadows.ShadowPackageManager; import org.chromium.base.ContextUtils; import org.chromium.base.DeviceInfo; @@ -1393,6 +1397,65 @@ } @Test + @DisableFeatures({UiAndroidFeatures.CHECK_INTENT_CALLER_PERMISSION}) + public void testContentSchemeRejectsOpaqueAndSameAppUris() { + Context context = ContextUtils.getApplicationContext(); + String pkg = context.getPackageName(); + String sameAppAuthority = pkg + ".TestProvider"; + ShadowPackageManager shadowPm = Shadows.shadowOf(context.getPackageManager()); + shadowPm.getInternalMutablePackageInfo(pkg).applicationInfo.uid = Process.myUid(); + ProviderInfo providerInfo = new ProviderInfo(); + providerInfo.authority = sameAppAuthority; + providerInfo.packageName = pkg; + providerInfo.name = "TestProvider"; + providerInfo.applicationInfo = shadowPm.getInternalMutablePackageInfo(pkg).applicationInfo; + shadowPm.addOrUpdateProvider(providerInfo); + + Uri[] rejectedUris = + new Uri[] { + new Uri.Builder() + .scheme("content") + .encodedOpaquePart("//com.example.app.provider/files/doc.txt") + .build(), + new Uri.Builder() + .scheme("CONTENT") + .encodedOpaquePart("//com.example.app.provider/files/doc.txt") + .build(), + new Uri.Builder() + .scheme("content") + .encodedPath("//com.example.external/doc.txt") + .build(), + Uri.parse("content:///files/doc.txt"), + Uri.parse("content://" + sameAppAuthority + "/files/missing.txt"), + }; + + TestSelectFileDialog testDialog = new TestSelectFileDialog(null); + for (Uri uri : rejectedUris) { + Intent dataIntent = new Intent(Intent.ACTION_VIEW, uri); + testDialog.onIntentCompleted(Activity.RESULT_OK, dataIntent); + runAllAsyncTasks(); + assertEquals("Expected 0 successes for " + uri, 0, testDialog.mFileSelectionSuccess); + assertEquals("Expected 1 abort for " + uri, 1, testDialog.mFileSelectionAborted); + testDialog.resetFileSelectionAttempts(); + + Intent clipIntent = new Intent(); + clipIntent.setClipData(new ClipData("label", new String[0], new ClipData.Item(uri))); + testDialog.onIntentCompleted(Activity.RESULT_OK, clipIntent); + runAllAsyncTasks(); + assertEquals("Expected 0 successes for " + uri, 0, testDialog.mFileSelectionSuccess); + assertEquals("Expected 1 abort for " + uri, 1, testDialog.mFileSelectionAborted); + testDialog.resetFileSelectionAttempts(); + } + + Uri externalUri = Uri.parse("content://com.example.external/doc.txt"); + testDialog.onIntentCompleted( + Activity.RESULT_OK, new Intent(Intent.ACTION_VIEW, externalUri)); + runAllAsyncTasks(); + assertEquals(1, testDialog.mFileSelectionSuccess); + assertEquals(0, testDialog.mFileSelectionAborted); + } + + @Test public void testShowTypes() { ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); shadowMimeTypeMap.addExtensionMimeTypeMapping("jpg", "image/jpeg");