Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in WebAppInstalls
DescriptionIncorrect authorization in WebAppInstalls
ComponentWebAppInstalls
Sub-componentUI>Browser>WebAppInstalls
Bug ClassAuthorization Error
Tracker501171258
Fix commit8efa672a01a6 (chromium/src) +110/-13
CISA KEVNot listed
CreditedNarenda Singh (@_3P1C)
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
if
chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java
modified
if
chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java
modified
TEST_F
chrome/browser/webapps/installable/installable_utils_unittest.cc
modified

Files Changed

  • chrome/android/java/src/org/chromium/chrome/browser/ShortcutHelper.java
  • chrome/android/junit/src/org/chromium/chrome/browser/webapps/WebappRegistryTest.java
  • chrome/android/java/src/org/chromium/chrome/browser/webapps/WebappRegistry.java
  • chrome/browser/webapps/installable/installable_utils_unittest.cc
From 8efa672a01a6a8a9b96e56e407e203ca65edd54f Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
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 <dmurph@chromium.org>
Reviewed-by: Boris Sazonov <bsazonov@chromium.org>
Auto-Submit: Andrew Paseltiner <apaseltiner@chromium.org>
Commit-Queue: Boris Sazonov <bsazonov@chromium.org>
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) {
Loading diff…

Regression Test / PoC

shipped with the fix
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) {
Loading diff…

Original Bug Report

reported by na...@gmail.com

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):

  1. notification_permission_context.cc:168-169 calls ShortcutHelper::DoesOriginContainAnyInstalledWebApk(origin)
  2. ShortcutHelper.java calls WebappRegistry.getInstance().hasAtLeastOneWebApkForOrigin(origin.toLowerCase())
  3. WebappRegistry.java:272-284 uses scope.startsWith(origin) - vulnerable check
  4. If returns true, notification_permission_context.cc:180-189 delegates to InstalledWebappBridge::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

  1. User installs a WebAPK for https://example.com/app/ (banking PWA).
  2. Attacker controls https://example.co.
  3. User visits https://example.co.
  4. Chrome checks hasAtLeastOneWebApkForOrigin("https://example.co").
  5. "https://example.com/app/".startsWith("https://example.co") = true (because “com” starts with “co”).
  6. Chrome skips the normal notification permission prompt and enters the WebAPK delegation path.

Collision examples:

  • https://example.com/ vs https://example.co - “com” starts with “co”
  • https://a.company.com/ vs https://a.com - “company” starts with “com”
  • https://my.community.org/ vs https://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:

  1. Installed a legitimate WebAPK for https://victim-bank.example.com/app/ via Chrome’s “Add to Home screen”
  2. Set up attacker page at https://victim-bank.example.co/
  3. 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:

  1. 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.

  2. 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.

  3. 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.

  4. 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)

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)

View on issue tracker