Chrome · Actor › WebMCP
CVE-2026-113296
Memory Corruption in Actor (WebMCP)
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcomponents/optimization_guide/content/browser/page_content_proto_util.cc |
modified | |
ifcomponents/optimization_guide/content/browser/page_content_proto_util.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/actor/tools/script_tool_browsertest.cc |
modified | |
TEST_Fcomponents/optimization_guide/content/browser/page_content_proto_util_unittest.cc |
modified |
Files Changed
chrome/browser/actor/tools/script_tool_browsertest.cccomponents/optimization_guide/content/browser/page_content_proto_provider.cccomponents/optimization_guide/content/browser/page_content_proto_util.hchrome/browser/actor/tools/script_tool_host.ccchrome/browser/actor/tools/script_tool_request.cccomponents/optimization_guide/content/browser/page_content_proto_util_unittest.cccomponents/optimization_guide/content/browser/page_content_proto_util.cc
Patch
From cff8d8fcf36bab59f3cbe85cd6e0499b0a536b51 Mon Sep 17 00:00:00 2001
From: Dominic Farolino <dom@chromium.org>
Date: Fri, 25 Sep 2026 16:45:57 -0700
Subject: [PATCH] WebMCP: Enforce permission policy in actor stack
Currently, `ScriptToolHost::TimeOfUseValidation()` and
`ConvertFrameData` do not verify in the browser process whether
`blink::features::kWebMCP` and `PermissionsPolicyFeature::kTools` are
enabled for the target `RenderFrameHost(Impl)`.
Because `Permissions-Policy: tools` defaults to `EnableForSelf`,
cross-origin subframes are disallowed from using WebMCP tools unless
explicitly delegated `allow="tools"`. Without browser-side checks:
1. A compromised renderer in a disallowed frame could populate
`mojom_frame_data.script_tools` in `GetAIPageContent` and advertise
tools to the agent in `AnnotatedPageContent`.
2. If the actor dispatched a `ScriptToolAction` targeting a disallowed
frame, `ScriptToolHost::Invoke()` would grant browser-verified
transient user activation (`NotifyUserActivation(kActorWebMCP)`) to
that frame before invoking the tool.
This CL enforces the `kWebMCP` feature and
`PermissionsPolicyFeature::kTools` on the `RenderFrameHost` at booth
tool/APC registration time (`GetRenderFrameInfo()` and
`ConvertFrameData()`) and tool execution time
(`ScriptToolHost::TimeOfUseValidation()`).
This has the benefit of making it impossible for a compromised renderer
without the `tools` permission to advertise tools to the actor stack,
and therefore makes it impossible for it to trick the agent into
executing a fake tool and providing the frame with user activation.
R=mfoltz
Bug: 524316342
Change-Id: Ic44b4bb8e927f889a1be75048e11ae03e6e2c323
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8466242
Reviewed-by: Shivani Sharma <shivanisha@chromium.org>
Reviewed-by: Mark Foltz <mfoltz@chromium.org>
Commit-Queue: Dominic Farolino <dom@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1705317}
---
diff --git a/chrome/browser/actor/tools/script_tool_host.cc b/chrome/browser/actor/tools/script_tool_host.cc
index 4da719a..c7834512 100644
--- a/chrome/browser/actor/tools/script_tool_host.cc
+++ b/chrome/browser/actor/tools/script_tool_host.cc
@@ -26,7 +26,9 @@
#include "content/public/browser/web_contents.h"
#include "content/public/browser/web_contents_user_data.h"
#include "mojo/public/cpp/bindings/message.h"
+#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
#include "third_party/blink/public/common/associated_interfaces/associated_interface_provider.h"
+#include "third_party/blink/public/common/features_generated.h"
#include "third_party/blink/public/mojom/frame/user_activation_notification_type.mojom-shared.h"
namespace actor {
@@ -97,6 +99,12 @@
return MakeResult(mojom::ActionResultCode::kTabWentAway);
}
+ CHECK(base::FeatureList::IsEnabled(blink::features::kWebMCP));
+ if (!target_rfh->IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools)) {
+ return MakeResult(mojom::ActionResultCode::kScriptToolInvocationFailed);
+ }
+
target_frame_tree_node_id_ = target_rfh->GetFrameTreeNodeId();
target_document_ = target_rfh->GetWeakDocumentPtr();
return MakeOkResult();
@@ -126,6 +134,9 @@
InitializePendingResult();
auto* frame = target_document_.AsRenderFrameHostIfValid();
CHECK(frame);
+ CHECK(base::FeatureList::IsEnabled(blink::features::kWebMCP));
+ CHECK(frame->IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools));
// Provide transient user activation to the frame for the tool invocation.
if (base::FeatureList::IsEnabled(
@@ -360,6 +371,19 @@
return;
}
+ // The new document must also be allowed to use WebMCP. It may not be, even
+ // though it is same-origin with the original target document, if it was
+ // served with `Permissions-Policy: tools=()`, or if its embedder revoked
+ // `allow="tools"` before the navigation.
+ if (!new_host->IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools)) {
+ PostErrorResult(std::move(tool_done_callback_),
+ mojom::ActionResultCode::kScriptToolInvocationFailed,
+ base::StrCat({"Tools permission disabled in new document: ",
+ new_host->GetLastCommittedURL().spec()}));
+ return;
+ }
+
lifecycle_ = Lifecycle::kPendingResultFromNewDocument;
new_document_ = new_host->GetWeakDocumentPtr();
diff --git a/chrome/browser/actor/tools/script_tool_request.cc b/chrome/browser/actor/tools/script_tool_request.cc
index fc385ee..b98b9f684 100644
--- a/chrome/browser/actor/tools/script_tool_request.cc
+++ b/chrome/browser/actor/tools/script_tool_request.cc
@@ -14,6 +14,7 @@
#include "chrome/common/actor/actor_constants.h"
#include "components/actor/core/actor_features.h"
#include "components/actor/public/mojom/actor_types.mojom.h"
+#include "third_party/blink/public/common/features_generated.h"
namespace actor {
@@ -42,6 +43,13 @@
ToolRequest::CreateToolResult ScriptToolRequest::CreateTool(
TaskId task_id,
ToolDelegate& tool_delegate) const {
+ if (!base::FeatureList::IsEnabled(blink::features::kWebMCP)) {
+ return {/*tool=*/nullptr,
+ MakeResult(mojom::ActionResultCode::kScriptToolInvocationFailed,
+ /*requires_page_stabilization=*/false,
+ "WebMCP is not enabled.")};
+ }
+
if (!GetTabHandle().Get()) {
return {/*tool=*/nullptr, MakeResult(mojom::ActionResultCode::kTabWentAway,
/*requires_page_stabilization=*/false,
diff --git a/components/optimization_guide/content/browser/page_content_proto_provider.cc b/components/optimization_guide/content/browser/page_content_proto_provider.cc
index da09d13d..3b7dec2 100644
--- a/components/optimization_guide/content/browser/page_content_proto_provider.cc
+++ b/components/optimization_guide/content/browser/page_content_proto_provider.cc
@@ -46,7 +46,9 @@
#include "services/metrics/public/cpp/metrics_utils.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
+#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
#include "services/service_manager/public/cpp/interface_provider.h"
+#include "third_party/blink/public/common/features_generated.h"
#include "third_party/blink/public/mojom/content_extraction/ai_page_content.mojom.h"
#include "ui/gfx/geometry/rect.h"
@@ -323,6 +325,10 @@
render_frame_info.source_origin = render_frame_host->GetLastCommittedOrigin();
render_frame_info.url = render_frame_host->GetLastCommittedURL();
render_frame_info.media_data = ComputeMediaData(render_frame_host);
+ render_frame_info.script_tools_are_allowed =
+ base::FeatureList::IsEnabled(blink::features::kWebMCP) &&
+ render_frame_host->IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools);
if (base::FeatureList::IsEnabled(
features::kAnnotatedPageContentVerifyPopupProcess)) {
diff --git a/components/optimization_guide/content/browser/page_content_proto_util.cc b/components/optimization_guide/content/browser/page_content_proto_util.cc
index a7983ea..a22ce5e 100644
--- a/components/optimization_guide/content/browser/page_content_proto_util.cc
+++ b/components/optimization_guide/content/browser/page_content_proto_util.cc
@@ -1053,8 +1053,23 @@
metadata.frame_metadata.back()->has_media_transcripts = true;
}
}
- for (const auto& tool : mojom_frame_data.script_tools) {
- ConvertScriptTool(*tool, proto_frame_data->add_script_tools());
+ // Silently drop `script_tools` rather than killing the renderer if
+ // `!render_frame_info.script_tools_are_allowed` and
+ // `!mojom_frame_data.script_tools.empty()`. `GetAIPageContent` is requested
+ // over an unassociated `blink.mojom.AIPageContentAgent` pipe and
+ // `GetRenderFrameInfo()` is evaluated asynchronously on the `RenderFrameHost`
+ // only after all subframes have responded. If a frame legitimately sends
+ // `script_tools` from a document where `Permissions-Policy: tools` is
+ // enabled, and later commits a navigation that reuses the same
+ // `RenderFrameHost` to a document with `Permissions-Policy: tools=()` while
+ // the browser is still waiting on other subframes, `script_tools_are_allowed`
+ // will be false by the time conversion runs. `RenderFrameHost` reuse happens
+ // when RenderDocument is disabled, *and* also independently when navigating
+ // away from the initial empty document.
+ if (render_frame_info.script_tools_are_allowed) {
+ for (const auto& tool : mojom_frame_data.script_tools) {
+ ConvertScriptTool(*tool, proto_frame_data->add_script_tools());
+ }
}
// Accessibility focus is tracked globally in the browser, so it should be set
diff --git a/components/optimization_guide/content/browser/page_content_proto_util.h b/components/optimization_guide/content/browser/page_content_proto_util.h
index 2fa71da6..59fa3ce 100644
--- a/components/optimization_guide/content/browser/page_content_proto_util.h
+++ b/components/optimization_guide/content/browser/page_content_proto_util.h
@@ -80,6 +80,15 @@
// The trusted screen bounds of the active popup widget in DIPs.
gfx::Rect popup_bounds_in_dips;
+
+ // Whether the browser process has verified that this frame is allowed to
+ // expose WebMCP script tools (i.e., the WebMCP feature is enabled and the
+ // frame's Permissions-Policy allows the `tools` feature). This ensures a
+ // compromised renderer in a frame where `Permissions-Policy: tools` is
+ // disabled (such as a cross-origin iframe without `allow="tools"`) cannot
+ // trick the browser into advertising or registering tools with the actor
+ // stack.
+ bool script_tools_are_allowed = false;
};
struct TargetNodeInfo {
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 307ca97c..5a24678 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -33,6 +33,7 @@
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/test_navigation_throttle.h"
#include "content/public/test/test_navigation_throttle_inserter.h"
+#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/features_generated.h"
@@ -966,4 +967,52 @@
PageContentExtractionPolicy::kSkipped);
}
+IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, PermissionsPolicyDisabled) {
+ // Embed a cross-origin iframe without `allow="tools"`. Since the default
+ // allowlist for `Permissions-Policy: tools` is `EnableForSelf`, the
+ // cross-origin iframe has `PermissionsPolicyFeature::kTools` disabled.
+ content::RenderFrameHost& subframe =
+ NavigateSubframe(embedded_https_test_server().GetURL(
+ "example.com", "/actor/simple_iframe.html"),
+ embedded_https_test_server().GetURL(
+ "sub.other.com", "/actor/script_tool.html"));
+ EXPECT_FALSE(subframe.HasTransientUserActivation());
+
+ auto action =
+ MakeScriptToolRequest(subframe, "echo", R"JSON({"text": "test"})JSON");
+ RunScriptToolExpectingError(
+ std::move(action), mojom::ActionResultCode::kScriptToolInvocationFailed);
+
+ // Ensure the browser process did not grant transient user activation to the
+ // disallowed frame.
+ EXPECT_FALSE(subframe.HasTransientUserActivation());
+}
+
+// A declarative tool whose result comes from a new, same-origin document must
+// fail if the new document has `PermissionsPolicyFeature::kTools` disabled.
+IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool,
+ PermissionsPolicyDisabledInCrossDocumentResult) {
+ // The same-origin iframe has `PermissionsPolicyFeature::kTools` enabled by
+ // default.
+ content::RenderFrameHost& subframe = NavigateSubframe(
+ embedded_https_test_server().GetURL("example.com",
+ "/actor/simple_iframe.html"),
+ embedded_https_test_server().GetURL(
+ "example.com", "/actor/declarative_script_tool_cross_document.html"));
+ ASSERT_TRUE(subframe.IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools));
+
+ // Revoke `tools` from the iframe. The updated container policy only applies
+ // to the document created by the tool's form submission.
+ ASSERT_TRUE(
+ content::ExecJs(web_contents(),
+ "document.getElementById('iframe').setAttribute('allow', "
+ "\"tools 'none'\");"));
+
+ auto action = MakeScriptToolRequest(subframe, "declarative_tool",
+ R"JSON({"echo": "hello world"})JSON");
+ RunScriptToolExpectingError(
+ std::move(action), mojom::ActionResultCode::kScriptToolInvocationFailed);
+}
+
} // namespace actor
diff --git a/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc b/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
index bdf085c7..dbcde462 100644
--- a/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
+++ b/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
@@ -3406,5 +3406,57 @@
.enable_otp_redaction = true,
}));
+TEST_F(PageContentProtoUtilTest, ScriptToolsFilteredByRenderFrameInfo) {
+ auto main_frame_token = CreateFrameToken();
+ auto root_content = CreatePageContent();
+ auto tool = blink::mojom::ScriptTool::New();
+ tool->name = "forged_tool";
+ tool->description = "forged description";
+ tool->annotations = blink::mojom::ScriptToolAnnotations::New();
+ root_content->frame_data->script_tools.push_back(std::move(tool));
+
+ AIPageContentMap page_content_map;
+ page_content_map[main_frame_token] = std::move(root_content);
+
+ bool allow_script_tools = false;
+ auto get_render_frame_info = base::BindLambdaForTesting(
+ [&](int child_process_id,
+ blink::FrameToken token) -> std::optional<RenderFrameInfo> {
+ RenderFrameInfo render_frame_info;
+ render_frame_info.global_frame_token = main_frame_token;
+ render_frame_info.source_origin =
+ url::Origin::Create(GURL("https://example.com"));
+ render_frame_info.url = GURL("https://example.com");
+ render_frame_info.serialized_server_token = token.ToString();
+ render_frame_info.script_tools_are_allowed = allow_script_tools;
+ return render_frame_info;
+ });
+
+ {
+ AIPageContentResult page_content;
+ FrameTokenSet frame_token_set;
+ ASSERT_TRUE(ConvertAIPageContentToProto(
+ blink::mojom::AIPageContentOptions::New(), main_frame_token,
+ page_content_map, get_render_frame_info, frame_token_set,
+ page_content)
+ .has_value());
+ EXPECT_TRUE(page_content.proto.main_frame_data().script_tools().empty());
+ }
+
+ allow_script_tools = true;
+ {
+ AIPageContentResult page_content;
+ FrameTokenSet frame_token_set;
+ ASSERT_TRUE(ConvertAIPageContentToProto(
+ blink::mojom::AIPageContentOptions::New(), main_frame_token,
+ page_content_map, get_render_frame_info, frame_token_set,
+ page_content)
+ .has_value());
+ ASSERT_EQ(page_content.proto.main_frame_data().script_tools().size(), 1);
+ EXPECT_EQ(page_content.proto.main_frame_data().script_tools(0).name(),
+ "forged_tool");
+ }
+}
+
} // namespace
} // namespace optimization_guide
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 307ca97c..5a24678 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -33,6 +33,7 @@
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/test_navigation_throttle.h"
#include "content/public/test/test_navigation_throttle_inserter.h"
+#include "services/network/public/mojom/permissions_policy/permissions_policy_feature.mojom-shared.h"
#include "third_party/blink/public/common/features.h"
#include "third_party/blink/public/common/features_generated.h"
@@ -966,4 +967,52 @@
PageContentExtractionPolicy::kSkipped);
}
+IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, PermissionsPolicyDisabled) {
+ // Embed a cross-origin iframe without `allow="tools"`. Since the default
+ // allowlist for `Permissions-Policy: tools` is `EnableForSelf`, the
+ // cross-origin iframe has `PermissionsPolicyFeature::kTools` disabled.
+ content::RenderFrameHost& subframe =
+ NavigateSubframe(embedded_https_test_server().GetURL(
+ "example.com", "/actor/simple_iframe.html"),
+ embedded_https_test_server().GetURL(
+ "sub.other.com", "/actor/script_tool.html"));
+ EXPECT_FALSE(subframe.HasTransientUserActivation());
+
+ auto action =
+ MakeScriptToolRequest(subframe, "echo", R"JSON({"text": "test"})JSON");
+ RunScriptToolExpectingError(
+ std::move(action), mojom::ActionResultCode::kScriptToolInvocationFailed);
+
+ // Ensure the browser process did not grant transient user activation to the
+ // disallowed frame.
+ EXPECT_FALSE(subframe.HasTransientUserActivation());
+}
+
+// A declarative tool whose result comes from a new, same-origin document must
+// fail if the new document has `PermissionsPolicyFeature::kTools` disabled.
+IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool,
+ PermissionsPolicyDisabledInCrossDocumentResult) {
+ // The same-origin iframe has `PermissionsPolicyFeature::kTools` enabled by
+ // default.
+ content::RenderFrameHost& subframe = NavigateSubframe(
+ embedded_https_test_server().GetURL("example.com",
+ "/actor/simple_iframe.html"),
+ embedded_https_test_server().GetURL(
+ "example.com", "/actor/declarative_script_tool_cross_document.html"));
+ ASSERT_TRUE(subframe.IsFeatureEnabled(
+ network::mojom::PermissionsPolicyFeature::kTools));
+
+ // Revoke `tools` from the iframe. The updated container policy only applies
+ // to the document created by the tool's form submission.
+ ASSERT_TRUE(
+ content::ExecJs(web_contents(),
+ "document.getElementById('iframe').setAttribute('allow', "
+ "\"tools 'none'\");"));
+
+ auto action = MakeScriptToolRequest(subframe, "declarative_tool",
+ R"JSON({"echo": "hello world"})JSON");
+ RunScriptToolExpectingError(
+ std::move(action), mojom::ActionResultCode::kScriptToolInvocationFailed);
+}
+
} // namespace actor
diff --git a/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc b/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
index bdf085c7..dbcde462 100644
--- a/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
+++ b/components/optimization_guide/content/browser/page_content_proto_util_unittest.cc
@@ -3406,5 +3406,57 @@
.enable_otp_redaction = true,
}));
+TEST_F(PageContentProtoUtilTest, ScriptToolsFilteredByRenderFrameInfo) {
+ auto main_frame_token = CreateFrameToken();
+ auto root_content = CreatePageContent();
+ auto tool = blink::mojom::ScriptTool::New();
+ tool->name = "forged_tool";
+ tool->description = "forged description";
+ tool->annotations = blink::mojom::ScriptToolAnnotations::New();
+ root_content->frame_data->script_tools.push_back(std::move(tool));
+
+ AIPageContentMap page_content_map;
+ page_content_map[main_frame_token] = std::move(root_content);
+
+ bool allow_script_tools = false;
+ auto get_render_frame_info = base::BindLambdaForTesting(
+ [&](int child_process_id,
+ blink::FrameToken token) -> std::optional<RenderFrameInfo> {
+ RenderFrameInfo render_frame_info;
+ render_frame_info.global_frame_token = main_frame_token;
+ render_frame_info.source_origin =
+ url::Origin::Create(GURL("https://example.com"));
+ render_frame_info.url = GURL("https://example.com");
+ render_frame_info.serialized_server_token = token.ToString();
+ render_frame_info.script_tools_are_allowed = allow_script_tools;
+ return render_frame_info;
+ });
+
+ {
+ AIPageContentResult page_content;
+ FrameTokenSet frame_token_set;
+ ASSERT_TRUE(ConvertAIPageContentToProto(
+ blink::mojom::AIPageContentOptions::New(), main_frame_token,
+ page_content_map, get_render_frame_info, frame_token_set,
+ page_content)
+ .has_value());
+ EXPECT_TRUE(page_content.proto.main_frame_data().script_tools().empty());
+ }
+
+ allow_script_tools = true;
+ {
+ AIPageContentResult page_content;
+ FrameTokenSet frame_token_set;
+ ASSERT_TRUE(ConvertAIPageContentToProto(
+ blink::mojom::AIPageContentOptions::New(), main_frame_token,
+ page_content_map, get_render_frame_info, frame_token_set,
+ page_content)
+ .has_value());
+ ASSERT_EQ(page_content.proto.main_frame_data().script_tools().size(), 1);
+ EXPECT_EQ(page_content.proto.main_frame_data().script_tools(0).name(),
+ "forged_tool");
+ }
+}
+
} // namespace
} // namespace optimization_guide
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page