CVE-2026-91732
Overview
Background
- Web App Manifest
- a JSON document a page declares that describes install/launch metadata (e.g.
start_url,scope), which Blink parses in the renderer and forwards to the browser process as ablink::mojom::Manifest. - `ManifestManagerHost`
- the browser-side
contentobject that receives the parsed manifest over Mojo from the renderer and is responsible for revalidating security-sensitive invariants the renderer cannot be trusted to enforce. - Same-origin validation
- the rule that manifest-supplied URLs used to launch or perform actions must share the document’s origin, checked here with
url::Origin::IsSameOriginWith. - Bad message / `ReportBadMessage`
- the browser’s mechanism for rejecting a malformed or malicious IPC and terminating the offending renderer; the patched code signals it by returning a non-empty reason string.
Root Cause Analysis
The browser-side manifest validation path (reached via ManifestManagerHost::ValidateAndMaybeOverrideManifest, exercised by the test through ValidateAndMaybeOverrideManifestForTesting) already re-checked several manifest URL fields for same-origin/same-site correctness — for example protocol_handlers and the migrate_from entries visible in the hunk context — but it never validated manifest.note_taking->new_note_url or manifest.lock_screen->start_url. Because these fields were only constrained by the renderer-side parser, a compromised renderer could bypass the parser and send a blink::mojom::Manifest whose note_taking->new_note_url or lock_screen->start_url pointed at an arbitrary cross-origin URL (e.g. https://evil.com). The invariant violated is that action/launch URLs derived from a document’s manifest must be same-origin with that document.
The patch TIGHTENS validation: it adds two guards that, when the field is_valid() and !document_origin.IsSameOriginWith(...), return a reason string (“Manifest note_taking new_note_url must be same-origin with the document.” / “Manifest lock_screen start_url must be same-origin with the document.”), which the caller treats as a bad message and kills the renderer before the untrusted URL is ever stored or used.
The fix works because it moves the trust boundary into the browser process, so a lying renderer can no longer smuggle a cross-origin URL past the now-authoritative check.
note_taking->new_note_url and lock_screen->start_url while the browser omitted these two fields from its own revalidation; the fix closes the authorization gap by adding the same browser-process same-origin checks that the other launch-related manifest fields already had.Attack Path
- Compromise a renderer The attacker gains code execution in a renderer process (the standard precondition for a browser-process authorization bypass) so it can forge Mojo messages instead of going through the trusted manifest parser.
- Forge a cross-origin manifest field
The renderer sends a
blink::mojom::ManifesttoManifestManagerHostwith a validnote_taking->new_note_urlorlock_screen->start_urlset to an off-origin URL such ashttps://evil.com, which the pre-patch browser accepted without a same-origin check. - Bypass missing authorization Because the vulnerable path validated other fields but not these two, the cross-origin URL passed browser-side validation and would be stored/used as the document’s own note-taking or lock-screen action URL.
- Malicious launch/action When that manifest URL is later used to launch or perform an action, the attacker-controlled cross-origin URL is invoked in a context that assumed same-origin, enabling cross-origin action under the victim app’s authority (inferred — the patch demonstrates only the missing check, not a specific downstream sink).
Impact Assessment
note_taking or lock_screen manifest fields, defeating the same-origin invariant these launch/action URLs are supposed to hold. The precondition is a compromised or malicious renderer, and the affected trust boundary is the browser process (content/browser/manifest). Any further escalation to a concrete cross-origin compromise or spoofed app action beyond acceptance of the bad URL is (inferred); the advisory classifies this as a LogicError / missing authorization in AppManifest and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/manifest/manifest_manager_host.cc |
modified |
Files Changed
content/browser/manifest/manifest_browsertest.cccontent/browser/manifest/manifest_manager_host.cc
Audit Directions
- Renderer-supplied URL fields lacking browser-side same-origin checksEnumerate every URL/origin field in
blink::mojom::Manifest(and similar renderer-to-browser structs) and confirm each launch/action URL is revalidated inManifestManagerHost, not just in the renderer parser. - Asymmetry between parser validation and browser validationLook for fields the Blink manifest parser constrains for same-origin/same-site that the browser process does not re-check, since the renderer half of that pair is untrustworthy once compromised.
- New manifest fields added without a matching browser guardWhen a manifest member (e.g.
note_taking,lock_screen,protocol_handlers,migrate_from) is introduced, verify the correspondingIsSameOriginWith/IsSameSiteWithguard is added to the browser-side validation list so no field is silently exempt.
Patch
From 9e594d53dececc1605a9f9778ea2152b5e163894 Mon Sep 17 00:00:00 2001
From: Nate Chapin <japhet@chromium.org>
Date: Wed, 03 Jun 2026 12:06:27 -0700
Subject: [PATCH] Validate Manifest note_taking and lock_screen same-origin in browser
This CL adds same-origin validation in the browser process (specifically
in ManifestManagerHost) for the following manifest fields:
- `note_taking->new_note_url`
- `lock_screen->start_url`
This prevents a compromised renderer from bypassing the parser and
sending cross-origin URLs for these fields, which could lead to security
issues when these URLs are used to launch or perform actions.
TAG=agy
CONV=6ebd9b15-cbef-4370-915a-a209f317b895
Bug: 511062248
Change-Id: If2f53ca6e7b2c819c76103a6055edc49eaffdb70
Fixed: 511062248
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7897641
Auto-Submit: Nate Chapin <japhet@chromium.org>
Commit-Queue: Nate Chapin <japhet@chromium.org>
Reviewed-by: Daniel Murphy <dmurph@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1641100}
---
diff --git a/content/browser/manifest/manifest_manager_host.cc b/content/browser/manifest/manifest_manager_host.cc
index eb8b136..df8b1c4 100644
--- a/content/browser/manifest/manifest_manager_host.cc
+++ b/content/browser/manifest/manifest_manager_host.cc
@@ -114,6 +114,18 @@
}
}
+ if (manifest.note_taking && manifest.note_taking->new_note_url.is_valid() &&
+ !document_origin.IsSameOriginWith(manifest.note_taking->new_note_url)) {
+ return "Manifest note_taking new_note_url must be same-origin with the "
+ "document.";
+ }
+
+ if (manifest.lock_screen && manifest.lock_screen->start_url.is_valid() &&
+ !document_origin.IsSameOriginWith(manifest.lock_screen->start_url)) {
+ return "Manifest lock_screen start_url must be same-origin with the "
+ "document.";
+ }
+
net::SchemefulSite document_site(document_origin);
for (const auto& migrate_from : manifest.migrate_from) {
if (!document_site.IsSameSiteWith(migrate_from->id)) {
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index a992ddec3..a07a027 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1218,6 +1218,64 @@
"Manifest protocol_handlers must be same-origin with the document."));
}
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+ BadMessage_NoteTakingNewNoteUrlCrossOrigin) {
+ 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 note_taking new_note_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 = embedded_test_server()->GetURL("/manifest/");
+
+ blink::mojom::ManifestNoteTakingPtr note_taking =
+ blink::mojom::ManifestNoteTaking::New();
+ note_taking->new_note_url = GURL("https://evil.com");
+ bad_manifest->note_taking = std::move(note_taking);
+
+ 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 note_taking new_note_url must be "
+ "same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+ BadMessage_LockScreenStartUrlCrossOrigin) {
+ 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 lock_screen 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 = embedded_test_server()->GetURL("/manifest/");
+
+ blink::mojom::ManifestLockScreenPtr lock_screen =
+ blink::mojom::ManifestLockScreen::New();
+ lock_screen->start_url = GURL("https://evil.com");
+ bad_manifest->lock_screen = std::move(lock_screen);
+
+ 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 lock_screen start_url 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.
Regression Test / PoC
diff --git a/content/browser/manifest/manifest_browsertest.cc b/content/browser/manifest/manifest_browsertest.cc
index a992ddec3..a07a027 100644
--- a/content/browser/manifest/manifest_browsertest.cc
+++ b/content/browser/manifest/manifest_browsertest.cc
@@ -1218,6 +1218,64 @@
"Manifest protocol_handlers must be same-origin with the document."));
}
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+ BadMessage_NoteTakingNewNoteUrlCrossOrigin) {
+ 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 note_taking new_note_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 = embedded_test_server()->GetURL("/manifest/");
+
+ blink::mojom::ManifestNoteTakingPtr note_taking =
+ blink::mojom::ManifestNoteTaking::New();
+ note_taking->new_note_url = GURL("https://evil.com");
+ bad_manifest->note_taking = std::move(note_taking);
+
+ 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 note_taking new_note_url must be "
+ "same-origin with the document."));
+}
+
+IN_PROC_BROWSER_TEST_F(ManifestBrowserTest,
+ BadMessage_LockScreenStartUrlCrossOrigin) {
+ 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 lock_screen 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 = embedded_test_server()->GetURL("/manifest/");
+
+ blink::mojom::ManifestLockScreenPtr lock_screen =
+ blink::mojom::ManifestLockScreen::New();
+ lock_screen->start_url = GURL("https://evil.com");
+ bad_manifest->lock_screen = std::move(lock_screen);
+
+ 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 lock_screen start_url 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
Manifest note_taking->new_note_url not validated same-origin in browser process (missed sibling of 0b080cee89)
Report description
Manifest note_taking->new_note_url not validated same-origin in browser process (missed sibling of 0b080cee89)
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
The problem
Please describe the technical details of the vulnerability
Vulnerability Description
ManifestManagerHost::MaybeGetBadMessageStringForManifest (manifest_manager_host.cc:42) validates same-origin for start_url, id, scope, share_target->action, file_handlers->action, protocol_handlers->url after the recent fix 0b080cee89 (2026-04-24, “Valid Manifest start_url, id, scope, share_target->action are same origin in the browser process”) but does not validate note_taking->new_note_url.
A compromised renderer at victim.com submits a manifest mojo with cross-origin note_taking->new_note_url:
bad_manifest->note_taking = blink::mojom::ManifestNoteTaking::New();
bad_manifest->note_taking->new_note_url = GURL("https://attacker.com/");
The browser accepts it. After PWA install, the URL is stored in WebApp::note_taking_new_note_url_. When the user invokes the Create Note intent on the installed PWA, WebAppLaunchProcess::GetLaunchUrl (chrome/browser/ui/web_applications/web_app_launch_process.cc:275) opens the attacker URL inside the PWA window:
} else if (is_note_taking_intent &&
web_app_->note_taking_new_note_url().is_valid()) {
launch_url = web_app_->note_taking_new_note_url();
}
The fix is the same one-liner pattern already applied to share_target/file_handlers/protocol_handlers in 0b080cee89: add a document_origin.IsSameOriginWith(...) check.
Attack Preconditions
- Compromised renderer at the document origin (the same renderer that delivers the manifest mojo).
- User installs the PWA from the document origin.
- User invokes the Create Note intent on the installed PWA.
Reproduction Steps
A regression browsertest mirroring BadMessage_FileHandlersActionCrossOrigin and BadMessage_ProtocolHandlersActionCrossOrigin is provided.
- Apply the test patch (
manifest_note_taking_origin_poc.patch) tocontent/browser/manifest/manifest_browsertest.cc. - Build:
autoninja -C out/asan content_browsertests - Run:
out/asan/content_browsertests \ --gtest_filter=ManifestBrowserTest.BadMessage_NoteTakingNewNoteUrlCrossOrigin
Captured outputs:
manifest_note_taking_origin_test_output_without_fix.txt- on unmodified Chromium HEAD: FAILED. “Expected: starts with "Manifest note_taking new_note_url must be same-origin with the document." / Actual: ""” - no bad message issued.manifest_note_taking_origin_test_output_with_fix.txt- withmanifest_note_taking_origin_fix.patchapplied: PASSED in 2 seconds.
Impact analysis
A page that successfully induces the user to install a PWA can plant any cross-origin URL into the PWA’s note-taking action. When the user invokes Create Note (e.g. via ChromeOS quick-action, Lens, or app launcher), the PWA window opens the attacker URL while the PWA chrome reflects the victim PWA’s identity. This is a cross-origin URL hijack of an installed PWA action.
Severity:
- Vulnerability type: Permissions Bypass (cross-origin URL launched in PWA context that should be same-origin), with Security UI Spoofing as a secondary framing.
- Process: browser.
- Mitigations: none. Same-origin enforcement was added for sibling URL fields in 0b080cee89 (file_handlers, protocol_handlers, share_target) but missed
note_taking->new_note_url.
Other URL fields in the manifest mojom that are also not currently validated and would benefit from the same fix in a follow-up sweep: lock_screen->start_url, update_manifest_url, tab_strip->new_tab_button->url, scope_extensions[].origin. Of these, tab_strip->new_tab_button->url is consumed by WebAppRegistrar::GetAppNewTabUrl for tabbed PWAs and exhibits the same launch-URL pattern.
The cause
What version of Chrome have you found the security issue in?
150.0.7831.0 (HEAD) Stable at the time of writing: 148.0.7778.97. Pre-existing on HEAD; the recent CL 0b080cee89 introduced the same-origin enforcement for several manifest URL fields but did not extend it to note_taking->new_note_url.
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Permissions Bypass