CVE-2026-13851
Overview
Files Changed
chrome/browser/android/webapps/twa_launch_queue_delegate.ccchrome/browser/file_system_access/chrome_file_system_access_permission_context.ccchrome/browser/file_system_access/chrome_file_system_access_permission_context_unittest.cc
Patch
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"),
Regression Test / PoC
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"),
Original Bug Report
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.ccchrome/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.
- A co-installed malicious app
com.evil.twahosts Digital Asset Links verification metadata forhttps://evil.exampleto establish TWA trust. - The app fires a TWA intent with a percent-encoded file URI in
EXTRA_FILE_HANDLING_DATAtargeting:content://com.android.chrome%2EFileProvider/downloads/victim.pdf - Chrome verifies the TWA trust and notifies the launch queue via JNI without normalizing or URL-decoding the input.
- C++ blocklist checks verify the raw string against
"content://com.android.chrome."and miss the match because of the%2Eencoding. - The file handle is granted to the renderer with write permissions (
UserAction::kSavepolicy). - The renderer uses the handle to write or read data. This request is mapped to
ContentUriUtils.javain the browser process. - The Android OS decodes the authority to
com.android.chrome.FileProviderand grants access because the requester runs under the same UID (confused deputy bypass ofexported="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.