CVE-2026-106324
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java |
modified | |
ifchrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java |
modified | |
TEST_Fchrome/browser/webapps/installable/installable_utils_unittest.cc |
modified |
Files Changed
chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.javachrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.javachrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.javachrome/browser/webapps/installable/installable_utils_unittest.cc
Patch
From 8efa672a01a6a8a9b96e56e407e203ca65edd54f Mon Sep 17 00:00:00 2001 From: Andrew Paseltiner <[email protected]> Date: Mon, 14 Sep 2026 02:18:19 -0700 Subject: [PATCH] Use canonical Origin matching in WebappRegistry WebappRegistry previously determined whether an installed WebAPK matched a given origin using string prefix comparisons between the scope URL and the origin string. Because origin strings lack trailing delimiters, string prefix matching could produce false positive matches when distinct origins share character prefixes. Update WebappRegistry to parse both the target origin and the stored scope URL into canonical Origin objects and compare them via equality checks. Also validate against null or empty origin inputs, and provide an overload accepting Origin directly to avoid redundant re-parsing. Additionally, update hasAtLeastOneWebApkForOrigin() to ignore records with an uninstall timestamp. Uninstalled WebAPKs temporarily remain in WebappDataStorage for UKM metrics reporting until periodic cleanup, and while getOriginsWithWebApk() already checked for uninstall timestamps, hasAtLeastOneWebApkForOrigin() previously omitted this check. Also update ShortcutHelper to remove locale-dependent lowercasing (toLowerCase(Locale.getDefault())), as callers from C++ (url::Origin::Serialize) already provide canonical lowercase origins and Locale.getDefault() is prone to locale-specific bugs. TAG=agy CONV=9b842e77-b6a2-46ed-8550-44c4d669c444 Fixed: 501171258 Change-Id: I6e5ae688144b42ebf4033a06152856c54b8dbb73 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8386376 Reviewed-by: Daniel Murphy <[email protected]> Reviewed-by: Boris Sazonov <[email protected]> Auto-Submit: Andrew Paseltiner <[email protected]> Commit-Queue: Boris Sazonov <[email protected]> Cr-Commit-Position: refs/heads/main@{#1697126} --- diff --git a/chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java b/chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java index a52b4ae..153d6f5f 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java @@ -37,7 +37,6 @@ import org.chromium.components.webapps.WebappsUtils; import java.util.HashMap; -import java.util.Locale; import java.util.Map; import java.util.Set; @@ -311,8 +310,7 @@ @VisibleForTesting public static boolean doesOriginContainAnyInstalledWebApk( @JniType("std::string") String origin) { - return WebappRegistry.getInstance() - .hasAtLeastOneWebApkForOrigin(origin.toLowerCase(Locale.getDefault())); + return WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin(origin); } /** @@ -322,7 +320,7 @@ @CalledByNative @VisibleForTesting public static boolean doesOriginContainAnyInstalledTwa(@JniType("std::string") String origin) { - Origin parsedOrigin = Origin.create(origin.toLowerCase(Locale.getDefault())); + Origin parsedOrigin = Origin.create(origin); if (parsedOrigin == null) { return false; } diff --git a/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java b/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java index 6d7e883..0fc9f4a 100644 --- a/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java +++ b/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java @@ -307,24 +307,53 @@ } /** - * Returns true if a WebAPK is found whose scope matches |origin|. - * @param origin The origin to search a WebAPK for. + * Returns true if an installed WebAPK is found whose scope has the same origin as + * |targetOrigin|. + * + * @param targetOrigin The origin to search a WebAPK for. */ - public boolean hasAtLeastOneWebApkForOrigin(String origin) { + public boolean hasAtLeastOneWebApkForOrigin(@Nullable Origin targetOrigin) { + if (targetOrigin == null) { + return false; + } + for (WebappDataStorage storage : mStorages.values()) { String scope = getWebApkScopeFromStorage(storage); if (scope.isEmpty()) continue; + if (storage.getWebApkUninstallTimestamp() > 0) continue; + + Origin scopeOrigin = Origin.create(scope); + if (scopeOrigin == null) continue; + + if (!targetOrigin.equals(scopeOrigin)) continue; String webApkPackageName = storage.getWebApkPackageName(); - assumeNonNull(webApkPackageName); - if (scope.startsWith(origin) && PackageUtils.isPackageInstalled(webApkPackageName)) { + if (webApkPackageName != null && PackageUtils.isPackageInstalled(webApkPackageName)) { return true; } } return false; } - /** Returns a Set of all origins that have an installed WebAPK. */ + /** + * Returns true if an installed WebAPK is found whose scope has the same origin as |origin|. + * + * @param origin The origin to search a WebAPK for. + */ + public boolean hasAtLeastOneWebApkForOrigin(@Nullable String origin) { + if (TextUtils.isEmpty(origin)) { + return false; + } + return hasAtLeastOneWebApkForOrigin(Origin.create(origin)); + } + + /** + * Returns a Set of all origins that have an installed WebAPK. + * + * <p>Note: Unlike {@link #hasAtLeastOneWebApkForOrigin}, this method checks only the uninstall + * timestamp and avoids verifying {@link PackageUtils#isPackageInstalled} for each storage entry + * to avoid repetitive PackageManager IPCs across all registered WebAPKs. + */ private Set<String> getOriginsWithWebApk() { Set<String> origins = new HashSet<>(); for (WebappDataStorage storage : mStorages.values()) { @@ -333,8 +362,9 @@ if (storage.getWebApkUninstallTimestamp() > 0) continue; Origin origin = Origin.create(scope); - assumeNonNull(origin); - origins.add(origin.toString()); + if (origin != null) { + origins.add(origin.toString()); + } } return origins; } @@ -423,7 +453,7 @@ if (origin == null) { return false; } - if (hasAtLeastOneWebApkForOrigin(origin.toString())) { + if (hasAtLeastOneWebApkForOrigin(origin)) { return true; } return TwaValidator.isTwaInstalledForUrl(url); diff --git a/chrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.java b/chrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.java index 51030132..46c0bb3c 100644 --- a/chrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.java +++ b/chrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.java @@ -772,6 +772,71 @@ Shadows.shadowOf(RuntimeEnvironment.application.getPackageManager()) .addPackage(testPackageName); assertTrue(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin(testOrigin)); + + final String exampleUrl = "https://example.com/app/"; + final String examplePackageName = "org.chromium.webapk.example"; + BrowserServicesIntentDataProvider exampleWebApk = + new WebApkIntentDataProviderBuilder(examplePackageName, exampleUrl).build(); + registerWebapp(exampleWebApk); + Shadows.shadowOf(RuntimeEnvironment.application.getPackageManager()) + .addPackage(examplePackageName); + + assertTrue( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://example.com")); + assertTrue( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://example.com/")); + assertTrue( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin("https://example.com/other_path")); + assertTrue( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin("https://example.com:443")); + assertFalse( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://example.co")); + assertFalse( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin("https://example.com.other.domain")); + assertFalse( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("http://example.com")); + assertFalse( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin("https://example.com:8080")); + + final String companyUrl = "https://a.company.com/app/"; + final String companyPackageName = "org.chromium.webapk.company"; + BrowserServicesIntentDataProvider companyWebApk = + new WebApkIntentDataProviderBuilder(companyPackageName, companyUrl).build(); + registerWebapp(companyWebApk); + Shadows.shadowOf(RuntimeEnvironment.application.getPackageManager()) + .addPackage(companyPackageName); + + assertTrue( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://a.company.com")); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://a.com")); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://a.comp")); + + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin((String) null)); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin((Origin) null)); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("")); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("invalid_origin")); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("about:blank")); + assertFalse(WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("file:///sdcard")); + + assertTrue( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin(Origin.create("https://example.com"))); + assertFalse( + WebappRegistry.getInstance() + .hasAtLeastOneWebApkForOrigin(Origin.create("https://example.co"))); + + // Marking WebAPK as uninstalled should cause hasAtLeastOneWebApkForOrigin to return false. + String companyWebappId = companyWebApk.getWebappExtras().id; + WebappDataStorage companyStorage = + WebappRegistry.getInstance().getWebappDataStorage(companyWebappId); + assertNotNull(companyStorage); + companyStorage.setWebApkUninstallTimestamp(); + assertFalse( + WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin("https://a.company.com")); } @Test diff --git a/chrome/browser/webapps/installable/installable_utils_unittest.cc b/chrome/browser/webapps/installable/installable_utils_unittest.cc index 7b0f915..76636102 100644 --- a/chrome/browser/webapps/installable/installable_utils_unittest.cc +++ b/chrome/browser/webapps/installable/installable_utils_unittest.cc @@ -28,6 +28,10 @@ profile(), GURL("https://www.example.com"))); EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp( profile(), GURL("https://www.example2.com"))); + EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp( + profile(), GURL("https://www.example.co"))); + EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp( + profile(), GURL("https://www.example.com.other.com"))); } TEST_F(InstallableUtilsTest, GetOriginsWithInstalledWebApps) {
Regression Test / PoC
diff --git a/chrome/browser/webapps/installable/installable_utils_unittest.cc b/chrome/browser/webapps/installable/installable_utils_unittest.cc
index 7b0f915..76636102 100644
--- a/chrome/browser/webapps/installable/installable_utils_unittest.cc
+++ b/chrome/browser/webapps/installable/installable_utils_unittest.cc
@@ -28,6 +28,10 @@
profile(), GURL("https://www.example.com")));
EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp(
profile(), GURL("https://www.example2.com")));
+ EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp(
+ profile(), GURL("https://www.example.co")));
+ EXPECT_FALSE(DoesOriginContainAnyInstalledWebApp(
+ profile(), GURL("https://www.example.com.other.com")));
}
TEST_F(InstallableUtilsTest, GetOriginsWithInstalledWebApps) {
Original Bug Report
WebAPK origin prefix matching in WebappRegistry.java bypasses notification permission prompt on Android 13+ via scope.startsWith(origin) cross-origin false positives
Report description
WebAPK origin prefix matching in WebappRegistry.java bypasses notification permission prompt on Android 13+ via scope.startsWith(origin) cross-origin false positives
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
Chrome Android.
The problem
Please describe the technical details of the vulnerability
Summary
WebappRegistry.hasAtLeastOneWebApkForOrigin() uses raw string prefix matching (startsWith) to determine if a given origin has an installed WebAPK. This produces cross-origin false positives whenever one hostname is a string prefix of another, allowing an attacker-controlled origin to hijack the notification permission flow on Android 13+.
// WebappRegistry.java:272-284 (current main)
if (scope.startsWith(origin) && PackageUtils.isPackageInstalled(webApkPackageName))
False positive examples:
"https://example.com/app/".startsWith("https://example.co")= true"https://a.company.com/".startsWith("https://a.com")= true"https://my.community.org/".startsWith("https://my.com")= true
On Android 13+, when hasAtLeastOneWebApkForOrigin() returns true, Chrome skips the normal notification permission prompt entirely and delegates the decision to InstalledWebappBridge::DecidePermission().
Affected code
Primary vulnerability: WebappRegistry.java:272-284
src/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java
Call chain (verified on current main branch):
notification_permission_context.cc:168-169callsShortcutHelper::DoesOriginContainAnyInstalledWebApk(origin)ShortcutHelper.javacallsWebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin(origin.toLowerCase())WebappRegistry.java:272-284usesscope.startsWith(origin)- vulnerable check- If returns true,
notification_permission_context.cc:180-189delegates toInstalledWebappBridge::DecidePermission()instead of showing the normal prompt
Also affects: periodic_background_sync_permission_context.cc gates periodic background sync permission on installed-PWA status.
Attack scenario
- User installs a WebAPK for
https://example.com/app/(banking PWA). - Attacker controls
https://example.co. - User visits
https://example.co. - Chrome checks
hasAtLeastOneWebApkForOrigin("https://example.co"). "https://example.com/app/".startsWith("https://example.co")= true (because “com” starts with “co”).- Chrome skips the normal notification permission prompt and enters the WebAPK delegation path.
Collision examples:
https://example.com/vshttps://example.co- “com” starts with “co”https://a.company.com/vshttps://a.com- “company” starts with “com”https://my.community.org/vshttps://my.com- “community” starts with “com”
Runtime confirmation (A/B test)
Tested on a Pixel 7 (Android 14, arm64) with Chrome 146.0.7680.178 and Google Play Services v26.12.33.
Setup:
- Installed a legitimate WebAPK for
https://victim-bank.example.com/app/via Chrome’s “Add to Home screen” - Set up attacker page at
https://victim-bank.example.co/ - Verified prefix match:
"https://victim-bank.example.com/app/".startsWith("https://victim-bank.example.co")= true
A/B comparison:
- Control origin (no prefix match): Standard Chrome “Allow notifications?” prompt, blocks waiting for user input
- Attack origin (prefix match): No dialog shown, returns denied instantly (<200ms), logcat shows
cr_TWAClient: Unable to request notification permission
The cr_TWAClient log proves Chrome entered InstalledWebappBridge::DecidePermission() - the code path reserved for origins with installed WebAPKs.
Root cause
String-level origin comparison where parsed origin comparison is required. The same file already has a correct implementation:
// getOriginsWithWebApk() - CORRECT:
Origin origin = Origin.create(scope);
origins.add(origin.toString());
// Uses Set.contains() for exact match
// hasAtLeastOneWebApkForOrigin() - VULNERABLE:
scope.startsWith(origin) // Raw string prefix
Suggested fix
public boolean hasAtLeastOneWebApkForOrigin(String origin) {
for (WebappDataStorage storage : mStorages.values()) {
String scope = getWebApkScopeFromStorage(storage);
if (scope.isEmpty()) continue;
String webApkPackageName = storage.getWebApkPackageName();
assumeNonNull(webApkPackageName);
Origin scopeOrigin = Origin.create(scope);
if (scopeOrigin != null
&& scopeOrigin.toString().equals(origin)
&& PackageUtils.isPackageInstalled(webApkPackageName)) {
return true;
}
}
return false;
}
Chrome version
Chrome 146.0.7680.178 (arm64), Android 14 (Pixel 7).
Impact analysis
An attacker who controls a domain that is a string prefix of any installed WebAPK’s scope URL can:
-
Bypass notification permission prompt (Android 13+): Chrome skips the standard “Allow notifications?” dialog and routes through the WebAPK delegation path. The user never sees a permission prompt for the attacker’s origin.
-
Silent notification permission inheritance: If the victim’s WebAPK already has notification permission granted, the attacker’s origin can silently inherit that permission through the delegation path, enabling push notifications without any user consent.
-
Permanent notification denial-of-service: When delegation fails, the attacker’s origin gets denied instead of prompt, permanently preventing the user from ever granting notification permission for that origin through Chrome’s normal flow.
-
Unauthorized periodic background sync: The same prefix match check gates periodic background sync permissions, allowing the attacker to register background sync tasks without meeting Chrome’s normal engagement requirements.
Attack complexity is low - attacker only needs to register a domain that is a string prefix of a victim’s installed WebAPK scope. Many natural collisions exist between .co and .com TLDs, and .com substring in longer TLDs like .community, .company, .commerce.
The cause
What version of Chrome have you found the security issue in?
146.0.7680.178 stable (Android 14, Pixel 7 emulator)
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Permissions Bypass
How would you like to be publicly acknowledged for your report?
Narenda Singh (@_3P1C)