CVE-2026-118314
Overview
Background
- `SelectFileDialog`
- Android file-picker glue (
ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java) that converts the URIs returned by the Android picker into file paths handed to the browser for opening. - `file:` URI forms
- a hierarchical
file:///pathURI has a real path component; an opaque (non-hierarchical)file:URI such asfile:content://authority/pathhas no path and only a scheme-specific part. Relativefile:URIs (file:valid.jpg) likewise lack an absolute path. - `Uri.getPath()` vs `Uri.getSchemeSpecificPart()`
getPath()returns the hierarchical path (or null for opaque URIs);getSchemeSpecificPart()returns everything after the scheme, so forfile:content://...it returns the barecontent://...string.- `resolveRegularFilePath()`
- canonicalizes a caller-supplied path with
realpath()and returns it only if it names an existing regular file outside procfs. - `GetDisplayNameTask` / `onIntentCompleted()`
- two code paths that process picker results; the former extracted the path via
getSchemeSpecificPart(), the latter already usedgetPath(), so they disagreed.
Root Cause Analysis
The two paths that turn a file: URI into a path were inconsistent, and the inconsistency was security-relevant. In GetDisplayNameTask, a file: URI was reduced to a path via mUris[i].getSchemeSpecificPart(). For an opaque file: URI like file:content://com.android.chrome.FileProvider/images/pic.jpg, that call returns the string content://com.android.chrome.FileProvider/images/pic.jpg — a bare content URI, not a filesystem path. As the added comment explains, native would then open that string through ContentResolver, bypassing the content-URI checks that the code performs for legitimately content-scheme URIs. In other words, an attacker-influenced picker result could wrap a content URI inside an opaque file: URI to slip past the content-URI access controls, and relative file: URIs (file:valid.jpg) could likewise reach realpath() and resolve to unintended files. resolveRegularFilePath() compounded this by only checking TextUtils.isEmpty(path) before canonicalizing, so any non-absolute string was accepted into FileUtils.getAbsoluteFilePath().
The patch enforces that a file: URI must be hierarchical and absolute. GetDisplayNameTask now calls mUris[i].getPath() (returning null for opaque URIs) to match onIntentCompleted(), and resolveRegularFilePath() rejects any path that is empty or does not start with / (!path.startsWith("/")), its parameter made @Nullable. Together these reject opaque file:content://… URIs and relative paths before path canonicalization, so a content URI can no longer be laundered into the file path and must go through the proper content-URI checks. The advisory’s label Improper input validation in UI matches: this is missing validation of the URI shape that enabled a check bypass. The new testFileSchemeRejectsOpaqueAndRelativeUris test asserts opaque/relative URIs are rejected end-to-end and never reach getAbsoluteFilePath(), while a valid hierarchical file:/// URI still succeeds.
GetDisplayNameTask extracted a file: URI’s path with getSchemeSpecificPart(), so an opaque file:content://… URI yielded a bare content URI that native opened via ContentResolver, bypassing the content-URI access checks — a validation gap fixed by requiring a hierarchical, absolute path.Attack Path
- Craft a URI
Attacker (a malicious/colluding app returning an intent result, or any flow that can influence the picker’s returned URIs) supplies an opaque
file:content://com.android.chrome.FileProvider/images/pic.jpg. (reachability via intent result inferred from the component) - Path extraction
GetDisplayNameTaskcallsgetSchemeSpecificPart(), extractingcontent://com.android.chrome.FileProvider/images/pic.jpg. - Check bypass
Because the string arrived as a
file:URI, it skips the content-URI validation and is forwarded as a path; native opens it throughContentResolver. - Unintended access The resolved content URI points at a file/provider the content-URI checks would have blocked (or a relative path resolves to an unintended regular file).
- Primitive Access to a file the user did not select / that the access checks would deny, i.e., a content-URI access-control bypass. (strength beyond unintended read inferred)
Impact Assessment
SelectFileDialog from the picker/intent result. No memory-safety sink is involved — the defect is a logic/validation gap that lets an opaque file: URI smuggle a content URI past the content-URI checks, yielding unintended file/provider access rather than memory corruption. Privilege required is the ability to influence the returned intent URIs (e.g., a malicious app participating in the picker flow). The concrete gain is reading/forwarding a file or content URI the user did not choose and that the checks would otherwise reject; broader escalation is (inferred). The advisory’s “improper input validation” is accurate for this diff.Changed Functions
| Function | Change | Notes |
|---|---|---|
forui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java |
modified |
Files Changed
ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.javaui/android/java/src/org/chromium/ui/base/SelectFileDialog.java
Audit Directions
- `getSchemeSpecificPart()` on `file:`/`content:` URIsGrep Android glue for any URI-to-path conversion using
getSchemeSpecificPart(); it returns laundered content for opaque URIs and should generally begetPath(). - Divergent URI handling between code pathsCompare every picker/intent-result path (single-file vs. multi-file vs. display-name) for consistent scheme/absolute-path validation; the bug was two paths disagreeing.
- Missing absolute-path checksReview callers of
realpath()/getAbsoluteFilePath()for inputs that are not validated as absolute before canonicalization. - Opaque-URI smugglingLook for other places where a
content:/file:URI could be nested to bypass scheme-specific access checks.
Patch
From 9dffe99d209ab61d768bfd3f46f55f43cea466b5 Mon Sep 17 00:00:00 2001 From: Calder Kitagawa <[email protected]> Date: Mon, 19 Oct 2026 09:04:13 -0700 Subject: [PATCH] [Android] Consistently extract absolute file paths in SelectFileDialog GetDisplayNameTask previously extracted file: URI paths via Uri.getSchemeSpecificPart(), whereas single-file selection in onIntentCompleted() uses Uri.getPath(). Aligning GetDisplayNameTask on Uri.getPath() and requiring an absolute path in resolveRegularFilePath() ensures non-hierarchical and relative file: URIs are rejected consistently before path canonicalization. Fixed: 574803484 Change-Id: I97cb96f43dcda41065c71fef8a78a4c86a6a6964 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8611108 Auto-Submit: Calder Kitagawa <[email protected]> Reviewed-by: Sky Malice <[email protected]> Commit-Queue: Calder Kitagawa <[email protected]> Cr-Commit-Position: refs/heads/main@{#1722015} --- 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 56e80f4..fa1e565 100644 --- a/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java +++ b/ui/android/java/src/org/chromium/ui/base/SelectFileDialog.java @@ -1028,11 +1028,12 @@ } /** - * Canonicalises a caller-supplied file path with realpath() and returns it only if it names an - * existing regular file that is not in procfs. Returns null otherwise. + * Canonicalises a caller-supplied file path with realpath() and returns it only if it is an + * absolute path that names an existing regular file that is not in procfs. Returns null + * otherwise. */ - private static @Nullable String resolveRegularFilePath(String path) { - if (TextUtils.isEmpty(path)) return null; + private static @Nullable String resolveRegularFilePath(@Nullable String path) { + if (TextUtils.isEmpty(path) || !path.startsWith("/")) return null; String resolved = FileUtils.getAbsoluteFilePath(path); // Reject paths such as /proc/self/maps that resolve into procfs. if (resolved.isEmpty() || resolved.startsWith("/proc/") || !new File(resolved).isFile()) { @@ -1401,12 +1402,17 @@ // device was observed to return a file:// URI instead, so convert if necessary. // See https://crbug.com/752834 for context. if (ContentResolver.SCHEME_FILE.equals(mUris[i].getScheme())) { + // Only hierarchical file:///path URIs are acceptable here. An opaque + // file: URI such as "file:content://authority/path" has no path, and its + // scheme-specific part is a bare content URI that native would open + // through ContentResolver, bypassing the content URI checks below. + // // Resolve the path *once* (realpath, follows /proc/self/fd/N magic // links and symlinks) and forward the resolved path instead of the // caller-supplied alias, so that later opens by the browser cannot be // redirected to a different file. Reject anything that does not // resolve to an existing regular file outside the app data dir. - String resolved = resolveRegularFilePath(mUris[i].getSchemeSpecificPart()); + String resolved = resolveRegularFilePath(mUris[i].getPath()); if (resolved == null || isPathUnderAppDir(resolved, mContext)) { return null; } 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 81999f05..2f4adf3 100644 --- a/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java +++ b/ui/android/junit/src/org/chromium/ui/base/SelectFileDialogTest.java @@ -1219,6 +1219,75 @@ } @Test + @DisableFeatures({UiAndroidFeatures.CHECK_INTENT_CALLER_PERMISSION}) + public void testFileSchemeRejectsOpaqueAndRelativeUris() throws IOException { + File validFile = mTempFolder.newFile("valid.jpg"); + + // Even if realpath() were to resolve a non-hierarchical or relative path to an existing + // regular file, opaque file: URIs (such as "file:content://...") and relative paths must + // be rejected before reaching FileUtils.getAbsoluteFilePath(). + Mockito.lenient() + .doReturn(validFile.getCanonicalPath()) + .when(mFileUtilsMocks) + .getAbsoluteFilePath(any()); + + SelectFileDialog selectFileDialog = new SelectFileDialog(0); + Uri[] opaqueAndRelativeUris = + new Uri[] { + Uri.parse("file:content://com.android.chrome.FileProvider/images/pic.jpg"), + Uri.parse("FILE:content://com.android.chrome.FileProvider/images/pic.jpg"), + Uri.parse("file:valid.jpg"), + Uri.fromParts("file", "valid.jpg", null), + }; + for (Uri uri : opaqueAndRelativeUris) { + SelectFileDialog.GetDisplayNameTask task = + selectFileDialog + .new GetDisplayNameTask( + ContextUtils.getApplicationContext(), + /* isMultiple= */ false, + new Uri[] {uri}); + assertEquals("Expected null for " + uri, null, task.doInBackground()); + } + + SelectFileDialog.FilePathSelectedTask relativePathTask = + selectFileDialog + .new FilePathSelectedTask(ContextUtils.getApplicationContext(), "valid.jpg", null); + assertFalse(relativePathTask.doInBackground()); + + // End-to-end onIntentCompleted checks with opaque file:content:// URI in ClipData and data. + TestSelectFileDialog testDialog = new TestSelectFileDialog(0); + Uri opaqueContentUri = + Uri.parse("file:content://com.android.chrome.FileProvider/images/pic.jpg"); + Intent clipIntent = new Intent(); + clipIntent.setClipData( + new ClipData("label", new String[0], new ClipData.Item(opaqueContentUri))); + testDialog.onIntentCompleted(Activity.RESULT_OK, clipIntent); + runAllAsyncTasks(); + assertEquals(0, testDialog.mFileSelectionSuccess); + assertEquals(1, testDialog.mFileSelectionAborted); + testDialog.resetFileSelectionAttempts(); + + Intent dataIntent = new Intent(Intent.ACTION_VIEW, opaqueContentUri); + testDialog.onIntentCompleted(Activity.RESULT_OK, dataIntent); + runAllAsyncTasks(); + assertEquals(0, testDialog.mFileSelectionSuccess); + assertEquals(1, testDialog.mFileSelectionAborted); + testDialog.resetFileSelectionAttempts(); + Mockito.verify(mFileUtilsMocks, Mockito.never()).getAbsoluteFilePath(any()); + + // Valid hierarchical file:/// URI still succeeds. + SelectFileDialog.GetDisplayNameTask validTask = + selectFileDialog + .new GetDisplayNameTask( + ContextUtils.getApplicationContext(), + /* isMultiple= */ false, + new Uri[] {Uri.fromFile(validFile)}); + assertTrue(validTask.doInBackground() != null); + Mockito.verify(mFileUtilsMocks).getAbsoluteFilePath(validFile.getPath()); + assertEquals(validFile.getCanonicalPath(), validTask.mFilePaths[0]); + } + + @Test public void testShowTypes() { ShadowMimeTypeMap shadowMimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); shadowMimeTypeMap.addExtensionMimeTypeMapping("jpg", "image/jpeg");