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
Tracker522560124
Fix commitc6bee15e8f33 (chromium/src) +131/-94
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

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/content/WebAppLaunchHandler.java
  • chrome/android/junit/src/org/chromium/chrome/browser/customtabs/content/WebAppLaunchHandlerTest.java
From c6bee15e8f336c8feabf539d8bbb540c134ec20a Mon Sep 17 00:00:00 2001
From: Nate Chapin <[email protected]>
Date: Wed, 17 Jun 2026 14:57:03 -0700
Subject: [PATCH] Move TWA Launch Queue Validation to Java

This CL moves the validation of launch files for TWAs from
C++ (TwaLaunchQueueDelegate) to Java (WebAppLaunchHandler).

Performing the validation in Java allows us to use the platform's
android.net.Uri parser, which is safer and matches how the platform
routes content URIs. The validation ensures that only content:// URIs
are allowed, and that they do not target Chrome's own providers.

Fixed: 522560124
Change-Id: Ie01074a12d603de8848c95b377291c2ae8c16f18
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7952149
Reviewed-by: Moe Adel <[email protected]>
Reviewed-by: Glenn Hartmann <[email protected]>
Commit-Queue: Nate Chapin <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1648612}
---

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 6e1d5f06..2c10074 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
@@ -15,6 +15,7 @@
 
 import android.app.Activity;
 import android.content.ActivityNotFoundException;
+import android.content.ContentResolver;
 import android.content.Intent;
 import android.net.Uri;
 import android.text.TextUtils;
@@ -26,6 +27,7 @@
 import org.jni_zero.JniType;
 import org.jni_zero.NativeMethods;
 
+import org.chromium.base.ContextUtils;
 import org.chromium.base.Log;
 import org.chromium.build.annotations.NullMarked;
 import org.chromium.build.annotations.Nullable;
@@ -42,6 +44,7 @@
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Locale;
 
 /**
  * Manages web application launch configurations based on client mode. Provides methods to process
@@ -115,6 +118,39 @@
         mActivity = activity;
     }
 
+    private boolean isValidFileHandlingData(FileHandlingData fileHandlingData) {
+        String packageName = ContextUtils.getApplicationContext().getPackageName();
+        for (Uri uri : fileHandlingData.uris) {
+            if (!isValidLaunchUri(uri, packageName)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static boolean isValidLaunchUri(Uri uri, String packageName) {
+        if (uri == null) return false;
+
+        // Only content URIs are allowed. Legitimate file launching on Android should
+        // use Content URIs.
+        if (!ContentResolver.SCHEME_CONTENT.equalsIgnoreCase(uri.getScheme())) {
+            return false;
+        }
+
+        // Block Chrome's own Content URIs.
+        String authority = uri.getAuthority();
+        if (authority != null) {
+            String chromeAuthorityPrefix = packageName + ".";
+            if (authority
+                    .toLowerCase(Locale.US)
+                    .startsWith(chromeAuthorityPrefix.toLowerCase(Locale.US))) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
     /**
      * Generates WebAppLaunchParams based on the AndroidX representation of the client mode.
      *
@@ -130,17 +166,19 @@
             String packageName,
             @Nullable FileHandlingData fileHandlingData) {
         List<Uri> fileUris = null;
-        if (fileHandlingData != null && !fileHandlingData.uris.isEmpty()) {
-            if (fileHandlingData.uris.size() == 1) {
-                WebAppLaunchHandlerHistogram.logFileHandling(FileHandlingAction.SINGLE_FILE);
-            } else {
-                WebAppLaunchHandlerHistogram.logFileHandling(FileHandlingAction.MULTIPLE_FILES);
-            }
+        @FileHandlingAction int action = FileHandlingAction.NO_FILES;
+
+        if (fileHandlingData != null
+                && !fileHandlingData.uris.isEmpty()
+                && isValidFileHandlingData(fileHandlingData)) {
             fileUris = fileHandlingData.uris;
-        } else {
-            WebAppLaunchHandlerHistogram.logFileHandling(FileHandlingAction.NO_FILES);
+            action =
+                    fileUris.size() == 1
+                            ? FileHandlingAction.SINGLE_FILE
+                            : FileHandlingAction.MULTIPLE_FILES;
         }
 
+        WebAppLaunchHandlerHistogram.logFileHandling(action);
         return new WebAppLaunchParams(newNavigationStarted, targetUrl, packageName, fileUris);
     }
 
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 e9f8d83..d0f941d 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
@@ -39,6 +39,7 @@
 import org.mockito.junit.MockitoRule;
 import org.robolectric.annotation.Config;
 
+import org.chromium.base.ContextUtils;
 import org.chromium.base.Promise;
 import org.chromium.base.test.BaseRobolectricTestRunner;
 import org.chromium.base.test.util.Batch;
@@ -276,8 +277,8 @@
 
     @Test
     public void multipleFilePaths() {
-        final String secondUri = "second_uri.com";
-        final String thirdUri = "third_uri.com";
+        final String secondUri = "content://com.a.b.c/second";
+        final String thirdUri = "content://com.a.b.c/third";
         mFileHandlingData =
                 new FileHandlingData(
                         Arrays.asList(
@@ -295,6 +296,87 @@
                 /* expectedNotifyQueue= */ true);
     }
 
+    @Test
+    public void filePath_invalidScheme() {
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse("file:///foo/bar")));
+        mExpectedFileList = new String[0]; // Expect empty because file:// is invalid
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
+    public void filePath_chromePrivateData() {
+        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
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
+    public void filePath_emptyPath() {
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse("")));
+        mExpectedFileList = new String[0];
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
+    public void filePath_absolutePath() {
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse("/absolute/path")));
+        mExpectedFileList = new String[0];
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
+    }
+
+    @Test
+    public void filePath_parentReference() {
+        mFileHandlingData = new FileHandlingData(Arrays.asList(Uri.parse("relative/../path")));
+        mExpectedFileList = new String[0];
+        doTestHandleIntent(
+                LaunchHandlerClientMode.AUTO,
+                INITIAL_URL,
+                /* expectedLoadUrl= */ false,
+                /* expectedNotifyQueue= */ true);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/android/webapps/twa_launch_queue_delegate_unittest.cc b/chrome/browser/android/webapps/twa_launch_queue_delegate_unittest.cc
deleted file mode 100644
index f5819b6f..0000000
--- a/chrome/browser/android/webapps/twa_launch_queue_delegate_unittest.cc
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright 2026 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/android/webapps/twa_launch_queue_delegate.h"
-
-#include "base/files/file_path.h"
-#include "components/webapps/browser/launch_queue/launch_params.h"
-#include "testing/gtest/include/gtest/gtest.h"
-
-namespace webapps {
-
-TEST(TwaLaunchQueueDelegateTest, IsValidLaunchParams) {
-  TwaLaunchQueueDelegate delegate;
-
-  // Helper lambda to test a single path
-  auto check_path = [&](const std::string& path_str) {
-    LaunchParams params;
-    params.add_path(base::FilePath(path_str));
-    return delegate.IsValidLaunchParams(params);
-  };
-
-  // Legitimate Content URIs should be allowed
-  EXPECT_TRUE(check_path("content://com.example.provider/file"));
-
-  // Empty path should be blocked (IsSensitivePath returns true for empty)
-  EXPECT_FALSE(check_path(""));
-
-  // Absolute paths should be blocked
-  EXPECT_FALSE(check_path("/absolute/path"));
-
-  // Parent references should be blocked
-  EXPECT_FALSE(check_path("relative/../path"));
-
-  // file:// URIs should be blocked
-  EXPECT_FALSE(check_path("file:///absolute/path"));
-  EXPECT_FALSE(check_path("file://relative/path"));
-
-  // Relative paths should be blocked (VULNERABILITY)
-  EXPECT_FALSE(check_path("relative/path"));
-  EXPECT_FALSE(check_path("data/data/com.android.chrome/cookies"));
-
-  // Chrome's own Content URIs should be blocked.
-  // We don't know the package name at compile time, but it should start with
-  // content://pkg. Since we cannot easily mock apk_info package name in this
-  // test without more setup, we might need to be careful. If apk_info is not
-  // initialized, it might crash or return empty. Let's see what happens.
-}
-
-}  // namespace webapps
diff --git a/chrome/test/BUILD.gn b/chrome/test/BUILD.gn
index d1dd664..9f2eab5 100644
--- a/chrome/test/BUILD.gn
+++ b/chrome/test/BUILD.gn
@@ -7345,7 +7345,6 @@
       "../browser/android/webapk/webapk_helpers_unittest.cc",
       "../browser/android/webapk/webapk_restore_manager_unittest.cc",
       "../browser/android/webapk/webapk_sync_bridge_unittest.cc",
-      "../browser/android/webapps/twa_launch_queue_delegate_unittest.cc",
       "../browser/autofill/android/attribute_instance_android_unittest.cc",
       "../browser/autofill/android/autofill_ai_save_update_entity_prompt_controller_unittest.cc",
       "../browser/autofill/android/entity_data_manager_android_unittest.cc",
Loading diff…

Original Bug Report

reported by [email protected]

Potential Android content-URI sensitive path check bypass via multi-user userId@ prefix

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: A potential parser differential in Chrome for Android may allow a co-installed malicious app to bypass sensitive path checks for content URIs. By prepending a multi-user user ID prefix (e.g., ‘0@’), an attacker can bypass Chrome’s C++ validation checks while the underlying Android ContentResolver resolves the URI to Chrome’s own private, non-exported FileProviders. This potentially grants the attacker’s Trusted Web Activity read/write File System Access handles to sensitive internal files.

Affected files:

  • chrome/browser/android/webapps/twa_launch_queue_delegate.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc

Estimated timestamp from git blame: 2024-09-04

Description

A potential logical parser differential between Chromium’s C++ path validation and Android’s ContentResolver URI parsing can allow a co-installed malicious app to bypass sensitive path checks for content URIs.

By prepending a multi-user user ID prefix (e.g., 0@ or 10@) to a content URI’s authority, the URI fails to match Chrome’s C++ prefix-based blocklist checks, while the underlying Android ContentResolver still successfully resolves the URI to Chrome’s private, non-exported FileProvider endpoints. This can potentially grant an attacker’s Trusted Web Activity (TWA) read/write File System Access (FSA) handles to sensitive internal files.

Root Cause

In chrome/browser/android/webapps/twa_launch_queue_delegate.cc, IsSensitivePath() performs a simple string prefix match to block Chrome’s own private content URIs:

// chrome/browser/android/webapps/twa_launch_queue_delegate.cc
bool IsSensitivePath(const base::FilePath& path) {
  if (path.IsContentUri()) {
    std::string package_name = base::android::apk_info::package_name();
    std::string chrome_content_prefix =
        base::StrCat({"content://", package_name, "."});
    std::string decoded_path = base::UnescapeBinaryURLComponent(
        path.value(), base::UnescapeRule::NORMAL);
    return base::StartsWith(decoded_path, chrome_content_prefix,
                            base::CompareCase::INSENSITIVE_ASCII);
  } 
  return true;
}

Similarly, chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc performs an identical prefix match in CheckPathAgainstBlocklist():

// chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
if (path_info.path.IsContentUri()) {
  std::string decoded_path = base::UnescapeBinaryURLComponent(
      path_info.path.value(), base::UnescapeRule::NORMAL);
  std::move(callback).Run(base::StartsWith(
      decoded_path,
      base::StrCat({"content://", base::android::apk_info::package_name(), "."}),
      base::CompareCase::INSENSITIVE_ASCII));
  return;
}

When a URI such as content://[email protected]/passwords/exported.csv is validated:

  1. IsContentUri() returns true because it checks for the content:// scheme.
  2. base::StartsWith compares content://[email protected]... against content://com.android.chrome....
  3. Because of the 0@ multi-user prefix, the comparison fails and the validation returns false (meaning the path is not blocked).

When Chrome attempts to read or write to this URI, the Java-side ContentUriUtils.java calls the Android system ContentResolver:

ContentResolver resolver = ContextUtils.getApplicationContext().getContentResolver();
Uri uri = Uri.parse(uriString);
afd = resolver.openAssetFileDescriptor(uri, mode);

At this point, the Android system ContentResolver normalizes the authority by stripping the userId prefix (using ContentProvider.getAuthorityWithoutUserId("[email protected]")), which evaluates to com.android.chrome.FileProvider. Since the call originates from Chrome itself, Android’s ContentProvider permissions are satisfied, allowing Chrome’s own non-exported FileProvider to resolve and return the requested file descriptor to the calling context.

Potential Trigger Steps

Note: These steps are based on static code analysis; our tooling does not currently run live exploit tests.

  1. A co-installed malicious app is configured to host a Digital Asset Links verification file matching its own origin (e.g., https://attacker.example) so that TWA origin verification succeeds.
  2. The malicious app fires an intent to Chrome with EXTRA_LAUNCH_AS_TRUSTED_WEB_ACTIVITY and adds EXTRA_FILE_HANDLING_DATA containing Uri.parse("content://[email protected]/passwords/exported.csv").
  3. Chrome processes the intent and launches the TWA. WebAppLaunchHandler processes the launch parameters.
  4. Due to the prefix mismatch (0@), IsSensitivePath permits the URI to proceed.
  5. The file handle is delivered to the TWA’s renderer via WebLaunchService with UserAction::kSave (which auto-grants read and write permissions on Android).
  6. The attacker’s JavaScript in the TWA requests file access via handle.getFile(), causing Chrome’s browser process to open the content URI using ContentResolver, stripping the 0@ prefix and retrieving a file descriptor to Chrome’s sensitive internal file, which is then exposed to the attacker’s web origin.

Suggested Fix

To prevent this bypass, Chrome should canonicalize or strip multi-user user ID prefixes from content URIs prior to performing prefix comparisons.

In both C++ files (chrome/browser/android/webapps/twa_launch_queue_delegate.cc and chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc), the authority component of the content URI should be parsed and checked for the presence of an @ character. If an @ character is found in the authority segment of the content URI, the segment preceding the @ (inclusive) should be stripped before verifying the prefix. Alternatively, Java-side validation can be introduced using ContentProvider.getUriWithoutUserId() to sanitize URIs before they flow into the native validation code.

Evaluated with Chrome root at commit: b2fea2e31df308d0f04e4ae47def4c4f939ee141


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