CVE-2026-11008
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fcontent/browser/manifest/manifest_browsertest.cc |
modified |
Files Changed
content/browser/manifest/manifest_browsertest.cccontent/browser/manifest/manifest_manager_host.cc
Patch
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)) {
Regression Test / PoC
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.
Original Bug Report
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.cccontent/browser/manifest/manifest_manager_host.ccchrome/browser/ui/web_applications/share_target_utils.cccomponents/webapps/browser/installable/installable_evaluator.ccchrome/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:
- Missing Document Origin Check: In
content/browser/manifest/manifest_manager_host.cc, theMaybeGetBadMessageStringForManifestfunction only validates that the manifest’sstart_url,id, andscopeare validGURLs. It does not verify that they are same-origin with theRenderFrameHostthat provided them. Furthermore,InstallableEvaluatorblindly trusts thehas_valid_specified_start_urlboolean 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). - Missing Share Target Scope Check: In
chrome/browser/web_applications/jobs/manifest_to_web_app_install_info_job.cc, theToWebAppShareTargetfunction blindly copies theshare_target.actionURL 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.)
- An attacker compromises a renderer process navigating to
https://attacker.com/. - The compromised renderer intercepts the
ManifestManager::RequestManifestMojo request. - The renderer responds with a crafted
blink::mojom::Manifestwhere:start_url,id, andscopeare set tohttps://victim.com/.has_valid_specified_start_urlis set totrue.nameis set to “Victim Bank”.share_target.actionis set tohttps://attacker.com/exfil.
- The browser processes the manifest, bypassing validation, and displays an install prompt for “Victim Bank” from
victim.com. - The user installs the app.
- The user later invokes the system share sheet and selects the spoofed “Victim Bank” app to share sensitive data.
- The browser navigates to
https://attacker.com/exfilwith a POST body containing the exfiltrated data.
Suggested Fix
- Enforce Document Origin Matching: In the browser process (e.g.,
ManifestManagerHostorFetchManifestAndInstallCommand), explicitly verify that the manifest’sstart_urlandidare same-origin with theRenderFrameHost’s last committed origin. - Enforce Share Target Scope: During
WebAppconstruction or insideManifestToWebAppInstallInfoJob::ToWebAppShareTarget, enforce thatshare_target.actionis within the manifest’sscope.
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.