Low watch CVSS 5.4 chrome Policy Bypass 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebApp
DescriptionInsufficient policy enforcement in WebApp
ComponentWebApp
Sub-componentPWA
Bug ClassPolicy Bypass
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker498353173
Fix commitc61b9844f197 (chromium/src) +57/-5
CWECWE-451
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:L
CISA KEVNot listed
CreditedGoogle
Reported2026-03-31 (per vendor advisory)
Fix landed2026-04-03 (public git)
Disclosed2026-05-05
Patch gap32 days — fix public in source before the advisory

Background

PWA / web app install
The flow where Chrome turns a page into an installed application, deriving fields such as start_url from the page’s manifest and WebPageMetadata.
`WebPageMetadata`
Renderer-supplied page metadata (title, description, application_url) sent over Mojo and consumed by the browser to populate WebAppInstallInfo.
`start_url`
The URL an installed app launches to; if it can be set cross-origin it determines which origin the installed app effectively represents.
`url::IsSameOriginWith`
Utility that compares two GURLs for same-origin equality, used by the fix to validate application_url against the committed page URL.

Root Cause Analysis

When a PWA install needed to populate start_url from the page’s application_url metadata, the browser trusted the renderer-supplied metadata->application_url without checking it against the frame’s actual origin. In web_app_data_retriever.cc, PopulateWebAppInfoFromMetadata consumed metadata->application_url directly, and on Android shortcut_info.cc similarly adopted metadata.application_url as the shortcut url/scope whenever it was merely is_valid(). Because WebPageMetadata originates in the (potentially compromised) renderer, this let a cross-origin application_url flow into the installed app’s start_url, violating the invariant that an installed app must represent the origin the user actually visited.

The fix clamps metadata->application_url to an empty GURL() when it is not same-origin with contents->GetLastCommittedURL() (which forces a fallback to the trusted start_url computed from the web contents), and the Android path only adopts application_url when url::IsSameOriginWith(metadata.application_url, url) holds. The install command change additionally reuses the already-validated install_info when falling back to generated icons, so validated manifest fields take priority.

Key insight
The browser treated a renderer-controlled application_url as trustworthy when deriving an installed app’s origin; the fix re-anchors that value to the last committed URL via a same-origin check, falling back to the browser-computed start_url on mismatch.

Attack Path

  1. Compromised renderer An attacker who controls a renderer (e.g. via a separate renderer bug) can populate arbitrary WebPageMetadata, including a cross-origin application_url. (inferred as the threat model stated in the added comment)
  2. Trigger install During a PWA install where the manifest lacks an explicit start URL, the browser reaches the metadata-population path in web_app_data_retriever.cc / shortcut_info.cc.
  3. Cross-origin value accepted Pre-patch, the only gate was application_url.is_valid(), so the attacker’s cross-origin URL was copied into start_url/scope without an origin check.
  4. Misattributed app The installed app’s start_url (and Android scope) now points at a different origin than the page that was installed, an insufficient-policy-enforcement outcome for the WebApp component.

Impact Assessment

Per the vendor advisory this is a low-severity insufficient-policy-enforcement issue: an attacker controlling a renderer can cause an installed PWA/shortcut to adopt a cross-origin start_url/scope, misrepresenting which origin the app belongs to. The precondition is a PWA installation flow combined with attacker-controlled page metadata; no memory corruption is shown or implied by the diff.

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
modified
TEST_F
components/webapps/browser/android/shortcut_info_unittest.cc
modified

Files Changed

  • chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc
  • chrome/browser/web_applications/web_contents/web_app_data_retriever.cc
  • chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
  • components/webapps/browser/android/shortcut_info.cc
  • components/webapps/browser/android/shortcut_info_unittest.cc

Audit Directions

  • Renderer-supplied URLs as app identity
    Audit every place WebPageMetadata/manifest fields set start_url, scope, or origin-bearing values, confirming each is validated against the committed URL rather than merely is_valid().
  • `is_valid()` used as a security gate
    Flag checks where GURL::is_valid() substitutes for an origin or same-origin comparison before trusting a URL.
  • Install/shortcut fallback paths
    Review fallback branches (generated icons, missing manifest fields) to ensure they reuse validated data instead of re-reading untrusted metadata.
From c61b9844f19794ea11031449ddc7f0065d3200af Mon Sep 17 00:00:00 2001
From: Dibyajyoti Pal <dibyapal@google.com>
Date: Fri, 03 Apr 2026 14:33:51 -0700
Subject: [PATCH] [PWA] Enforce same origin constraints for application url metadata

In the event of a PWA installation needing to populate the start_url
from the application url obtained from the page metadata, this CL
enforces strict same origin guarantees, comparing to the last committed
url of the web contents. A change has also been made in the user
installation flow, to reuse the validated install_info parsed from the
manifest while installing an app with generated icons, to prioritize
manifest fields that are validated.

Fixed: 498353173
Change-Id: I44dc983ab3e21d780cef6729236f84a582f61cf7
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7728736
Commit-Queue: Dibyajyoti Pal <dibyapal@chromium.org>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1609957}
---

diff --git a/chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc b/chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc
index edd0fc1..c56da847 100644
--- a/chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc
+++ b/chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc
@@ -583,7 +583,10 @@
       GetMutableDebugValue().Set("used_fallback_after_icon_download_failed",
                                  true);
       valid_manifest_for_crafted_web_app_ = false;
+
+      web_app_info_ = std::move(install_info);
       web_app_info_->is_diy_app = true;
+
       data_retriever_->GetIcons(
           web_contents_.get(), {},
           /*download_page_favicons=*/true,
diff --git a/chrome/browser/web_applications/web_contents/web_app_data_retriever.cc b/chrome/browser/web_applications/web_contents/web_app_data_retriever.cc
index bfad9c8..2989237a 100644
--- a/chrome/browser/web_applications/web_contents/web_app_data_retriever.cc
+++ b/chrome/browser/web_applications/web_contents/web_app_data_retriever.cc
@@ -317,6 +317,16 @@
   }
   CHECK(metadata);
 
+  // Ensure that the metadata's application URL is same origin as the page, to
+  // prevent a compromised renderer from installing cross-origin apps. Setting
+  // this to an empty GURL allows a fallback to the `start_url`, which is
+  // computed from the web contents itself.
+  if (metadata->application_url.is_valid() &&
+      !url::IsSameOriginWith(metadata->application_url,
+                             contents->GetLastCommittedURL())) {
+    metadata->application_url = GURL();
+  }
+
   std::unique_ptr<WebAppInstallInfo> info = std::move(fallback_install_info_);
   PopulateWebAppInfoFromMetadata(info.get(), *metadata);
   std::move(get_web_app_info_callback_).Run(std::move(info));
diff --git a/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc b/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
index db59e28a..72c67cb 100644
--- a/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
+++ b/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
@@ -187,9 +187,9 @@
 TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_AppUrlPresent) {
   SetFakeWebPageMetadataAgent();
 
-  web_contents_tester()->NavigateAndCommit(GURL("https://foo.example"));
-
   GURL other_app_url = GURL("https://bar.example");
+  web_contents_tester()->NavigateAndCommit(other_app_url);
+
   std::u16string other_app_title = u"Other App Title";
   SetRendererWebPageMetadata(other_app_url, other_app_title,
                              /*description=*/u"");
@@ -206,6 +206,30 @@
   EXPECT_EQ(other_app_title, web_app_info()->title.value());
 }
 
+TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_AppUrlCrossOrigin) {
+  SetFakeWebPageMetadataAgent();
+
+  const GURL kFooUrl("https://foo.example");
+  web_contents_tester()->NavigateAndCommit(kFooUrl);
+
+  GURL other_app_url = GURL("https://bar.example");
+  std::u16string other_app_title = u"Other App Title";
+  SetRendererWebPageMetadata(other_app_url, other_app_title,
+                             /*description=*/u"");
+
+  base::RunLoop run_loop;
+  WebAppDataRetriever retriever;
+  retriever.GetWebAppInstallInfo(
+      web_contents(),
+      base::BindOnce(&WebAppDataRetrieverTest::GetWebAppInstallInfoCallback,
+                     base::Unretained(this), run_loop.QuitClosure()));
+  run_loop.Run();
+
+  // If the origin differs, we fallback to the url that was already in the
+  // web_app_info().
+  EXPECT_EQ(kFooUrl, web_app_info()->start_url());
+}
+
 TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_TitleAbsentFromRenderer) {
   SetFakeWebPageMetadataAgent();
 
diff --git a/components/webapps/browser/android/shortcut_info.cc b/components/webapps/browser/android/shortcut_info.cc
index 705a6d1..cd255d7a 100644
--- a/components/webapps/browser/android/shortcut_info.cc
+++ b/components/webapps/browser/android/shortcut_info.cc
@@ -135,7 +135,8 @@
   if (!metadata.description.empty()) {
     description = metadata.description;
   }
-  if (metadata.application_url.is_valid()) {
+  if (metadata.application_url.is_valid() &&
+      url::IsSameOriginWith(metadata.application_url, url)) {
     url = metadata.application_url;
     scope = metadata.application_url.GetWithoutFilename();
   }
diff --git a/components/webapps/browser/android/shortcut_info_unittest.cc b/components/webapps/browser/android/shortcut_info_unittest.cc
index fb4dc05..37f0379 100644
--- a/components/webapps/browser/android/shortcut_info_unittest.cc
+++ b/components/webapps/browser/android/shortcut_info_unittest.cc
@@ -147,12 +147,13 @@
 }
 
 TEST_F(ShortcutInfoTest, UpdateFromWebPageMetadata) {
-  info_ = ShortcutInfo(GURL());
+  const GURL kAppUrl("https://new.com/start");
+  info_ = ShortcutInfo(kAppUrl);
   webapps::mojom::WebPageMetadataPtr metadata =
       webapps::mojom::WebPageMetadata::New();
   metadata->application_name = u"new title";
   metadata->description = u"new description";
-  metadata->application_url = GURL("https://new.com/start");
+  metadata->application_url = kAppUrl;
   metadata->mobile_capable = mojom::WebPageMobileCapable::ENABLED;
 
   info_.UpdateFromWebPageMetadata(*metadata);
@@ -166,6 +167,19 @@
   ASSERT_EQ(blink::mojom::DisplayMode::kStandalone, info_.display);
 }
 
+TEST_F(ShortcutInfoTest, UpdateFromWebPageMetadataCrossOrigin) {
+  const GURL kInitialUrl("https://old.com/start");
+  info_ = ShortcutInfo(kInitialUrl);
+  webapps::mojom::WebPageMetadataPtr metadata =
+      webapps::mojom::WebPageMetadata::New();
+  metadata->application_url = GURL("https://new.com/start");
+
+  info_.UpdateFromWebPageMetadata(*metadata);
+
+  // URL should not change if cross-origin
+  ASSERT_EQ(kInitialUrl, info_.url);
+}
+
 TEST_F(ShortcutInfoTest, WebPageMetadataTitleAppName) {
   info_ = ShortcutInfo(GURL());
   webapps::mojom::WebPageMetadataPtr metadata =
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc b/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
index db59e28a..72c67cb 100644
--- a/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
+++ b/chrome/browser/web_applications/web_contents/web_app_data_retriever_unittest.cc
@@ -187,9 +187,9 @@
 TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_AppUrlPresent) {
   SetFakeWebPageMetadataAgent();
 
-  web_contents_tester()->NavigateAndCommit(GURL("https://foo.example"));
-
   GURL other_app_url = GURL("https://bar.example");
+  web_contents_tester()->NavigateAndCommit(other_app_url);
+
   std::u16string other_app_title = u"Other App Title";
   SetRendererWebPageMetadata(other_app_url, other_app_title,
                              /*description=*/u"");
@@ -206,6 +206,30 @@
   EXPECT_EQ(other_app_title, web_app_info()->title.value());
 }
 
+TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_AppUrlCrossOrigin) {
+  SetFakeWebPageMetadataAgent();
+
+  const GURL kFooUrl("https://foo.example");
+  web_contents_tester()->NavigateAndCommit(kFooUrl);
+
+  GURL other_app_url = GURL("https://bar.example");
+  std::u16string other_app_title = u"Other App Title";
+  SetRendererWebPageMetadata(other_app_url, other_app_title,
+                             /*description=*/u"");
+
+  base::RunLoop run_loop;
+  WebAppDataRetriever retriever;
+  retriever.GetWebAppInstallInfo(
+      web_contents(),
+      base::BindOnce(&WebAppDataRetrieverTest::GetWebAppInstallInfoCallback,
+                     base::Unretained(this), run_loop.QuitClosure()));
+  run_loop.Run();
+
+  // If the origin differs, we fallback to the url that was already in the
+  // web_app_info().
+  EXPECT_EQ(kFooUrl, web_app_info()->start_url());
+}
+
 TEST_F(WebAppDataRetrieverTest, GetWebAppInstallInfo_TitleAbsentFromRenderer) {
   SetFakeWebPageMetadataAgent();
diff --git a/components/webapps/browser/android/shortcut_info_unittest.cc b/components/webapps/browser/android/shortcut_info_unittest.cc
index fb4dc05..37f0379 100644
--- a/components/webapps/browser/android/shortcut_info_unittest.cc
+++ b/components/webapps/browser/android/shortcut_info_unittest.cc
@@ -147,12 +147,13 @@
 }
 
 TEST_F(ShortcutInfoTest, UpdateFromWebPageMetadata) {
-  info_ = ShortcutInfo(GURL());
+  const GURL kAppUrl("https://new.com/start");
+  info_ = ShortcutInfo(kAppUrl);
   webapps::mojom::WebPageMetadataPtr metadata =
       webapps::mojom::WebPageMetadata::New();
   metadata->application_name = u"new title";
   metadata->description = u"new description";
-  metadata->application_url = GURL("https://new.com/start");
+  metadata->application_url = kAppUrl;
   metadata->mobile_capable = mojom::WebPageMobileCapable::ENABLED;
 
   info_.UpdateFromWebPageMetadata(*metadata);
@@ -166,6 +167,19 @@
   ASSERT_EQ(blink::mojom::DisplayMode::kStandalone, info_.display);
 }
 
+TEST_F(ShortcutInfoTest, UpdateFromWebPageMetadataCrossOrigin) {
+  const GURL kInitialUrl("https://old.com/start");
+  info_ = ShortcutInfo(kInitialUrl);
+  webapps::mojom::WebPageMetadataPtr metadata =
+      webapps::mojom::WebPageMetadata::New();
+  metadata->application_url = GURL("https://new.com/start");
+
+  info_.UpdateFromWebPageMetadata(*metadata);
+
+  // URL should not change if cross-origin
+  ASSERT_EQ(kInitialUrl, info_.url);
+}
+
 TEST_F(ShortcutInfoTest, WebPageMetadataTitleAppName) {
   info_ = ShortcutInfo(GURL());
   webapps::mojom::WebPageMetadataPtr metadata =
Loading diff…

Original Bug Report

reported by rj...@google.com

Potential cross-origin PWA identity spoofing via metadata and icon fallback bypass

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 without the security team.

Overview: A logic flaw in PWA installation allows a malicious site to supply a cross-origin application-url via page metadata. By intentionally failing a manifest icon download, an attacker can bypass manifest validation and install or overwrite a PWA using the victim’s identity but attacker-controlled icons and names. This also bypasses AppLock integrity checks and affects Android shortcuts.

Affected files:

  • chrome/browser/web_applications/web_contents/web_app_data_retriever.cc
  • chrome/browser/web_applications/commands/fetch_manifest_and_install_command.cc
  • components/webapps/browser/android/shortcut_info.cc

Estimated timestamp from git blame: 2025-07-10

Description

A potential vulnerability exists in the Progressive Web App (PWA) installation flow that allows a malicious renderer to spoof the identity of an installed app or overwrite an existing victim’s app with attacker-controlled icons and titles. This requires user interaction (accepting the install prompt) but results in persistent UI spoofing.

The vulnerability is a chain of three distinct issues:

1. Unvalidated Origin in Page Metadata During the initial phase of PWA installation, WebAppDataRetriever::GetWebAppInstallInfo requests page metadata from the renderer. In WebAppDataRetriever::PopulateWebAppInfoFromMetadata(), the metadata.application_url is blindly trusted and used to set the fallback start_url and manifest_id for the app. There is no check to ensure this URL is same-origin with the document’s last committed URL.

2. Logic Error in Manifest Fallback FetchManifestAndInstallCommand receives this tainted fallback metadata and stores it in web_app_info_. It then fetches the attacker’s actual manifest (which passes validation) and acquires an AppLock for the attacker’s true App ID. However, if the manifest’s icons fail to download (e.g., return a 404), the ManifestToWebAppInstallInfoJob flags install_info->is_generated_icon = true. In the callback OnInstallInfoObtainedMergeAndShowDialog (around line 580), this triggers an early return to fall back to page favicons. Critically, this early return skips the assignment web_app_info_ = std::move(install_info); (line 598). As a result, the command retains the tainted fallback metadata (the victim’s ID) instead of the validated manifest data.

3. AppLock Integrity Bypass When the user accepts the prompt, FinalizeInstallJob writes the app to the WebAppRegistry using the tainted web_app_info_->manifest_id() (the victim’s ID). Because the system does not enforce that the modified App ID matches the currently held AppLock (which is for the attacker’s ID), the write succeeds. This allows an attacker to silently overwrite an existing victim’s app name and OS launcher icon.

Android Impact A similar lack of validation exists in Android’s components/webapps/browser/android/shortcut_info.cc. ShortcutInfo::UpdateFromMetadata blindly trusts metadata.application_url to set the shortcut’s url and scope, enabling Android homescreen shortcut spoofing.

Potential Reproduction Steps

(Note: These are suggested steps based on static code analysis; we do not have a working exploit to automatically verify this.)

  1. Host an attacker page at https://attacker.example. Include the following meta tag to spoof the origin: <meta name="application-url" content="https://victim.example/">.
  2. Set the <title> and <link rel="icon"> (favicons) on the page to attacker-controlled values (e.g., “Victim Bank” and a fake bank logo).
  3. Host a manifest at https://attacker.example/manifest.json that passes kValidManifestIgnoreDisplay but includes an icons array pointing to a URL that returns a 404 Not Found error.
  4. Have the user navigate to https://attacker.example and trigger the PWA install (e.g., via the omnibox icon or Chrome menu).
  5. The user will see an install dialog with the attacker’s title and favicon. If they accept, the app is installed with the manifest_id of https://victim.example/, potentially overwriting the legitimate app if it was already installed.

Suggested Fix

  1. Validate Metadata Origin: In WebAppDataRetriever::PopulateWebAppInfoFromMetadata, verify that metadata.application_url is same-origin with the WebContents’ last committed URL before trusting it.
  2. Fix Command Logic: In FetchManifestAndInstallCommand::OnInstallInfoObtainedMergeAndShowDialog, ensure that web_app_info_ is updated with the validated install_info data before returning early to fetch favicons, or restructure the fallback logic so that validated manifest fields are always prioritized and retained.
  3. Enforce AppLock Integrity: ScopedRegistryUpdate or FinalizeInstallJob should verify that the App ID being written to the registry corresponds to the AppLock currently held by the command.
  4. Patch Android ShortcutInfo: Add origin validation to ShortcutInfo::UpdateFromMetadata in components/webapps/browser/android/shortcut_info.cc.

Evaluated with Chrome root at commit: e9e0fcbb690b1a8c1a26c81c2a9ea23d6e178368


Results 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