CVE-2026-87521
Overview
Background
- WebMCP
- the Chrome feature (component
WebMCP) that lets a page register script-backed “tools” a model can invoke, exposed to the browser process via theblink::mojom::ModelContextHostMojo interface. - `ScriptTool`
- the
blink::mojom::ScriptToolstruct describing a registered tool, carrying fields such asname,description,origin,tool_owner_frame_token, andexposed_origins. - `exposed_origins`
- the list of
url::Originvalues on aScriptToolnaming which origins are authorized to see/use the tool, i.e. a server-side access-control list (ACL) that should not be handed back to a calling renderer. - Renderer/browser trust boundary
- data returned from the privileged browser process to a lower-privileged renderer over Mojo (here, the
GetScriptTools()reply) crosses a trust boundary and must be redacted of anything the renderer is not entitled to learn.
Root Cause Analysis
When the browser process assembled the list of tools to return to a caller, it cloned each stored ScriptTool and set the clone’s tool_owner_frame_token from GetFrameTokenForSiteInstanceGroup(site_instance_group), then pushed the clone onto all_tools and returned it to the calling renderer. The cloned tool retained its exposed_origins field, so the full authorized-origin ACL of every tool was serialized back across the browser-to-renderer boundary. This violated the invariant that a renderer should only receive data pertinent to itself and not the complete access-control list of other origins.
The fix adds cloned_tool->exposed_origins.clear(); immediately after the frame-token assignment and before all_tools.push_back(std::move(cloned_tool)), so the clone returned to the renderer carries an empty exposed_origins, while the authoritative copy the browser retains is unaffected. The accompanying browser test GetScriptToolsExposedOriginsRedacted confirms that after registering a tool with two exposed_origins entries, GetScriptTools() returns a tool whose exposed_origins is empty.
ScriptTool to the renderer without stripping its server-side ACL field, leaking exposed_origins across the trust boundary; the fix simply clears exposed_origins on the clone before it is pushed and returned.Attack Path
- Register a tool
A page in a renderer binds
ModelContextHostand callsRegisterScriptToolwith aScriptToolwhoseexposed_originslists other authorized origins, which the browser stores. - Request the tool list
The same (or another) renderer calls
GetScriptTools()over the same Mojo interface, prompting the browser process to clone each stored tool for the reply. - Receive the un-redacted ACL
Before the patch, each cloned tool was pushed onto
all_toolswithexposed_originsintact, so the reply delivered to the calling renderer contained the full list of authorized origins for tools it should not have full visibility into. - Harvest authorized origins
The renderer reads the returned
exposed_originsto learn which origins are authorized for each tool, i.e. information disclosure of the ACL contents (inferred use of the leaked data; the patch demonstrates only the leak, not any further escalation).
Impact Assessment
exposed_origins access-control lists of registered WebMCP tools — the set of authorized origins — which they are not entitled to enumerate; the advisory classifies this as an information leak (InfoDisclosure) in WebMCP. The precondition is that WebMCP is enabled and reachable (the test guards on PermissionsPolicyFeature::kTools) and that the renderer can invoke GetScriptTools(). The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 3.1. The advisory does not report exploited-in-the-wild status or a specific severity, and the patch demonstrates only the leak, not any further escalation.Changed Functions
| Function | Change | Notes |
|---|---|---|
CorsExploitBrowserTestcontent/browser/security_exploit_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/model_context_user_data.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Cloned-object leakage across IPCWherever the browser process clones an internal struct (e.g. a
ScriptTool) and returns it to a renderer, audit that every server-only or ACL field is cleared before serialization, since fields left populated leak by default. - ACL and origin-list fieldsSearch WebMCP and related Mojo replies for fields that hold authorization data (
exposed_origins, allowlists, tokens) to confirm they are stripped from renderer-facing responses rather than passed through. - Redact-on-return invariantReview
GetScriptTools()and sibling accessors onModelContextHost(and similar mojom interfaces) to verify each output path applies redaction between the internal representation and the value handed back over the boundary.
Patch
From b4983713ccc6cc723c7bf3d5c5228dd52f7ce9b1 Mon Sep 17 00:00:00 2001
From: François Beaufort <beaufort.francois@gmail.com>
Date: Thu, 30 Jul 2026 23:51:18 -0700
Subject: [PATCH] [WebMCP] clear exposed_origins to prevent leakage to renderers
Bug: 512986143, 512533939
Change-Id: I67bc4e762503bd73fde091f71c418455a0291f00
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8137431
Reviewed-by: Dominic Farolino <dom@chromium.org>
Commit-Queue: Fr <beaufort.francois@gmail.com>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671656}
---
diff --git a/content/browser/renderer_host/model_context_user_data.cc b/content/browser/renderer_host/model_context_user_data.cc
index dcbce4ed..965a68f 100644
--- a/content/browser/renderer_host/model_context_user_data.cc
+++ b/content/browser/renderer_host/model_context_user_data.cc
@@ -240,6 +240,11 @@
->GetFrameTokenForSiteInstanceGroup(site_instance_group);
cloned_tool->tool_owner_frame_token = token;
+
+ // Clear `exposed_origins` to prevent leaking the full list of
+ // authorized origins to the calling renderer process.
+ cloned_tool->exposed_origins.clear();
+
all_tools.push_back(std::move(cloned_tool));
}
return RenderFrameHost::FrameIterationAction::kContinue;
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c1d86a44..9909662 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -906,6 +906,52 @@
EXPECT_EQ(bad_message::RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN, kill_waiter.Wait());
}
+// Test that `GetScriptTools()` clears `exposed_origins` so that the full ACL
+// list is not leaked to caller renderers.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestWebMCPEnabled,
+ GetScriptToolsExposedOriginsRedacted) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+
+ EXPECT_TRUE(
+ rfh->IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kTools));
+
+ auto tool = blink::mojom::ScriptTool::New();
+ tool->name = "test-tool";
+ tool->description = "test description";
+ tool->tool_owner_frame_token = rfh->GetFrameToken();
+ tool->origin = rfh->GetLastCommittedOrigin();
+ tool->exposed_origins.push_back(
+ url::Origin::Create(GURL("https://friend.example")));
+ tool->exposed_origins.push_back(
+ url::Origin::Create(GURL("https://attacker.example")));
+
+ mojo::Remote<blink::mojom::ModelContextHost> script_tool_host;
+ rfh->BindModelContextHost(script_tool_host.BindNewPipeAndPassReceiver());
+
+ base::RunLoop register_loop;
+ script_tool_host->RegisterScriptTool(std::move(tool),
+ register_loop.QuitClosure());
+ register_loop.Run();
+
+ base::RunLoop get_tools_loop;
+ std::vector<blink::mojom::ScriptToolPtr> result_tools;
+ script_tool_host->GetScriptTools(
+ {}, base::BindLambdaForTesting(
+ [&](std::vector<blink::mojom::ScriptToolPtr> tools) {
+ result_tools = std::move(tools);
+ get_tools_loop.Quit();
+ }));
+ get_tools_loop.Run();
+
+ ASSERT_EQ(1u, result_tools.size());
+ EXPECT_EQ("test-tool", result_tools[0]->name);
+ EXPECT_TRUE(result_tools[0]->exposed_origins.empty());
+}
+
class CorsExploitBrowserTest : public ContentBrowserTest {
public:
CorsExploitBrowserTest() = default;
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index c1d86a44..9909662 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -906,6 +906,52 @@
EXPECT_EQ(bad_message::RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN, kill_waiter.Wait());
}
+// Test that `GetScriptTools()` clears `exposed_origins` so that the full ACL
+// list is not leaked to caller renderers.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTestWebMCPEnabled,
+ GetScriptToolsExposedOriginsRedacted) {
+ GURL start_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(shell(), start_url));
+
+ RenderFrameHostImpl* rfh = static_cast<RenderFrameHostImpl*>(
+ shell()->web_contents()->GetPrimaryMainFrame());
+
+ EXPECT_TRUE(
+ rfh->IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kTools));
+
+ auto tool = blink::mojom::ScriptTool::New();
+ tool->name = "test-tool";
+ tool->description = "test description";
+ tool->tool_owner_frame_token = rfh->GetFrameToken();
+ tool->origin = rfh->GetLastCommittedOrigin();
+ tool->exposed_origins.push_back(
+ url::Origin::Create(GURL("https://friend.example")));
+ tool->exposed_origins.push_back(
+ url::Origin::Create(GURL("https://attacker.example")));
+
+ mojo::Remote<blink::mojom::ModelContextHost> script_tool_host;
+ rfh->BindModelContextHost(script_tool_host.BindNewPipeAndPassReceiver());
+
+ base::RunLoop register_loop;
+ script_tool_host->RegisterScriptTool(std::move(tool),
+ register_loop.QuitClosure());
+ register_loop.Run();
+
+ base::RunLoop get_tools_loop;
+ std::vector<blink::mojom::ScriptToolPtr> result_tools;
+ script_tool_host->GetScriptTools(
+ {}, base::BindLambdaForTesting(
+ [&](std::vector<blink::mojom::ScriptToolPtr> tools) {
+ result_tools = std::move(tools);
+ get_tools_loop.Quit();
+ }));
+ get_tools_loop.Run();
+
+ ASSERT_EQ(1u, result_tools.size());
+ EXPECT_EQ("test-tool", result_tools[0]->name);
+ EXPECT_TRUE(result_tools[0]->exposed_origins.empty());
+}
+
class CorsExploitBrowserTest : public ContentBrowserTest {
public:
CorsExploitBrowserTest() = default;
Original Bug Report
Cross-origin metadata leak in WebMCP GetScriptTools implementation
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 Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The browser-side implementation of the WebMCP (Web Model Control Protocol) feature potentially leaks the full Access Control List (ACL) of a script tool to any origin authorized to discover it. This allows a compromised renderer to identify all other origins that a victim has authorized to use a specific script tool.
Affected files:
content/browser/renderer_host/model_context_user_data.ccthird_party/blink/public/mojom/content_extraction/script_tools.mojomthird_party/blink/renderer/core/script_tools/model_context.cc
Estimated timestamp from git blame: 2026-05-01
Summary
A potential logic error in the browser process handling of WebMCP script tool discovery allows an authorized renderer to learn the identity of all other origins authorized to access a given tool. This constitutes a cross-origin information leak of metadata, revealing a victim origin’s trust relationships.
Root Cause
In content/browser/renderer_host/model_context_user_data.cc, the GetScriptTools method handles Mojo requests from renderers to discover available script tools. When a tool is visible to the caller (based on the caller’s origin being present in the tool’s exposed_origins list), the browser process clones the stored blink::mojom::ScriptTool struct and returns it to the caller.
// content/browser/renderer_host/model_context_user_data.cc:212
if (IsScriptToolVisibleToOrigin(rfh->GetLastCommittedOrigin(),
t->exposed_origins, caller_origin)) {
blink::mojom::ScriptToolPtr cloned_tool = t.Clone();
// ... redaction of other fields occurs here, but exposed_origins is missed
cloned_tool->tool_owner_frame_token = token;
all_tools.push_back(std::move(cloned_tool));
}
The blink::mojom::ScriptTool struct (third_party/blink/public/mojom/content_extraction/script_tools.mojom) contains the exposed_origins field, which is an array of all origins authorized to access the tool. The browser process fails to redact this array from the cloned struct before sending it back to the renderer.
Potential Impact
An attacker who has compromised a renderer process (e.g., via a renderer RCE) can discover the identity of other origins that a victim site has authorized for tool access. This reveals sensitive cross-origin relationship metadata across the Site-Isolation boundary.
Suggested Reproduction Steps
- A victim page (e.g.,
https://victim.example) registers a script tool and authorizes access forhttps://attacker.exampleandhttps://friend.exampleusing theexposedTooption in the WebMCP API. - The attacker-controlled origin (
https://attacker.example) is loaded in a frame where thetoolspermissions-policy is enabled. - The attacker compromises the renderer process for
https://attacker.example. - From the compromised renderer, the attacker invokes the Mojo method
ModelContextHost::GetScriptTools()via theBrowserInterfaceBroker. - The attacker inspects the
exposed_originsfield in the received Mojo response, discovering thathttps://friend.exampleis also authorized for the victim’s tool.
Suggested Fix
Modify ModelContextUserData::GetScriptTools in content/browser/renderer_host/model_context_user_data.cc to clear the exposed_origins list in the ScriptTool struct before it is sent to the renderer process. The renderer only needs to know that it has access, not which other origins also have access.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
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.