CVE-2026-91717
Overview
Background
- Web app launch intent
- An Android intent that starts an installed PWA/Trusted Web Activity in a Custom Tab, optionally carrying file-handling or share-target data (file
content://URIs) for the web content to consume. - `isValidLaunchUri`
- A
WebAppLaunchHandlerhelper that vets a launch/shareUri, rejecting unsupported schemes (e.g.file://) and Chrome’s own internalFileProviderauthorities (such ascontent://<package>.FileProvider/...) so that only legitimate external content URIs are forwarded (behavior inferred; the helper body is not in the diff). - `doesCallerHavePermissionForUri`
- A check that the intent’s caller or the current process holds
FLAG_GRANT_READ_URI_PERMISSIONon aUriviacheckUriPermission, which a Chrome-internalFileProviderURI passes because Chrome itself owns that provider. - Missing authorization
- Because the caller-permission check alone accepts URIs Chrome can read on its own behalf, an unvalidated URI can reference internal Chrome files the web principal was never authorized to access.
Root Cause Analysis
The file-handling filter loop (the @@ -501 hunk, whose enclosing function is not named in the hunk header) and copyFilePermissions validated each file Uri only with doesCallerHavePermissionForUri, omitting the isValidLaunchUri scheme/authority check that the share-target paths (filterShareData, copyShareDataPermissions) already performed. Because checkUriPermission returns granted for Chrome’s own FileProvider content URIs — Chrome holds permission to its own provider — and for file:// URIs Chrome can open, an attacker-supplied launch intent could smuggle internal or unsupported URIs through the file-handling flow. The invariant violated is that every URI forwarded to launched web content must first be confirmed to be an external, supported content URI, not a Chrome-internal or file:// resource.
The fix inserts if (!isValidLaunchUri(uri)) { ...; continue; } before the permission check in both the file-handling filter loop and copyFilePermissions, so internal and file:// URIs are dropped before any URI is added to filteredUris/verifiedUris. It also refactors the two share-target loops that already contained isValidLaunchUri into the same two-guard ordering for consistency and clearer logging, without changing which URIs those paths accept. This change TIGHTENS the file-handling path by adding a validation step that was previously absent.
isValidLaunchUri even though share-target URIs were, so Chrome’s own internal FileProvider and file:// URIs slipped past the caller-permission gate; the fix applies the same validation to the file-handling path.Attack Path
- Install a file-handling web app The victim has an installed PWA/Trusted Web Activity that registers as a file handler (or POST share target) opened in a Custom Tab.
- Craft a malicious launch intent
A local malicious app builds a launch or share intent whose file
Urilist includes a Chrome-internal URI such ascontent://<chrome package>.FileProvider/net_export/sample.txtor afile:///sdcard/...URI. - Bypass the caller-permission gate
In the pre-patch file-handling loop and
copyFilePermissions, onlydoesCallerHavePermissionForUriruns; it returns granted for Chrome’s ownFileProviderURI, so the internal URI is added tofilteredUris/verifiedUris. - Deliver internal file contents to web content
The verified URIs are handed to the launched web app or passed to
WebApkPostShareTargetNavigatoras POST form data, exposing the internal file’s contents to the web principal. - Exfiltrate (inferred) the web principal (a share-target POST endpoint) transmits the disclosed contents to an attacker-controlled server; the patch demonstrates only the disclosure to web content, not the network step.
Impact Assessment
file:// resources) and disclose them to attacker-influenced web content via a file-handling launch or share-target intent — a missing-authorization / information-disclosure issue occurring in the Chrome browser process on Android. Preconditions are that a suitable file-handling or share-target web app is installed and that the attacker can deliver the crafted intent to it. The advisory classifies this as LogicError / “Missing authorization in Android”; it does not report exploitation in the wild, and any escalation beyond the information disclosure the patch demonstrates is (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java |
modified | |
forchrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java |
modified | |
ifchrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java |
modified |
Files Changed
chrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.javachrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.javachrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.javachrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
Audit Directions
- Asymmetric validation across parallel pathsWhen two flows (here file-handling vs share-target) consume the same kind of untrusted input, diff their per-item validation; a check present in one and absent in the other is a likely authorization gap.
- Permission checks that trust the current process
checkUriPermission/doesCallerHavePermissionForUrireturns granted for URIs the app itself owns (Chrome’sFileProvider), so audit any intent-URI handling that gates only on process/caller permission without a scheme and authority allowlist. - Intent extras carrying URIs into web contentReview all paths that forward externally supplied
content:///file://URIs into launched web apps, share-target POSTs, or navigations to confirm each appliesisValidLaunchUri-style filtering before trusting the URI.
Patch
From f44c857d8af9af119e2c281d021d6df0d191de2f Mon Sep 17 00:00:00 2001 From: Adriana Ixba <[email protected]> Date: Thu, 10 Sep 2026 10:45:44 -0700 Subject: [PATCH] Validate launch URIs in WebAppLaunchHandler Ensure that file URIs received via launch intents are verified with isValidLaunchUri() in WebAppLaunchHandler prior to checking caller permissions. This aligns file handling URI validation with share target handling and ensures that unsupported or internal provider URIs are consistently filtered out during intent processing. Additionally, update copyFilePermissions() to enforce the same URI validation when propagating verified launch data, and add test coverage for launch intent URI filtering. TAG=agy Bug: 549225472 Change-Id: Idef1e9db1a6e12d39a3f7dd68aacccc96df58e7d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8373702 Reviewed-by: Daniel Murphy <[email protected]> Reviewed-by: Sinan Sahin <[email protected]> Commit-Queue: Adriana Ixba <[email protected]> Cr-Commit-Position: refs/heads/main@{#1695502} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java index e1742af..5ab2f42 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java @@ -501,16 +501,20 @@ List<Uri> filteredUris = new ArrayList<>(); for (Uri uri : fileHandlingData.uris) { - if (doesCallerHavePermissionForUri( + if (!isValidLaunchUri(uri)) { + Log.w(TAG, "Invalid launch URI: " + uri); + continue; + } + if (!doesCallerHavePermissionForUri( mActivity, caller, intentDataProvider.getSession(), uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)) { - filteredUris.add(uri); - } else { Log.w(TAG, "Caller does not have read permission for URI: " + uri); + continue; } + filteredUris.add(uri); } if (filteredUris.isEmpty()) { @@ -588,7 +592,7 @@ public static void copyFilePermissions( Activity activity, Intent sourceIntent, Intent targetIntent) { // Strip EXTRA_VERIFIED_FILE_HANDLING_DATA/EXTRA_VERIFIED_FILE_CAN_WRITE if present on - // targetIntent so that they cannot be spoofed by CCT client apps. + // targetIntent so that they cannot be set by external client apps. IntentUtils.safeRemoveExtra( targetIntent, CustomTabIntentDataProvider.EXTRA_VERIFIED_FILE_HANDLING_DATA); IntentUtils.safeRemoveExtra( @@ -624,8 +628,13 @@ List<Uri> verifiedUris = new ArrayList<>(); List<Boolean> canWriteList = new ArrayList<>(); for (Uri uri : fileHandlingData.uris) { + if (!isValidLaunchUri(uri)) { + Log.w(TAG, "Invalid launch URI: " + uri); + continue; + } if (!doesCallerHavePermissionForUri( activity, caller, session, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)) { + Log.w(TAG, "Caller does not have read permission for URI: " + uri); continue; } verifiedUris.add(uri); @@ -666,8 +675,8 @@ */ public static void copyShareDataPermissions( Activity activity, Intent sourceIntent, Intent targetIntent) { - // Strip EXTRA_VERIFIED_SHARE_DATA if present on targetIntent so that it cannot be spoofed - // by CCT client apps. + // Strip EXTRA_VERIFIED_SHARE_DATA if present on targetIntent so that it cannot be set + // by external client apps. IntentUtils.safeRemoveExtra( targetIntent, CustomTabIntentDataProvider.EXTRA_VERIFIED_SHARE_DATA); @@ -704,17 +713,16 @@ SessionHolder<?> session = SessionHolder.getSessionHolderFromIntent(sourceIntent); List<Uri> verifiedUris = new ArrayList<>(); for (Uri uri : shareData.uris) { - if (isValidLaunchUri(uri) - && doesCallerHavePermissionForUri( - activity, - caller, - session, - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION)) { - verifiedUris.add(uri); - } else { - Log.w(TAG, "Caller does not have read permission for share URI: " + uri); + if (!isValidLaunchUri(uri)) { + Log.w(TAG, "Invalid launch URI: " + uri); + continue; } + if (!doesCallerHavePermissionForUri( + activity, caller, session, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)) { + Log.w(TAG, "Caller does not have read permission for share URI: " + uri); + continue; + } + verifiedUris.add(uri); } ShareData verifiedShareData = new ShareData(shareData.title, shareData.text, verifiedUris); @@ -761,17 +769,20 @@ List<Uri> filteredUris = new ArrayList<>(); for (Uri uri : shareData.uris) { - if (isValidLaunchUri(uri) - && doesCallerHavePermissionForUri( - activity, - caller, - intentDataProvider.getSession(), - uri, - Intent.FLAG_GRANT_READ_URI_PERMISSION)) { - filteredUris.add(uri); - } else { - Log.w(TAG, "Caller does not have read permission for share URI: " + uri); + if (!isValidLaunchUri(uri)) { + Log.w(TAG, "Invalid launch URI: " + uri); + continue; } + if (!doesCallerHavePermissionForUri( + activity, + caller, + intentDataProvider.getSession(), + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION)) { + Log.w(TAG, "Caller does not have read permission for share URI: " + uri); + continue; + } + filteredUris.add(uri); } return new ShareData(shareData.title, shareData.text, filteredUris); diff --git a/chrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java b/chrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java index 2d5dbbc..8e96f00 100644 --- a/chrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java +++ b/chrome/android/javatests/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityShareTargetTest.java @@ -9,12 +9,14 @@ import static org.chromium.chrome.browser.browserservices.TrustedWebActivityTestUtil.spoofVerification; import android.content.Intent; +import android.net.Uri; import androidx.browser.trusted.TrustedWebActivityIntentBuilder; import androidx.browser.trusted.sharing.ShareData; import androidx.browser.trusted.sharing.ShareTarget; import androidx.test.filters.MediumTest; +import org.junit.Assert; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -59,6 +61,19 @@ null, new ShareTarget.Params("received_title", "received_text", null)); + private static final ShareTarget POST_MULTIPART_FILE_SHARE_TARGET = + new ShareTarget( + "https://pwa.rocks/share.html", + "POST", + "multipart/form-data", + new ShareTarget.Params( + "received_title", + "received_text", + Collections.singletonList( + new ShareTarget.FileFormField( + "received_file", + Collections.singletonList("text/plain"))))); + @Rule public CustomTabActivityTestRule mCustomTabActivityTestRule = new CustomTabActivityTestRule(); @@ -172,7 +187,44 @@ () -> mCustomTabActivityTestRule.getActivity().onNewIntent(intent)); } + @Test + @MediumTest + public void sharesDataWithPost_FiltersUnauthorizedFileUris() throws Exception { + Uri internalUri = + Uri.parse("content://" + PACKAGE_NAME + ".FileProvider/net_export/sample.txt"); + ShareData shareData = + new ShareData("test_title", "test_text", Collections.singletonList(internalUri)); + mIntent.putExtra(TrustedWebActivityIntentBuilder.EXTRA_SHARE_DATA, shareData.toBundle()); + mIntent.putExtra( + TrustedWebActivityIntentBuilder.EXTRA_SHARE_TARGET, + POST_MULTIPART_FILE_SHARE_TARGET.toBundle()); + + mCustomTabActivityTestRule.startCustomTabActivityWithIntentNotWaitingForFirstFrame(mIntent); + assertPostNavigatorCalled(); + + Assert.assertNotNull(mPostNavigatorNatives.mIsValueFileUris); + for (boolean isFileUri : mPostNavigatorNatives.mIsValueFileUris) { + Assert.assertFalse(isFileUri); + } + Assert.assertNotNull(mPostNavigatorNatives.mValues); + for (String value : mPostNavigatorNatives.mValues) { + Assert.assertFalse(value.contains("sample.txt")); + Assert.assertFalse(value.contains("FileProvider")); + } + if (mPostNavigatorNatives.mFilenames != null) { + for (String filename : mPostNavigatorNatives.mFilenames) { + Assert.assertFalse(filename.contains("sample.txt")); + } + } + } + private class MockPostNavigatorNatives implements WebApkPostShareTargetNavigator.Natives { + public String[] mNames; + public String[] mValues; + public boolean[] mIsValueFileUris; + public String[] mFilenames; + public String[] mTypes; + @Override public void nativeLoadViewForShareTargetPost( boolean isMultipartEncoding, @@ -183,6 +235,11 @@ String[] types, String startUrl, WebContents webContents) { + mNames = names; + mValues = values; + mIsValueFileUris = isValueFileUris; + mFilenames = filenames; + mTypes = types; mPostNavigatorCallback.notifyCalled(); } } diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.java index 23624362..2e0137e5 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProviderTest.java @@ -2807,4 +2807,12 @@ assertEquals("title", result.title); assertEquals(fileUri, result.uris.get(0)); } + + @Test + public void testGetShareData_NullWhenMissing() { + Intent intent = new Intent(); + CustomTabIntentDataProvider dataProvider = + new CustomTabIntentDataProvider(intent, mContext, COLOR_SCHEME_LIGHT); + assertNull(dataProvider.getShareData()); + } } diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java index 0960e84..4375338 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java @@ -32,14 +32,18 @@ import android.content.pm.ProviderInfo; import android.net.Uri; import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; import android.os.Looper; import android.os.Process; import android.text.TextUtils; import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabsIntent; import androidx.browser.customtabs.CustomTabsSessionToken; import androidx.browser.trusted.FileHandlingData; import androidx.browser.trusted.LaunchHandlerClientMode; +import androidx.browser.trusted.TrustedWebActivityIntentBuilder; import androidx.browser.trusted.sharing.ShareData; import org.junit.Assert; @@ -466,7 +470,8 @@ String packageName = ContextUtils.getApplicationContext().getPackageName(); Uri privateUri = Uri.parse("content://" + packageName + ".FileProvider/foo"); mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse(CONTENT_URI), privateUri)); - mExpectedFileList = new String[0]; // Expect empty because one is Chrome private + mExpectedFileList = new String[] {CONTENT_URI}; + mExpectedCanWriteList = new boolean[] {true}; doTestHandleIntent( LaunchHandlerClientMode.AUTO, INITIAL_URL, @@ -480,7 +485,8 @@ // Bypass using exactly package name as authority (no trailing dot in prefix check) Uri bypassUri1 = Uri.parse("content://" + packageName + "/foo"); mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse(CONTENT_URI), bypassUri1)); - mExpectedFileList = new String[0]; // Expect empty because one is Chrome private (bypassed) + mExpectedFileList = new String[] {CONTENT_URI}; + mExpectedCanWriteList = new boolean[] {true}; doTestHandleIntent( LaunchHandlerClientMode.AUTO, INITIAL_URL, @@ -494,7 +500,8 @@ // Bypass using userinfo Uri bypassUri1 = Uri.parse("content://user@" + packageName + ".FileProvider/foo"); mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse(CONTENT_URI), bypassUri1)); - mExpectedFileList = new String[0]; // Expect empty because one is Chrome private (bypassed) + mExpectedFileList = new String[] {CONTENT_URI}; + mExpectedCanWriteList = new boolean[] {true}; doTestHandleIntent( LaunchHandlerClientMode.AUTO, INITIAL_URL, @@ -995,4 +1002,194 @@ Assert.assertEquals(1, result.uris.size()); Assert.assertEquals(authorizedUri, result.uris.get(0)); } + + @Test + public void testFilterShareData_InvalidUrisFiltered() { + Intent intent = new Intent(); + Uri internalUri = + Uri.parse( + "content://" + + ContextUtils.getApplicationContext().getPackageName() + + ".FileProvider/net_export/sample.txt"); + Uri fileSchemeUri = Uri.parse("file:///sdcard/sample.txt"); + Uri validUri = Uri.parse("content://com.example/valid.jpg"); + ShareData rawData = + new ShareData("title", "text", Arrays.asList(internalUri, fileSchemeUri, validUri)); + + CustomTabIntentDataProvider dataProvider = mock(CustomTabIntentDataProvider.class); + when(dataProvider.getIntent()).thenReturn(intent); + when(dataProvider.getShareData()).thenReturn(rawData); + when(dataProvider.getSession()).thenReturn(mSessionMock); + + when(mActivityMock.checkUriPermission( + eq(validUri), + anyInt(), + anyInt(), + eq(Intent.FLAG_GRANT_READ_URI_PERMISSION))) + .thenReturn(PackageManager.PERMISSION_GRANTED); + when(mActivityMock.checkUriPermission( + eq(internalUri), + anyInt(), + anyInt(), + eq(Intent.FLAG_GRANT_READ_URI_PERMISSION))) + .thenReturn(PackageManager.PERMISSION_GRANTED); + + ShareData result = WebAppLaunchHandler.filterShareData(dataProvider, mActivityMock, null); + Assert.assertNotNull(result); + Assert.assertEquals("title", result.title); + Assert.assertEquals(1, result.uris.size()); + Assert.assertEquals(validUri, result.uris.get(0)); + } + + @Test + public void testFilterFileHandlingData_InvalidUrisFiltered() { + Intent intent = new Intent(); + Uri internalUri = + Uri.parse( + "content://" + + ContextUtils.getApplicationContext().getPackageName() + + ".FileProvider/net_export/sample.txt"); + Uri fileSchemeUri = Uri.parse("file:///sdcard/sample.txt"); + Uri validUri = Uri.parse("content://com.example/valid.jpg"); + FileHandlingData rawData = + new FileHandlingData(Arrays.asList(internalUri, fileSchemeUri, validUri)); + + CustomTabIntentDataProvider dataProvider = mock(CustomTabIntentDataProvider.class); + when(dataProvider.getIntent()).thenReturn(intent); + when(dataProvider.getFileHandlingData()).thenReturn(rawData); + when(dataProvider.getSession()).thenReturn(mSessionMock); + when(dataProvider.getUrlToLoad()).thenReturn(INITIAL_URL); + when(dataProvider.getClientPackageName()).thenReturn(TEST_PACKAGE_NAME); + + when(mActivityMock.checkUriPermission( + eq(validUri), + anyInt(), + anyInt(), + eq(Intent.FLAG_GRANT_READ_URI_PERMISSION))) + .thenReturn(PackageManager.PERMISSION_GRANTED); + when(mActivityMock.checkUriPermission( + eq(internalUri), + anyInt(), + anyInt(), + eq(Intent.FLAG_GRANT_READ_URI_PERMISSION))) + .thenReturn(PackageManager.PERMISSION_GRANTED); + + WebAppLaunchHandler launchHandler = createWebAppLaunchHandler(); + launchHandler.handleInitialIntent(dataProvider); + + verify(mWebAppLaunchHandlerJniMock) + .prepareForLaunch( + any(), + anyLong(), + eq(INITIAL_URL), + eq(TEST_PACKAGE_NAME), + eq(new String[] {validUri.toString()}), + any(), + any(), + anyBoolean()); ... (file diff truncated)