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 <ckitagawa@chromium.org>
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 <charlesyc@google.com>
Reviewed-by: Sky Malice <skym@chromium.org>
Commit-Queue: Calder Kitagawa <ckitagawa@chromium.org>
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");