Overview

High
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebAppInstalls
DescriptionInsufficient validation of untrusted input in WebAppInstalls
ComponentWebAppInstalls
Bug ClassLogic Error
Tracker520576676
Fix commit7cceae635cb7 (chromium/src) +153/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-08

Changed Functions

FunctionChangeNotes
for
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
modified
if
chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandler.java
From 7cceae635cb7841114ce6d19665f58c6103e22c8 Mon Sep 17 00:00:00 2001
From: Dibyajyoti Pal <dibyapal@google.com>
Date: Mon, 29 Jun 2026 10:31:26 -0700
Subject: [PATCH] [PWA] Filter file-handling URIs based on caller permission

The Android Trusted Web Activity (TWA) file-handling path was accepting
arbitrary content URIs from a co-installed app and forwarding them to
the File System Access (FSA) API without checking if the calling app had
permissions on those URIs. If Chrome held a persisted permission for a
URI from a prior user action, an attacker-controlled TWA could read or
write that file promptlessly, leading to a confused deputy
vulnerability.

This CL fixes the issue by filtering the URIs in WebAppLaunchHandler
before they are forwarded to the launch queue or used to launch a new
intent. We verify that the caller has permission to the URIs using
Activity.getCurrentCaller().checkContentUriPermission() on Android 15+
and falling back to checking the session UID/PID via
Context.checkUriPermission() on older Android versions.

Bug: b:520576676
Test: tools/autotest.py -C out/Default WebAppLaunchHandlerTest
Change-Id: Ieaf01803003d7032091c6c3194cb9fc2baf636bc
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7910767
Reviewed-by: Peter Conn <peconn@chromium.org>
Commit-Queue: Dibyajyoti Pal <dibyapal@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1654161}
---

diff --git a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java
index 6c89f527..e82fd80 100644
--- a/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java
+++ b/chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabsConnection.java
@@ -1393,6 +1393,16 @@
         return mClientManager.getClientPackageNameForSession(session);
     }
 
+    /** See {@link ClientManager#getClientUidForSession(SessionHolder)} */
+    public int getClientUidForSession(@Nullable SessionHolder<?> session) {
+        return mClientManager.getClientUidForSession(session);
+    }
+
+    /** See {@link ClientManager#getClientPidForSession(SessionHolder)} */
+    public int getClientPidForSession(@Nullable SessionHolder<?> session) {
+        return mClientManager.getClientPidForSession(session);
+    }
+
     /**
      * Extracts the target network from the intent if the caller has the required permissions.
      * Package-private to be used by {@link CustomTabIntentDataProvider}.
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 2c10074..b8de8c9 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
@@ -13,11 +13,14 @@
 import static org.chromium.build.NullUtil.assertNonNull;
 import static org.chromium.build.NullUtil.assumeNonNull;
 
+import android.annotation.SuppressLint;
 import android.app.Activity;
 import android.content.ActivityNotFoundException;
 import android.content.ContentResolver;
 import android.content.Intent;
+import android.content.pm.PackageManager;
 import android.net.Uri;
+import android.os.Build;
 import android.text.TextUtils;
 
 import androidx.browser.trusted.FileHandlingData;
@@ -32,8 +35,10 @@
 import org.chromium.build.annotations.NullMarked;
 import org.chromium.build.annotations.Nullable;
 import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
+import org.chromium.chrome.browser.browserservices.intents.SessionHolder;
 import org.chromium.chrome.browser.browserservices.ui.controller.CurrentPageVerifier;
 import org.chromium.chrome.browser.browserservices.ui.controller.Verifier;
+import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.ClientModeAction;
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.FailureReasonAction;
 import org.chromium.chrome.browser.customtabs.content.WebAppLaunchHandlerHistogram.FileHandlingAction;
@@ -42,6 +47,7 @@
 import org.chromium.content_public.browser.WebContents;
 import org.chromium.content_public.browser.WebContentsObserver;
 
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.Locale;
@@ -195,12 +201,13 @@
     public void handleInitialIntent(BrowserServicesIntentDataProvider intentDataProvider) {
         WebAppLaunchHandlerHistogram.logClientMode(ClientModeAction.INITIAL_INTENT);
 
+        FileHandlingData filteredData = filterFileHandlingData(intentDataProvider);
         WebAppLaunchParams launchParams =
                 getLaunchParams(
                         /* newNavigationStarted= */ true,
                         assertNonNull(intentDataProvider.getUrlToLoad()),
                         assertNonNull(intentDataProvider.getClientPackageName()),
-                        intentDataProvider.getFileHandlingData());
+                        filteredData);
 
         maybeNotifyLaunchQueue(launchParams);
     }
@@ -225,11 +232,13 @@
         assert urlToLoad != null;
         String packageName = intentDataProvider.getClientPackageName();
 
+        FileHandlingData filteredData = filterFileHandlingData(intentDataProvider);
+
         CurrentPageVerifier.VerificationState state = mCurrentPageVerifier.getState();
         if (clientMode == NAVIGATE_NEW
                 || state == null
                 || state.status != CurrentPageVerifier.VerificationStatus.SUCCESS) {
-            launchNewIntent(urlToLoad, packageName, intentDataProvider.getFileHandlingData());
+            launchNewIntent(urlToLoad, packageName, filteredData);
         } else {
             boolean startNavigation =
                     clientMode == NAVIGATE_EXISTING && !TextUtils.isEmpty(urlToLoad);
@@ -242,11 +251,7 @@
 
             assert packageName != null;
             WebAppLaunchParams launchParams =
-                    getLaunchParams(
-                            startNavigation,
-                            urlToLoad,
-                            packageName,
-                            intentDataProvider.getFileHandlingData());
+                    getLaunchParams(startNavigation, urlToLoad, packageName, filteredData);
 
             maybeNotifyLaunchQueue(launchParams);
         }
@@ -371,6 +376,84 @@
     }
 
     /**
+     * Filters incoming file handling data to retain only URIs that the launching client app has
+     * permission to access.
+     *
+     * @param intentDataProvider Provides incoming intent and session customization data.
+     * @return The filtered FileHandlingData object containing authorized URIs, or null if all URIs
+     *     were denied or no file data was provided.
+     */
+    private @Nullable FileHandlingData filterFileHandlingData(
+            BrowserServicesIntentDataProvider intentDataProvider) {
+        FileHandlingData fileHandlingData = intentDataProvider.getFileHandlingData();
+        if (fileHandlingData == null || fileHandlingData.uris.isEmpty()) {
+            return null;
+        }
+
+        List<Uri> filteredUris = new ArrayList<>();
+        for (Uri uri : fileHandlingData.uris) {
+            if (doesCallerHavePermissionForUri(intentDataProvider.getSession(), uri)) {
+                filteredUris.add(uri);
+            } else {
+                Log.w(TAG, "Caller does not have permission for URI: " + uri);
+            }
+        }
+
+        if (filteredUris.isEmpty()) {
+            return null;
+        }
+        if (filteredUris.size() == fileHandlingData.uris.size()) {
+            return fileHandlingData;
+        }
+        return new FileHandlingData(filteredUris);
+    }
+
+    /**
+     * Verifies whether the calling application holds read permission for the specified URI.
+     *
+     * <p>On Android 15+ (API 35+), checks caller identity via {@link Activity#getCurrentCaller()}.
+     * On older Android versions, falls back to verifying URI permissions against the session UID.
+     *
+     * @param session The session holder associated with the launching client app.
+     * @param uri The Content URI to verify.
+     * @return True if the caller has explicit read permission for uri, false otherwise.
+     */
+    @SuppressLint("NewApi")
+    private boolean doesCallerHavePermissionForUri(@Nullable SessionHolder<?> session, Uri uri) {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
+            try {
+                var caller = mActivity.getCurrentCaller();
+                if (caller != null) {
+                    return caller.checkContentUriPermission(
+                                    uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
+                            == PackageManager.PERMISSION_GRANTED;
+                }
+            } catch (Exception e) {
+                Log.w(TAG, "Failed to check caller's permission via getCurrentCaller.", e);
+                return false;
+            }
+        }
+
+        // Fallback for Android versions prior to Android 15 (API < 35) or when getCurrentCaller()
+        // is unavailable. We check URI read permissions against the client UID and PID recorded
+        // when the TWA session was established.
+        if (session != null) {
+            int uid = CustomTabsConnection.getInstance().getClientUidForSession(session);
+            int pid = CustomTabsConnection.getInstance().getClientPidForSession(session);
+            if (uid != -1) {
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential promptless FSA write access to third-party content URIs via TWA file handling

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Android Trusted Web Activity (TWA) file-handling path accepts arbitrary content URIs from a co-installed app and forwards them to the File System Access (FSA) API without checking if the calling app has permissions on those URIs. If Chrome holds a persisted permission for a URI from a prior user action, an attacker-controlled TWA can read or write that file promptlessly. This allows a co-installed, zero-permission app to potentially bypass origin isolation and access previously granted Storage Access Framework (SAF) documents.

Affected files:

  • chrome/browser/android/webapps/twa_launch_queue_delegate.cc
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/CustomTabIntentDataProvider.java
  • chrome/android/java/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchParams.java
  • chrome/browser/android/webapps/web_app_launch_handler.cc
  • components/webapps/browser/launch_queue/launch_queue.cc

Estimated timestamp from git blame: 2025-04-22

Root Cause Analysis

When a Trusted Web Activity (TWA) is launched with file-handling intent data on Android, the content URIs provided by the launching application are parsed and directly forwarded to the File System Access (FSA) API.

Specifically, the following chain occurs:

  1. Extraction (Java): CustomTabIntentDataProvider.java retrieves the parcelable EXTRA_FILE_HANDLING_DATA bundle containing raw Content URIs from the launching intent without checking if the calling app actually holds read/write permissions for those URIs.
  2. Validation (C++): In chrome/browser/android/webapps/twa_launch_queue_delegate.cc, IsSensitivePath evaluates the URIs. It only blocks paths starting with Chrome’s own package content prefix (content://<package_name>.). Legitimate third-party content URIs (such as content://com.android.externalstorage.documents/...) are allowed to pass through by design.
  3. Handle Creation: components/webapps/browser/launch_queue/launch_queue.cc takes these validated paths and calls CreateFileEntryFromPath with UserAction::kSave (as seen in AddFileEntry).
  4. Auto-Granting Permissions: In chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc, the UserAction::kSave flag automatically grants both read and write status (i.e., PermissionStatus::GRANTED) to the target origin without presenting any security confirmation dialog or prompt.
  5. Persistence: Since Android’s ContentResolver tracks permission grants at the UID (application) level rather than the web origin level, and since Chrome’s UID persists SAF permissions obtained from previous standard file-picker tasks (via takePersistableUriPermission), the system-level file descriptor can be opened and modified. The attacker’s origin (https://attacker.example) receives a fully write-granted FileSystemFileHandle through the standard launchQueue.setConsumer API.

Note: The steps below are theoretical/suggested mechanics because our analysis is based on static code review, and we do not have a working automated exploit proof-of-concept.

Potential Attack Scenario

  1. User Precondition: The user visits a legitimate origin (e.g., https://victim.example) on Android and selects a sensitive document (e.g., Documents/report.docx) via the standard system file picker. Under the default-enabled kSelectFileOpenDocument feature, Chrome’s UID permanently holds a persisted OS-level read/write permission to the file.
  2. App Installation: The user installs a malicious app that has zero Android permissions, but declares Digital Asset Links tying it to an attacker-controlled origin (e.g., https://attacker.example).
  3. Intent Spoofing: The malicious app binds to Chrome’s Custom Tabs service and sends a TWA launch intent pointing to its origin. Inside EXTRA_FILE_HANDLING_DATA, it includes the predictable content URI of the target document: content://com.android.externalstorage.documents/document/primary%3ADocuments%2Freport.docx.
  4. Promptless Overwrite: The TWA opens https://attacker.example, which receives the file handle via the launchQueue API. The page calls createWritable() on the handle and overwrites or reads the user’s document silently without any picker dialog or confirmation prompt.

Suggested Fix

Ensure that the TWA launch path validates that the launching client application actually holds permission for any content URIs it passes via the file-handling intent.

On modern Android versions, this can be achieved by executing a caller permission check during intent parsing (similar to the logic in SelectFileDialog.java::doesCallerHavePermissionForUri):

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) {
    activity.getCurrentCaller().checkContentUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
}

Alternatively, Chrome must verify that the launching package has read/write permissions for the content URIs before converting them into raw base::FilePath entries in the launch queue.

Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker