Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
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
Tracker497538899
Fix commit8257e453fbec (chromium/src) +153/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
for
chrome/browser/web_applications/web_app_scope.cc
modified
if
chrome/browser/web_applications/web_app_scope.cc
modified
TEST_F
chrome/browser/web_applications/web_app_scope_unittest.cc
modified

Files Changed

  • chrome/browser/web_applications/web_app_scope.cc
  • chrome/browser/web_applications/web_app_scope_unittest.cc
From 8257e453fbecac25e0b18658fda442ed7cff9601 Mon Sep 17 00:00:00 2001
From: Lu Huang <[email protected]>
Date: Fri, 17 Apr 2026 10:54:52 -0700
Subject: [PATCH] [dPWA] Fix wildcard scope extension matching to check port properly

Bug: 497538899
Change-Id: I527682e0073466436394af7233ed9cedc6ba0157
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7761315
Reviewed-by: Daniel Murphy <[email protected]>
Commit-Queue: Lu Huang <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1616695}
---

diff --git a/chrome/browser/web_applications/web_app_scope.cc b/chrome/browser/web_applications/web_app_scope.cc
index 2195120..9e31210e 100644
--- a/chrome/browser/web_applications/web_app_scope.cc
+++ b/chrome/browser/web_applications/web_app_scope.cc
@@ -43,9 +43,10 @@
   }
 
   int score = 0;
-  std::string origin_string = origin.Serialize();
   for (const ScopeExtensionInfo& scope_extension : validated_scope_extensions) {
     CHECK(scope_extension.scope.is_valid());
+    // SENSITIVE is safe: GURL::spec() returns a canonicalized URL with a
+    // lowercase host.
     if (base::StartsWith(url.spec(), scope_extension.scope.spec(),
                          base::CompareCase::SENSITIVE)) {
       if (behavior == ScoreBehavior::kExitEarlyPositiveNumberFirstMatch) {
@@ -56,13 +57,18 @@
     }
 
     // Origins with wildcard e.g. *.foo are saved as https://foo.
-    // Ensure while matching that the origin ends with '.foo' and not 'foo'.
+    // Ensure while matching that the origin ends with '.foo' and not 'foo',
+    // and that the ports match (scheme is already guaranteed to be https).
     if (scope_extension.has_origin_wildcard) {
-      if (base::EndsWith(origin_string, scope_extension.origin.host(),
+      const std::string& target_host = origin.host();
+      const std::string& extension_host = scope_extension.origin.host();
+      // SENSITIVE is safe: url::Origin canonicalizes the host to lowercase
+      // for https schemes (enforced by the scheme check above).
+      if (origin.port() == scope_extension.origin.port() &&
+          base::EndsWith(target_host, extension_host,
                          base::CompareCase::SENSITIVE) &&
-          origin_string.size() > scope_extension.origin.host().size() &&
-          origin_string[origin_string.size() -
-                        scope_extension.origin.host().size() - 1] == '.') {
+          target_host.size() > extension_host.size() &&
+          target_host[target_host.size() - extension_host.size() - 1] == '.') {
         if (behavior == ScoreBehavior::kExitEarlyPositiveNumberFirstMatch) {
           return 1;
         }
diff --git a/chrome/browser/web_applications/web_app_scope_unittest.cc b/chrome/browser/web_applications/web_app_scope_unittest.cc
index 509c10b..3ab30d1c 100644
--- a/chrome/browser/web_applications/web_app_scope_unittest.cc
+++ b/chrome/browser/web_applications/web_app_scope_unittest.cc
@@ -141,6 +141,147 @@
   EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://another.com/")));
 }
 
+TEST_F(WebAppScopeTest, WildcardScopeExtensionPortMatching) {
+  const GURL app_scope("https://example.com:8080/");
+
+  // Wildcard scope extension declared for port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "example.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Same port (8080) subdomain should match.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:8080/page")));
+
+  // Multi-level subdomain on same port should match.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://a.b.example.com:8080/page")));
+
+  // Default port (443) subdomain must NOT match a port-8080 extension.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://sub.example.com/page")));
+
+  // Different non-default port must not match.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:9090/page")));
+
+  // The app's own scope (exact match on port 8080) should still match.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://example.com:8080/path")));
+
+  // The extension host itself on a non-default port is within the app's own
+  // scope, so it matches via the scope path (not the wildcard branch).
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://example.com:8080/other")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionDefaultPort) {
+  const GURL app_scope("https://example.com/");
+
+  // Wildcard scope extension declared for default port (443).
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::Create(GURL("https://example.com")),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Default port subdomain should match default port extension.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://sub.example.com/page")));
+
+  // Non-default port subdomain must NOT match default port extension.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:8080/page")));
+}
+
+TEST_F(WebAppScopeTest, NonWildcardScopeExtensionPortMismatch) {
+  const GURL app_scope("https://example.com/");
+
+  // Non-wildcard scope extension for a specific origin on port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "other.com", 8080),
+          /*has_origin_wildcard=*/false)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Exact origin match on same port should match (via scope prefix check).
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://other.com:8080/page")));
+
+  // Same host on default port must NOT match a port-8080 extension.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com/page")));
+
+  // Same host on different non-default port must not match.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com:9090/page")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionExactHostOnNonDefaultPort) {
+  const GURL app_scope("https://example.com/");
+
+  // Wildcard scope extension for a *different* domain on a non-default port.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "other.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Subdomain on same port should match via the wildcard branch.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://sub.other.com:8080/page")));
+
+  // Exact host on same port should match via scope prefix check, not wildcard.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://other.com:8080/page")));
+
+  // Exact host on default port must NOT match.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com/page")));
+
+  // Subdomain on wrong port must NOT match.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.other.com:9090/page")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionCaseNormalization) {
+  const GURL app_scope("https://example.com:8080/");
+
+  // Wildcard scope extension declared for port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "example.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Mixed-case hostnames should match because url::Origin canonicalizes hosts
+  // to lowercase for https schemes, making SENSITIVE comparison safe.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://Sub.Example.Com:8080/page")));
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://SUB.EXAMPLE.COM:8080/page")));
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://A.B.Example.COM:8080/page")));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/web_app_scope_unittest.cc b/chrome/browser/web_applications/web_app_scope_unittest.cc
index 509c10b..3ab30d1c 100644
--- a/chrome/browser/web_applications/web_app_scope_unittest.cc
+++ b/chrome/browser/web_applications/web_app_scope_unittest.cc
@@ -141,6 +141,147 @@
   EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://another.com/")));
 }
 
+TEST_F(WebAppScopeTest, WildcardScopeExtensionPortMatching) {
+  const GURL app_scope("https://example.com:8080/");
+
+  // Wildcard scope extension declared for port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "example.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Same port (8080) subdomain should match.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:8080/page")));
+
+  // Multi-level subdomain on same port should match.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://a.b.example.com:8080/page")));
+
+  // Default port (443) subdomain must NOT match a port-8080 extension.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://sub.example.com/page")));
+
+  // Different non-default port must not match.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:9090/page")));
+
+  // The app's own scope (exact match on port 8080) should still match.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://example.com:8080/path")));
+
+  // The extension host itself on a non-default port is within the app's own
+  // scope, so it matches via the scope path (not the wildcard branch).
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://example.com:8080/other")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionDefaultPort) {
+  const GURL app_scope("https://example.com/");
+
+  // Wildcard scope extension declared for default port (443).
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::Create(GURL("https://example.com")),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Default port subdomain should match default port extension.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://sub.example.com/page")));
+
+  // Non-default port subdomain must NOT match default port extension.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.example.com:8080/page")));
+}
+
+TEST_F(WebAppScopeTest, NonWildcardScopeExtensionPortMismatch) {
+  const GURL app_scope("https://example.com/");
+
+  // Non-wildcard scope extension for a specific origin on port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "other.com", 8080),
+          /*has_origin_wildcard=*/false)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Exact origin match on same port should match (via scope prefix check).
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://other.com:8080/page")));
+
+  // Same host on default port must NOT match a port-8080 extension.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com/page")));
+
+  // Same host on different non-default port must not match.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com:9090/page")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionExactHostOnNonDefaultPort) {
+  const GURL app_scope("https://example.com/");
+
+  // Wildcard scope extension for a *different* domain on a non-default port.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "other.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Subdomain on same port should match via the wildcard branch.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://sub.other.com:8080/page")));
+
+  // Exact host on same port should match via scope prefix check, not wildcard.
+  EXPECT_TRUE(web_app_scope->IsInScope(GURL("https://other.com:8080/page")));
+
+  // Exact host on default port must NOT match.
+  EXPECT_FALSE(web_app_scope->IsInScope(GURL("https://other.com/page")));
+
+  // Subdomain on wrong port must NOT match.
+  EXPECT_FALSE(
+      web_app_scope->IsInScope(GURL("https://sub.other.com:9090/page")));
+}
+
+TEST_F(WebAppScopeTest, WildcardScopeExtensionCaseNormalization) {
+  const GURL app_scope("https://example.com:8080/");
+
+  // Wildcard scope extension declared for port 8080.
+  const std::vector<ScopeExtensionInfo> scope_extensions = {
+      ScopeExtensionInfo::CreateForOrigin(
+          url::Origin::CreateFromNormalizedTuple("https", "example.com", 8080),
+          /*has_origin_wildcard=*/true)};
+
+  webapps::AppId app_id =
+      InstallWebAppWithScopeExtensions(app_scope, scope_extensions);
+  std::optional<WebAppScope> web_app_scope =
+      registrar().GetEffectiveScope(app_id);
+  ASSERT_TRUE(web_app_scope);
+
+  // Mixed-case hostnames should match because url::Origin canonicalizes hosts
+  // to lowercase for https schemes, making SENSITIVE comparison safe.
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://Sub.Example.Com:8080/page")));
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://SUB.EXAMPLE.COM:8080/page")));
+  EXPECT_TRUE(
+      web_app_scope->IsInScope(GURL("https://A.B.Example.COM:8080/page")));
+}
+
 TEST_F(WebAppScopeTest, LongerScopeWins) {
   const GURL scope1("https://example.com/");
   const GURL scope2("https://example.com/app/");
Loading diff…

Original Bug Report

reported by [email protected]

Cross-origin URL interception via PWA wildcard scope extension logic flaw

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: A logic flaw in PWA wildcard scope extension matching drops the port during origin comparison. This allows an attacker who controls a non-default port to capture navigations intended for the default port of the same domain by serving a malicious manifest from a compromised renderer.

Affected files:

  • chrome/browser/web_applications/web_app_scope.cc
  • components/webapps/browser/launch_queue/launch_queue.cc
  • chrome/browser/ui/web_applications/navigation_capturing_process.cc
  • chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
  • chrome/browser/ui/web_applications/web_app_browser_controller.cc

Estimated timestamp from git blame: 2025-09-09

Description

A logic flaw exists in the PWA scope extension matching logic where the port is ignored during wildcard origin comparisons. This allows an attacker who controls a non-default port on a domain (e.g., https://example.com:8080) to capture navigations to the default port of the same domain and its subdomains (e.g., https://sub.example.com:443).

The vulnerability lies in GetScopeExtensionsScore within chrome/browser/web_applications/web_app_scope.cc. When evaluating wildcard scope extensions, the code compares the serialized target origin with the host of the scope extension:

    if (scope_extension.has_origin_wildcard) {
      if (base::EndsWith(origin_string, scope_extension.origin.host(),
                         base::CompareCase::SENSITIVE) && ...

Here, origin_string is the result of url::Origin::Serialize(). For a target URL on the default HTTPS port (e.g., https://sub.example.com), Serialize() omits the port. The comparison uses scope_extension.origin.host(), which always returns the host without the port (e.g., example.com). Because both strings lack port information, base::EndsWith incorrectly matches https://sub.example.com against a scope extension for https://example.com:8080.

Furthermore, while the kWebAppEnableScopeExtensionsBySite feature (which allows wildcard scope extensions) is disabled by default, the browser process blindly trusts the has_origin_wildcard flag provided by the renderer in the blink::mojom::ManifestScopeExtension IPC message. ToWebAppScopeExtensions() in chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc fails to validate the feature flag, allowing a compromised renderer to register wildcard scope extensions even when the feature is disabled.

Potential Attack Scenario

  1. An attacker controls https://example.com:8080 but not https://example.com (port 443).
  2. The attacker hosts a malicious PWA on port 8080 and compromises the renderer for this origin.
  3. The user visits https://example.com:8080 and is persuaded to install the PWA.
  4. During installation, the compromised renderer sends a crafted blink::mojom::Manifest to the browser process via IPC, setting has_origin_wildcard = true for a scope extension originating at https://example.com:8080.
  5. The browser process bypasses the kWebAppEnableScopeExtensionsBySite feature flag check and accepts the wildcard extension.
  6. The browser validates the extension by fetching the association file from the attacker’s server at https://example.com:8080/.well-known/web-app-origin-association. The attacker returns a valid JSON association.
  7. The PWA is installed and configured with "launch_handler": {"client_mode": "focus-existing"}.
  8. Later, the user navigates to a sensitive URL on the default port, e.g., https://sub.example.com/oauth_callback?code=SECRET.
  9. The navigation capturing logic evaluates the URL against the PWA’s scope extensions. Due to the missing port check in GetScopeExtensionsScore, it falsely determines the URL is in-scope.
  10. The browser redirects the navigation to the attacker’s PWA window, and the sensitive URL is delivered directly to the attacker’s JavaScript via the launchQueue API, exfiltrating the OAuth code.

Suggested Fix

  1. In GetScopeExtensionsScore (chrome/browser/web_applications/web_app_scope.cc), properly check that the ports of both the target origin and the scope extension origin match. For wildcard matching, ensure the port logic aligns with origin equality semantics rather than just doing string comparisons on hosts.
  2. In ToWebAppScopeExtensions() (chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc), validate that the kWebAppEnableScopeExtensionsBySite feature flag is enabled before allowing has_origin_wildcard to be true. If the flag is disabled, force it to false or reject the scope extension.

Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0


Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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