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 <[email protected]> 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 <[email protected]> Reviewed-by: Mark Foltz <[email protected]> Commit-Queue: Dominic Farolino <[email protected]> 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