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
Tracker495864099
Fix commit0b080cee89a1 (chromium/src) +197/-10
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
IN_PROC_BROWSER_TEST_F
content/browser/manifest/manifest_browsertest.cc
modified

Files Changed

  • content/browser/manifest/manifest_browsertest.cc
  • content/browser/manifest/manifest_manager_host.cc
From 0b080cee89a1aa56a3ca324a5684800703b38529 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Fri, 24 Apr 2026 17:16:14 -0700
Subject: [PATCH] Valid Manifest start_url, id, scope, share_target->action are same origin in the browser process

Fixed: 495864099, 499175143
Change-Id: I2fe28c6995ea84c314d48a3bb8e572b485703576
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7789542
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1620572}
---

diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index 789d109..9986bcfd 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1060,6 +1060,164 @@
       *manifest_future.Get<blink::mojom::ManifestPtr>()));
 }
 
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_StartUrlCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = GURL("https://evil.com/");
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest start_url must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_IdCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = GURL("https://evil.com/");
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest id must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_ScopeCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = GURL("https://evil.com");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest scope must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_ShareTargetActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::Manifest::ShareTarget share_target;
+  share_target.action = GURL("https://evil.com");
+  bad_manifest->share_target = std::move(share_target);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest share_target must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_FileHandlersActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = GURL("https://evil.com");
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest file_handlers must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_ProtocolHandlersActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestProtocolHandlerPtr protocol_handler =
+      blink::mojom::ManifestProtocolHandler::New();
+  protocol_handler->url = GURL("https://evil.com");
+  bad_manifest->protocol_handlers.push_back(std::move(protocol_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest protocol_handlers must be same-origin with the document."));
+}
+
 // Tests that if a compromised renderer bypasses the manifest parser and sends
 // cross-site migration data directly, the browser correctly rejects it and
 // kills the renderer with a bad message.
diff --git a/content/browser/manifest/manifest_manager_host.cc b/content/browser/manifest/manifest_manager_host.cc
index 50504e7..b79b99e 100644
--- a/content/browser/manifest/manifest_manager_host.cc
+++ b/content/browser/manifest/manifest_manager_host.cc
@@ -40,7 +40,7 @@
 std::optional<std::string> MaybeGetBadMessageStringForManifest(
     blink::mojom::ManifestRequestResult result,
     const blink::mojom::Manifest& manifest,
-    const GURL& document_url) {
+    const url::Origin& document_origin) {
   if (result == blink::mojom::ManifestRequestResult::kSuccess &&
       blink::IsEmptyManifest(manifest)) {
     return "RequestManifest reported success but didn't return a manifest";
@@ -65,27 +65,55 @@
            valid_to_string(scope_valid), ")."});
     }
 
+    if (!document_origin.IsSameOriginWith(manifest.start_url)) {
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index 789d109..9986bcfd 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1060,6 +1060,164 @@
       *manifest_future.Get<blink::mojom::ManifestPtr>()));
 }
 
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_StartUrlCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = GURL("https://evil.com/");
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest start_url must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_IdCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = GURL("https://evil.com/");
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest id must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest, BadMessage_ScopeCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site start_url triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = GURL("https://evil.com");
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(bad_message_observer.WaitForBadMessage(),
+              ::testing::StartsWith(
+                  "Manifest scope must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_ShareTargetActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::Manifest::ShareTarget share_target;
+  share_target.action = GURL("https://evil.com");
+  bad_manifest->share_target = std::move(share_target);
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest share_target must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_FileHandlersActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestFileHandlerPtr file_handler =
+      blink::mojom::ManifestFileHandler::New();
+  file_handler->action = GURL("https://evil.com");
+  bad_manifest->file_handlers.push_back(std::move(file_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest file_handlers must be same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+                       BadMessage_ProtocolHandlersActionCrossOrigin) {
+  const GURL test_url =
+      embedded_test_server()->GetURL("/manifest/empty-manifest.html");
+  ASSERT_TRUE(NavigateToURL(shell(), test_url));
+
+  ManifestManagerHost* host = ManifestManagerHost::GetOrCreateForPage(
+      shell()->web_contents()->GetPrimaryPage());
+
+  // Test that a cross-site share_target action triggers a bad message.
+  mojo::FakeMessageDispatchContext fake_dispatch_context;
+  auto bad_manifest = blink::mojom::Manifest::New();
+  bad_manifest->start_url = test_url;
+  bad_manifest->id = test_url;
+  bad_manifest->scope = embedded_test_server()->GetURL("/manifest/");
+
+  blink::mojom::ManifestProtocolHandlerPtr protocol_handler =
+      blink::mojom::ManifestProtocolHandler::New();
+  protocol_handler->url = GURL("https://evil.com");
+  bad_manifest->protocol_handlers.push_back(std::move(protocol_handler));
+
+  mojo::test::BadMessageObserver bad_message_observer;
+  host->ValidateAndMaybeOverrideManifestForTesting(
+      blink::mojom::ManifestRequestResult::kSuccess, std::move(bad_manifest));
+  EXPECT_THAT(
+      bad_message_observer.WaitForBadMessage(),
+      ::testing::StartsWith(
+          "Manifest protocol_handlers must be same-origin with the document."));
+}
+
 // Tests that if a compromised renderer bypasses the manifest parser and sends
 // cross-site migration data directly, the browser correctly rejects it and
 // kills the renderer with a bad message.
Loading diff…

Original Bug Report

reported by vm...@google.com

PWA UI spoofing and data exfiltration via compromised renderer

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

Overview: A compromised renderer can send a crafted Web App Manifest to the browser during the PWA installation flow. By bypassing renderer-side origin and scope checks, the attacker can spoof a legitimate origin in the install prompt and configure a cross-origin share_target to exfiltrate user shared data.

Affected files:

  • chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc
  • content/browser/manifest/manifest_manager_host.cc
  • chrome/browser/ui/web_applications/share_target_utils.cc
  • components/webapps/browser/installable/installable_evaluator.cc
  • chrome/browser/ui/views/web_apps/web_app_icon_name_and_origin_view.cc

Estimated timestamp from git blame: 2025-10-20

Summary

A lack of browser-side validation in the Web App manifest parsing and installation flow allows a compromised renderer to install a malicious Progressive Web App (PWA) that spoofs a trusted origin (e.g., victim.com). The attacker can also configure a cross-origin share_target to exfiltrate data shared by the user to the spoofed app.

Technical Details

When the browser checks if a page is installable, it requests the Web App Manifest from the renderer via the blink.mojom.ManifestManager Mojo interface. The renderer parses the JSON manifest and enforces security restrictions, such as ensuring the start_url is same-origin with the document and that the share_target.action is within the app’s scope.

However, if the renderer is compromised, it can bypass these checks and send a forged blink::mojom::Manifest struct to the browser. The browser process fails to adequately re-verify these security invariants:

  1. Missing Document Origin Check: In content/browser/manifest/manifest_manager_host.cc, the MaybeGetBadMessageStringForManifest function only validates that the manifest’s start_url, id, and scope are valid GURLs. It does not verify that they are same-origin with the RenderFrameHost that provided them. Furthermore, InstallableEvaluator blindly trusts the has_valid_specified_start_url boolean flag set by the renderer. This allows an attacker to spoof the app’s origin (e.g., https://victim.com/), which is then displayed in the install prompt (WebAppIconNameAndOriginView).
  2. Missing Share Target Scope Check: In chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc, the ToWebAppShareTarget function blindly copies the share_target.action URL from the renderer-supplied manifest without performing any scope or origin checks. This allows the attacker to set an arbitrary cross-origin URL (e.g., https://attacker.com/exfil).

When a user installs the spoofed app and later shares data (such as sensitive files or text) with it via the system share sheet, NavigateParamsForShareTarget uses the attacker-controlled share_target.action URL. The browser then performs a POST navigation to this cross-origin URL, exfiltrating the shared content to the attacker.

Potential Attack Steps

(Note: These are suggested steps for a potential exploit, as this agent cannot execute code.)

  1. An attacker compromises a renderer process navigating to https://attacker.com/.
  2. The compromised renderer intercepts the ManifestManager::RequestManifest Mojo request.
  3. The renderer responds with a crafted blink::mojom::Manifest where:
    • start_url, id, and scope are set to https://victim.com/.
    • has_valid_specified_start_url is set to true.
    • name is set to “Victim Bank”.
    • share_target.action is set to https://attacker.com/exfil.
  4. The browser processes the manifest, bypassing validation, and displays an install prompt for “Victim Bank” from victim.com.
  5. The user installs the app.
  6. The user later invokes the system share sheet and selects the spoofed “Victim Bank” app to share sensitive data.
  7. The browser navigates to https://attacker.com/exfil with a POST body containing the exfiltrated data.

Suggested Fix

  1. Enforce Document Origin Matching: In the browser process (e.g., ManifestManagerHost or FetchManifestAndInstallCommand), explicitly verify that the manifest’s start_url and id are same-origin with the RenderFrameHost’s last committed origin.
  2. Enforce Share Target Scope: During WebApp construction or inside ManifestToWebAppInstallInfoJob::ToWebAppShareTarget, enforce that share_target.action is within the manifest’s scope.

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker