Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in WebMCP
DescriptionInsufficient policy enforcement in WebMCP
ComponentWebMCP
Bug ClassLogic Error
Tracker519500882
Fix commit613c13a80148 (chromium/src) +184/-2
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
ToolFunctionFinishedCallback
third_party/blink/renderer/core/script_tools/model_context.h
modified
ToolUnregisterAbortAlgorithm
third_party/blink/renderer/core/script_tools/model_context.h
modified
TEST_F
third_party/blink/renderer/core/script_tools/model_context_test.cc
modified

Files Changed

  • third_party/blink/renderer/core/script_tools/model_context.cc
  • third_party/blink/renderer/core/script_tools/model_context.h
  • third_party/blink/renderer/core/script_tools/model_context_test.cc
From 613c13a8014852139b2fcf6d32029fd8bb195142 Mon Sep 17 00:00:00 2001
From: mark a. foltz <mfoltz@chromium.org>
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 <khushalsagar@chromium.org>
Reviewed-by: Dominic Farolino <dom@chromium.org>
Commit-Queue: Mark Foltz <mfoltz@chromium.org>
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:dom@chromium.org">
+<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 mf...@chromium.org

Disable WebMCP for site-keyed documents

If a document opts out of origin keying via 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.
View on issue tracker