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
Tracker519692255
Fix commit15928cbdebd1 (chromium/src) +20/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Files Changed

  • chrome/browser/android/webapps/twa_launch_queue_delegate.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
  • chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
From 15928cbdebd153a46c4b84f25da2280359ae7705 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Wed, 10 Jun 2026 18:05:05 -0700
Subject: [PATCH] FileSystemAccess: Fix content-URI authority blocklist bypass via percent-encoding

Percent-decode the content URI before checking if it matches Chrome's
internal FileProvider authority. This prevents bypasses where
percent-encoded characters (like %2E for .) are used to evade the
C++ prefix check but are later decoded by Android's ContentResolver.

TAG=agy
CONV=a69b2a91-6918-4d45-8677-ec7d36ce1f2d

Change-Id: I12ac6a66bb59c7334d5ddd08f79a599343b88bb8
Fixed: 519692255
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7903809
Reviewed-by: Glenn Hartmann <hartmanng@chromium.org>
Reviewed-by: Andy Paicu <andypaicu@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1645014}
---

diff --git a/chrome/browser/android/webapps/twa_launch_queue_delegate.cc b/chrome/browser/android/webapps/twa_launch_queue_delegate.cc
index babfddca..eea2d12 100644
--- a/chrome/browser/android/webapps/twa_launch_queue_delegate.cc
+++ b/chrome/browser/android/webapps/twa_launch_queue_delegate.cc
@@ -6,6 +6,7 @@
 
 #include "base/android/apk_info.h"
 #include "base/files/file_path.h"
+#include "base/strings/escape.h"
 #include "base/strings/strcat.h"
 #include "base/strings/string_util.h"
 #include "components/webapps/browser/launch_queue/launch_params.h"
@@ -21,7 +22,9 @@
     std::string package_name = base::android::apk_info::package_name();
     std::string chrome_content_prefix =
         base::StrCat({"content://", package_name, "."});
-    return base::StartsWith(path.value(), chrome_content_prefix,
+    std::string decoded_path = base::UnescapeBinaryURLComponent(
+        path.value(), base::UnescapeRule::NORMAL);
+    return base::StartsWith(decoded_path, chrome_content_prefix,
                             base::CompareCase::INSENSITIVE_ASCII);
   }
 
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
index bd610b8..b544f92 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc
@@ -23,6 +23,7 @@
 #include "base/metrics/histogram_functions.h"
 #include "base/notreached.h"
 #include "base/path_service.h"
+#include "base/strings/escape.h"
 #include "base/strings/strcat.h"
 #include "base/task/bind_post_task.h"
 #include "base/task/sequenced_task_runner.h"
@@ -2171,8 +2172,10 @@
   // The only check for content-URIs is that they are not from an internal
   // FileProvider.
   if (path_info.path.IsContentUri()) {
+    std::string decoded_path = base::UnescapeBinaryURLComponent(
+        path_info.path.value(), base::UnescapeRule::NORMAL);
     std::move(callback).Run(base::StartsWith(
-        path_info.path.value(),
+        decoded_path,
         base::StrCat(
             {"content://", base::android::apk_info::package_name(), "."}),
         base::CompareCase::INSENSITIVE_ASCII));
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 05dc903..d6e6ae8 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -1093,6 +1093,18 @@
                         ".fileprovider/cache/file"})),
       HandleType::kFile));
 
+  // Percent-encoded authority should also fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "%2Efileprovider/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "%2efileprovider/cache/file"})),
+      HandleType::kFile));
+
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/dir"),
                             HandleType::kDirectory));
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/file"),
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
index 05dc903..d6e6ae8 100644
--- a/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
+++ b/chrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
@@ -1093,6 +1093,18 @@
                         ".fileprovider/cache/file"})),
       HandleType::kFile));
 
+  // Percent-encoded authority should also fail.
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "%2Efileprovider/cache/dir"})),
+      HandleType::kDirectory));
+  EXPECT_TRUE(IsOpenAbort(
+      base::FilePath(
+          base::StrCat({"content://", base::android::apk_info::package_name(),
+                        "%2efileprovider/cache/file"})),
+      HandleType::kFile));
+
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/dir"),
                             HandleType::kDirectory));
   EXPECT_TRUE(IsOpenAllowed(base::FilePath("content://authority/file"),
Loading diff…

Original Bug Report

reported by vm...@google.com

Potential content-URI authority blocklist bypass in Chrome for Android via percent-encoding

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 between Chrome’s raw-byte prefix checking and Android’s ContentResolver URI parsing allows a bypass of content-URI blocklists. By percent-encoding the authority (e.g., using ‘%2E’ instead of ‘.’), a malicious Trusted Web Activity (TWA) can potentially bypass Chrome’s blocklist checks. This may enable a malicious TWA to obtain unauthorized read or write access to Chrome’s internal FileProvider paths.

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

Potential Security Issue Overview

There is a potential parser differential vulnerability in Chrome for Android that can allow a malicious Trusted Web Activity (TWA) to bypass content-URI blocklist checks.

Chrome prevents TWAs from opening Chrome’s own internal content URIs by performing raw-string prefix checks in C++. However, when the URI is actually opened at the Java platform level, Android’s ContentResolver decodes the authority component. This discrepancy allows percent-encoded authorities (e.g., %2E instead of .) to bypass C++ blocklist checks while still resolving to Chrome’s own non-exported FileProviders.

Root Cause Analysis

In chrome/browser/android/webapps/twa_launch_queue_delegate.cc and chrome/browser/file_system_access/chrome_file_system_access_permission_context.cc, Chrome checks if a path is sensitive by executing a raw-byte comparison against a prefix containing Chrome’s package name followed by a dot:

// chrome/browser/android/webapps/twa_launch_queue_delegate.cc
if (path.IsContentUri()) {
  std::string package_name = base::android::apk_info::package_name();
  std::string chrome_content_prefix = base::StrCat({"content://", package_name, "."});
  return base::StartsWith(path.value(), chrome_content_prefix, base::CompareCase::INSENSITIVE_ASCII);
}

If the package name is com.android.chrome, the expected prefix is "content://com.android.chrome.".

If a malicious app registers a TWA file-handling URI with a percent-encoded dot in the authority (e.g., content://com.android.chrome%2EFileProvider/downloads/victim.pdf), the comparison at base::StartsWith fails because character offset 28 contains % (0x25) instead of . (0x2E).

When Chrome later resolves the URI to open the file, it executes the JNI call ContentUriUtils.openContentUri (base/android/content_uri_utils.cc), which calls getAssetFileDescriptor inside ContentUriUtils.java:

Uri uri = Uri.parse(uriString);
...
afd = resolver.openAssetFileDescriptor(uri, mode);

Under Android OS specifications, ContentResolver extracts the provider authority using uri.getAuthority(), which explicitly returns the percent-decoded authority. Consequently, %2E decodes to ., yielding com.android.chrome.FileProvider. The request is successfully routed to Chrome’s own internal FileProvider, bypassing the safety check.

Potential Attack Scenario

Note: Our tooling does not currently have the capability to execute code or run functional POCs. The steps below represent a theoretical, potential sequence of events based on static code analysis.

  1. A co-installed malicious app com.evil.twa hosts Digital Asset Links verification metadata for https://evil.example to establish TWA trust.
  2. The app fires a TWA intent with a percent-encoded file URI in EXTRA_FILE_HANDLING_DATA targeting: content://com.android.chrome%2EFileProvider/downloads/victim.pdf
  3. Chrome verifies the TWA trust and notifies the launch queue via JNI without normalizing or URL-decoding the input.
  4. C++ blocklist checks verify the raw string against "content://com.android.chrome." and miss the match because of the %2E encoding.
  5. The file handle is granted to the renderer with write permissions (UserAction::kSave policy).
  6. The renderer uses the handle to write or read data. This request is mapped to ContentUriUtils.java in the browser process.
  7. The Android OS decodes the authority to com.android.chrome.FileProvider and grants access because the requester runs under the same UID (confused deputy bypass of exported="false").

Suggested Fix

Before executing prefix validation on Content URIs in C++, Chrome should normalize the paths. Alternatively, the validation check should occur on a decoded or parsed representation of the authority.

For instance, we can decode percent-encoded sequences in the C++ string before verifying prefixes, or parse the URI via a robust URI parser (such as GURL or a utility that mirrors Android’s decoding logic) and match against the decoded host/authority component.

Evaluated with Chrome root at commit: 9ebf4302210513a012c901d87a2668b3aadf8cc1


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