Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebApp
DescriptionInsufficient policy enforcement in WebApp
ComponentWebApp
Bug ClassLogic Error
Tracker498353173
Fix commitc61b9844f197 (chromium/src) +57/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

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