Chrome · WebMCP
CVE-2026-17854
Logic Error in WebMCP
Overview
Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
ToolFunctionFinishedCallbackthird_party/blink/renderer/core/script_tools/model_context.h |
modified | |
ToolUnregisterAbortAlgorithmthird_party/blink/renderer/core/script_tools/model_context.h |
modified | |
TEST_Fthird_party/blink/renderer/core/script_tools/model_context_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/script_tools/model_context.ccthird_party/blink/renderer/core/script_tools/model_context.hthird_party/blink/renderer/core/script_tools/model_context_test.cc
Patch
From 613c13a8014852139b2fcf6d32029fd8bb195142 Mon Sep 17 00:00:00 2001 From: mark a. foltz <[email protected]> Date: Thu, 04 Jun 2026 17:21:44 -0700 Subject: [PATCH] [webmcp]: Gate APIs on document.domain being disabled. If document.domain is enabled (i.e., the document is not origin-keyed, such as when using 'Origin-Agent-Cluster: ?0'), prevent the use of WebMCP APIs (registerTool, getTools, executeTool, and declarative tool registration). Synchronous calls will throw a SecurityError DOMException, and promise-returning calls will reject with a SecurityError DOMException. This gates WebMCP behind document.domain being disabled to prevent issues where the document's origin might dynamically change during a tool's lifetime. Includes unit tests and a layout test. Fixed: 519500882 Change-Id: Ia6b4b08ba38586963cef5b73b9ff1ad049a72a6e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7896184 Reviewed-by: Khushal Sagar <[email protected]> Reviewed-by: Dominic Farolino <[email protected]> Commit-Queue: Mark Foltz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1642043} --- diff --git a/third_party/blink/renderer/core/script_tools/model_context.cc b/third_party/blink/renderer/core/script_tools/model_context.cc index ae769d7c..ed0b976 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.cc +++ b/third_party/blink/renderer/core/script_tools/model_context.cc @@ -60,6 +60,8 @@ const char kPermissionPolicyNotEnabledError[] = "Access to the feature \"tools\" is disallowed by permissions policy."; const char kInactiveDocumentError[] = "The document is not active."; +const char kDocumentDomainEnabledError[] = + "document.modelContext cannot be used when document.domain is enabled."; String ValidateAndStringifyObject(ScriptState* script_state, ExceptionState& exception_state, @@ -271,6 +273,11 @@ return; } + if (!IsOriginIsolatedOrFileUrl()) { + exception_state.ThrowSecurityError(kDocumentDomainEnabledError); + return; + } + if (!ExecutionContext::From(script_state) ->IsFeatureEnabled( network::mojom::PermissionsPolicyFeature::kTools)) { @@ -655,6 +662,10 @@ return; } + if (!IsOriginIsolatedOrFileUrl()) { + return; + } + // TODO(https://crbug.com/509983792): Surface an error if the tool's name is // not valid. UseCounter::Count(document_, @@ -783,6 +794,12 @@ return name; } +bool ModelContext::IsOriginIsolatedOrFileUrl() const { + return document_->domWindow()->originAgentCluster() || + document_->GetExecutionContext()->GetSecurityOrigin()->Protocol() == + "file"; +} + ScriptPromise<IDLSequence<RegisteredTool>> ModelContext::getTools( ScriptState* script_state, const ModelContextGetToolOptions* options) { @@ -793,6 +810,13 @@ kInactiveDocumentError)); } + if (!IsOriginIsolatedOrFileUrl()) { + return ScriptPromise<IDLSequence<RegisteredTool>>::RejectWithDOMException( + script_state, + MakeGarbageCollected<DOMException>(DOMExceptionCode::kSecurityError, + kDocumentDomainEnabledError)); + } + if (!ExecutionContext::From(script_state) ->IsFeatureEnabled( network::mojom::PermissionsPolicyFeature::kTools)) { @@ -887,6 +911,13 @@ kInactiveDocumentError)); } + if (!IsOriginIsolatedOrFileUrl()) { + return ScriptPromise<IDLNullable<IDLString>>::RejectWithDOMException( + script_state, + MakeGarbageCollected<DOMException>(DOMExceptionCode::kSecurityError, + kDocumentDomainEnabledError)); + } + if (!ExecutionContext::From(script_state) ->IsFeatureEnabled( network::mojom::PermissionsPolicyFeature::kTools)) { diff --git a/third_party/blink/renderer/core/script_tools/model_context.h b/third_party/blink/renderer/core/script_tools/model_context.h index b1840416..6bff00b 100644 --- a/third_party/blink/renderer/core/script_tools/model_context.h +++ b/third_party/blink/renderer/core/script_tools/model_context.h @@ -197,6 +197,8 @@ class ToolFunctionFinishedCallback; class ToolUnregisterAbortAlgorithm; + bool IsOriginIsolatedOrFileUrl() const; + bool ExecuteV8Tool(V8ToolExecuteCallback* tool_function, const base::UnguessableToken& invocation_id, const String& name, diff --git a/third_party/blink/renderer/core/script_tools/model_context_test.cc b/third_party/blink/renderer/core/script_tools/model_context_test.cc index 66f28a5..ca2a149 100644 --- a/third_party/blink/renderer/core/script_tools/model_context_test.cc +++ b/third_party/blink/renderer/core/script_tools/model_context_test.cc @@ -22,6 +22,7 @@ #include "third_party/blink/renderer/core/dom/abort_controller.h" #include "third_party/blink/renderer/core/dom/events/native_event_listener.h" #include "third_party/blink/renderer/core/frame/local_dom_window.h" +#include "third_party/blink/renderer/core/frame/settings.h" #include "third_party/blink/renderer/core/frame/web_local_frame_impl.h" #include "third_party/blink/renderer/core/page/validation_message_client.h" #include "third_party/blink/renderer/core/script_tools/model_context_supplement.h" @@ -143,6 +144,14 @@ protected: void SetUp() override { SimTest::SetUp(); + // In SimTest, web security is disabled and universal/file access from file + // URLs is enabled by default. This forces documents to always use a + // universal non-origin-keyed agent, bypassing the mock navigation agent + // cluster key. We adjust these settings here to ensure the mock browser + // navigation's agent cluster key is respected. + GetDocument().GetSettings()->SetWebSecurityEnabled(true); + GetDocument().GetSettings()->SetAllowUniversalAccessFromFileURLs(false); + GetDocument().GetSettings()->SetAllowFileAccessFromFileURLs(false); GetDocument() .GetExecutionContext() ->GetBrowserInterfaceBroker() @@ -1532,6 +1541,13 @@ : SimTest(base::test::TaskEnvironment::TimeSource::MOCK_TIME) {} protected: + void SetUp() override { + SimTest::SetUp(); + GetDocument().GetSettings()->SetWebSecurityEnabled(true); + GetDocument().GetSettings()->SetAllowUniversalAccessFromFileURLs(false); + GetDocument().GetSettings()->SetAllowFileAccessFromFileURLs(false); + } + void EvalJsString(std::string_view script) { MainFrame().ExecuteScript(WebScriptSource(WebString::FromUtf8(script))); } @@ -1911,4 +1927,52 @@ EXPECT_TRUE(got_callback); } +TEST_F(ModelContextTest, fileURLAllowedWithoutOriginKeying) { + SimRequest main_resource("file:///tmp/test.html", "text/html"); + LoadURL("file:///tmp/test.html"); + v8::HandleScope handle_scope(Window().GetIsolate()); + ScriptState::Scope script_scope( + ToScriptStateForMainWorld(Window().GetFrame())); + + main_resource.Complete(R"HTML( + <body> + <script> + window.registerToolError = null; + window.registerToolMessage = null; + try { + document.modelContext.registerTool({ + name: "test_tool", + description: "a test tool", + execute: () => "success", + }); + } catch (e) { + window.registerToolError = e.name; + window.registerToolMessage = e.message; + } + </script> + </body> + )HTML"); + test::RunPendingTasks(); + + EXPECT_TRUE(EvalJsBoolean("window.registerToolError === null")); + EXPECT_TRUE(EvalJsBoolean("window.registerToolMessage === null")); +} + +TEST_F(ModelContextTest, fileURLAllowedForDeclarativeTool) { + SimRequest main_resource("file:///tmp/test.html", "text/html"); + LoadURL("file:///tmp/test.html"); + main_resource.Complete("<body></body>"); + + auto* model_context = + ModelContextSupplement::modelContext(*Window().navigator());
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/third_party/blink/renderer/core/script_tools/model_context_test.cc b/third_party/blink/renderer/core/script_tools/model_context_test.cc
index 66f28a5..ca2a149 100644
--- a/third_party/blink/renderer/core/script_tools/model_context_test.cc
+++ b/third_party/blink/renderer/core/script_tools/model_context_test.cc
@@ -22,6 +22,7 @@
#include "third_party/blink/renderer/core/dom/abort_controller.h"
#include "third_party/blink/renderer/core/dom/events/native_event_listener.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
+#include "third_party/blink/renderer/core/frame/settings.h"
#include "third_party/blink/renderer/core/frame/web_local_frame_impl.h"
#include "third_party/blink/renderer/core/page/validation_message_client.h"
#include "third_party/blink/renderer/core/script_tools/model_context_supplement.h"
@@ -143,6 +144,14 @@
protected:
void SetUp() override {
SimTest::SetUp();
+ // In SimTest, web security is disabled and universal/file access from file
+ // URLs is enabled by default. This forces documents to always use a
+ // universal non-origin-keyed agent, bypassing the mock navigation agent
+ // cluster key. We adjust these settings here to ensure the mock browser
+ // navigation's agent cluster key is respected.
+ GetDocument().GetSettings()->SetWebSecurityEnabled(true);
+ GetDocument().GetSettings()->SetAllowUniversalAccessFromFileURLs(false);
+ GetDocument().GetSettings()->SetAllowFileAccessFromFileURLs(false);
GetDocument()
.GetExecutionContext()
->GetBrowserInterfaceBroker()
@@ -1532,6 +1541,13 @@
: SimTest(base::test::TaskEnvironment::TimeSource::MOCK_TIME) {}
protected:
+ void SetUp() override {
+ SimTest::SetUp();
+ GetDocument().GetSettings()->SetWebSecurityEnabled(true);
+ GetDocument().GetSettings()->SetAllowUniversalAccessFromFileURLs(false);
+ GetDocument().GetSettings()->SetAllowFileAccessFromFileURLs(false);
+ }
+
void EvalJsString(std::string_view script) {
MainFrame().ExecuteScript(WebScriptSource(WebString::FromUtf8(script)));
}
@@ -1911,4 +1927,52 @@
EXPECT_TRUE(got_callback);
}
+TEST_F(ModelContextTest, fileURLAllowedWithoutOriginKeying) {
+ SimRequest main_resource("file:///tmp/test.html", "text/html");
+ LoadURL("file:///tmp/test.html");
+ v8::HandleScope handle_scope(Window().GetIsolate());
+ ScriptState::Scope script_scope(
+ ToScriptStateForMainWorld(Window().GetFrame()));
+
+ main_resource.Complete(R"HTML(
+ <body>
+ <script>
+ window.registerToolError = null;
+ window.registerToolMessage = null;
+ try {
+ document.modelContext.registerTool({
+ name: "test_tool",
+ description: "a test tool",
+ execute: () => "success",
+ });
+ } catch (e) {
+ window.registerToolError = e.name;
+ window.registerToolMessage = e.message;
+ }
+ </script>
+ </body>
+ )HTML");
+ test::RunPendingTasks();
+
+ EXPECT_TRUE(EvalJsBoolean("window.registerToolError === null"));
+ EXPECT_TRUE(EvalJsBoolean("window.registerToolMessage === null"));
+}
+
+TEST_F(ModelContextTest, fileURLAllowedForDeclarativeTool) {
+ SimRequest main_resource("file:///tmp/test.html", "text/html");
+ LoadURL("file:///tmp/test.html");
+ main_resource.Complete("<body></body>");
+
+ auto* model_context =
+ ModelContextSupplement::modelContext(*Window().navigator());
+ ASSERT_TRUE(model_context);
+
+ auto* mock_tool = MakeGarbageCollected<MockDeclarativeTool>();
+ model_context->RegisterDeclarativeTool(mock_tool);
+
+ HeapVector<Member<const ToolData>> tools = model_context->ListTools();
+ ASSERT_EQ(1u, tools.size());
+ EXPECT_EQ("test_tool", tools[0]->Name());
+}
+
} // namespace blink
diff --git a/third_party/blink/renderer/core/testing/sim/sim_network.cc b/third_party/blink/renderer/core/testing/sim/sim_network.cc
index 279d809b..53b9215 100644
--- a/third_party/blink/renderer/core/testing/sim/sim_network.cc
+++ b/third_party/blink/renderer/core/testing/sim/sim_network.cc
@@ -133,6 +133,23 @@
for (const auto& http_header : request->response_http_headers_)
params->response.AddHttpHeaderField(http_header.key, http_header.value);
+ // SimTest mock navigations default to being origin-keyed unless the
+ // "Origin-Agent-Cluster: ?0" HTTP header is explicitly provided.
+ bool origin_keyed = true;
+ auto it_oac = request->response_http_headers_.find("Origin-Agent-Cluster");
+ if (it_oac != request->response_http_headers_.end() &&
+ it_oac->value == "?0") {
+ origin_keyed = false;
+ }
+
+ if (origin_keyed) {
+ WebOriginKeyedAgentClusterKey origin_key;
+ origin_key.origin = WebSecurityOrigin::Create(params->url);
+ params->agent_cluster_key = WebAgentClusterKey(origin_key);
+ } else {
+ params->agent_cluster_key = WebAgentClusterKey(params->url);
+ }
+
auto body_loader = std::make_unique<StaticDataNavigationBodyLoader>();
request->UsedForNavigation(body_loader.get());
params->body_loader = std::move(body_loader);
diff --git a/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html b/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html
new file mode 100644
index 0000000..5bb3419
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>WebMCP: Declarative tools are not registered when document.domain is enabled</title>
+<link rel="author" href="mailto:[email protected]">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+
+<form toolname="declarative_tool" tooldescription="Declarative tool description">
+ <input type="text" name="query" required>
+</form>
+
+<script>
+ promise_test(async t => {
+ // ontoolchange should not be triggered. We wait briefly to verify this.
+ let toolChangeTriggered = false;
+ document.modelContext.ontoolchange = () => {
+ toolChangeTriggered = true;
+ };
+
+ await new Promise(resolve => t.step_timeout(resolve, 200));
+ assert_false(toolChangeTriggered, "Declarative tool registration should not trigger ontoolchange");
+
+ // getTools() must reject with SecurityError because document.domain is enabled.
+ await promise_rejects_dom(t, 'SecurityError', document.modelContext.getTools());
+ }, "Declarative tool registration is blocked when document.domain is enabled");
+</script>
+</body>
diff --git a/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html.headers b/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html.headers
new file mode 100644
index 0000000..e007de4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/webmcp/declarative/document-domain-enabled-declarative.https.html.headers
@@ -0,0 +1 @@
+Origin-Agent-Cluster: ?0
diff --git a/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html b/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html
new file mode 100644
index 0000000..a07b8fbd
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html
@@ -0,0 +1,31 @@
+<!DOCTYPE html>
+<title>document.modelContext gated by document.domain being disabled</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+test(() => {
+ const tool = {
+ name: 'test_tool',
+ description: 'a test tool',
+ execute: () => 'success',
+ };
+
+ assert_throws_dom('SecurityError', () => {
+ document.modelContext.registerTool(tool);
+ });
+}, 'modelContext.registerTool throws SecurityError when document.domain is enabled');
+
+promise_test(t => {
+ return promise_rejects_dom(t, 'SecurityError', document.modelContext.getTools());
+}, 'modelContext.getTools rejects with SecurityError when document.domain is enabled');
+
+promise_test(t => {
+ const dummyTool = {
+ name: 'dummy',
+ description: 'dummy',
+ window: window,
+ origin: window.location.origin,
+ };
+ return promise_rejects_dom(t, 'SecurityError', document.modelContext.executeTool(dummyTool, ''));
+}, 'modelContext.executeTool rejects with SecurityError when document.domain is enabled');
+</script>
diff --git a/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html.headers b/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html.headers
new file mode 100644
index 0000000..e007de4
--- /dev/null
+++ b/third_party/blink/web_tests/external/wpt/webmcp/imperative/document-domain-enabled.https.html.headers
@@ -0,0 +1 @@
+Origin-Agent-Cluster: ?0
diff --git a/third_party/blink/web_tests/fast/webmcp/execute_tool.html b/third_party/blink/web_tests/fast/webmcp/execute_tool.html
index 641eb5b..c86faff8 100644
--- a/third_party/blink/web_tests/fast/webmcp/execute_tool.html
+++ b/third_party/blink/web_tests/fast/webmcp/execute_tool.html
@@ -43,11 +43,18 @@
document.getElementById("console").appendChild(document.createTextNode(message + "\n"));
}
-async function runTests() {
+function onloadInit() {
if (window.testRunner) {
testRunner.dumpAsText();
testRunner.waitUntilDone();
}
+ // Declarative tool registration is scheduled asynchronously on the DOM manipulation
+ // task runner, so we yield execution to the next event loop turn to ensure the tool
+ // has completed registration.
+ requestAnimationFrame(runTests);
+}
+
+async function runTests() {
if (!window.document.modelContext) {
print("FAIL: modelContext API disabled");
@@ -136,7 +143,7 @@
</script>
</head>
-<body onload="runTests();">
+<body onload="onloadInit();">
This tests that document.navigator.modelContextTesting works correctly.
<pre id="console">
</pre>
Loading diff…
Original Bug Report
reported by [email protected]
Disable WebMCP for site-keyed documents
If a document opts out of origin keying via
View on issue tracker
Origin-Agent-Cluster: ?0 HTTP header, we should block use of WebMCP, because the document’s origin may change over its lifetime by assigning document.domain.References
On This Page