CVE-2026-113041
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/actor/actor_proto_conversion.cc |
modified | |
ActorProtoConversionTestchrome/browser/actor/actor_proto_conversion_unittest.cc |
modified | |
ActorProtoConversionTestchrome/browser/actor/actor_proto_conversion_unittest.cc |
modified | |
TEST_Fchrome/browser/actor/actor_proto_conversion_unittest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/actor/tools/script_tool_browsertest.cc |
modified |
Files Changed
chrome/browser/actor/tools/script_tool_browsertest.ccchrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.ccchrome/browser/actor/actor_proto_conversion_unittest.ccchrome/browser/actor/actor_proto_conversion.ccchrome/browser/actor/tools/script_tool_request.h
Patch
From 9664cd02ac8d554a6351c6d39d7a538f253a978a Mon Sep 17 00:00:00 2001
From: mark a. foltz <mfoltz@chromium.org>
Date: Tue, 22 Sep 2026 16:33:00 -0700
Subject: [PATCH] [actor] Require document_identifier in ScriptTool requests
Previously, CreateScriptToolRequest() fell back to populating missing
document identifiers using the current primary main frame token.
This CL removes the fallback in CreateScriptToolRequest() and requires
ScriptToolAction to provide a valid document_identifier.
Fixed: 524319033
Change-Id: I3a197130e2d7c9db38219f0cfa871dd36c8d9bc3
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8423266
Reviewed-by: Ben Greenstein <bengr@chromium.org>
Commit-Queue: Mark Foltz <mfoltz@chromium.org>
Reviewed-by: Charles Harrison <csharrison@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1703054}
---
diff --git a/chrome/browser/actor/actor_proto_conversion.cc b/chrome/browser/actor/actor_proto_conversion.cc
index 889687eb..9574ec1 100644
--- a/chrome/browser/actor/actor_proto_conversion.cc
+++ b/chrome/browser/actor/actor_proto_conversion.cc
@@ -695,19 +695,13 @@
return nullptr;
}
- // TODO(khushalsagar): Remove once the callers are setting up this ID
- // correctly.
- std::optional<base::UnguessableToken> document_identifier;
- if (action.has_document_identifier()) {
- document_identifier = base::UnguessableToken::DeserializeFromString(
- action.document_identifier().serialized_token());
- } else {
- auto* main_rfh = tab_handle.Get()->GetContents()->GetPrimaryMainFrame();
- document_identifier = optimization_guide::DocumentIdentifierUserData::
- GetOrCreateForCurrentDocument(main_rfh)
- ->token();
+ if (!action.has_document_identifier()) {
+ return nullptr;
}
+ std::optional<base::UnguessableToken> document_identifier =
+ base::UnguessableToken::DeserializeFromString(
+ action.document_identifier().serialized_token());
if (!document_identifier) {
return nullptr;
}
diff --git a/chrome/browser/actor/tools/script_tool_request.h b/chrome/browser/actor/tools/script_tool_request.h
index b204aff..e3e4bfe 100644
--- a/chrome/browser/actor/tools/script_tool_request.h
+++ b/chrome/browser/actor/tools/script_tool_request.h
@@ -36,6 +36,14 @@
bool RequiresOpeningWebContents() const override;
+ const base::UnguessableToken& GetTargetDocumentIdForTesting() const {
+ return target_document_id_;
+ }
+ const std::string& GetNameForTesting() const { return name_; }
+ const std::string& GetInputArgumentsForTesting() const {
+ return input_arguments_;
+ }
+
private:
base::UnguessableToken target_document_id_;
std::string name_;
diff --git a/chrome/browser/actor/actor_proto_conversion_unittest.cc b/chrome/browser/actor/actor_proto_conversion_unittest.cc
index ed6fc0037..67c1f04a 100644
--- a/chrome/browser/actor/actor_proto_conversion_unittest.cc
+++ b/chrome/browser/actor/actor_proto_conversion_unittest.cc
@@ -7,6 +7,12 @@
#include <optional>
#include <vector>
+#include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
+#include "base/unguessable_token.h"
+#include "chrome/browser/actor/tools/script_tool_request.h"
+#include "components/actor/core/actor_features.h"
+#include "components/optimization_guide/proto/features/actions_data.pb.h"
#include "components/optimization_guide/proto/features/common_quality_data.pb.h"
#include "components/optimization_guide/proto/features/common_quality_data_fuzzable.pb.h"
#include "components/origin_gating/core/task_policy_config.h"
@@ -35,12 +41,35 @@
return Rule(std::move(navigation_sources), resources, capabilities);
}
+optimization_guide::proto::Actions CreateActionsWithScriptTool(
+ std::optional<int32_t> tab_id,
+ std::optional<std::string> document_identifier) {
+ optimization_guide::proto::Actions actions;
+ optimization_guide::proto::ScriptToolAction* script_action =
+ actions.add_actions()->mutable_script_tool();
+ if (tab_id.has_value()) {
+ script_action->set_tab_id(*tab_id);
+ }
+ script_action->set_tool_name("echo");
+ script_action->set_input_arguments(R"({"text":"sample_input"})");
+ if (document_identifier.has_value()) {
+ script_action->mutable_document_identifier()->set_serialized_token(
+ *document_identifier);
+ }
+ return actions;
+}
+
} // namespace
class ActorProtoConversionTest : public testing::Test {
public:
- ActorProtoConversionTest() = default;
+ ActorProtoConversionTest() {
+ feature_list_.InitAndEnableFeature(kGlicActorEnableScriptTools);
+ }
~ActorProtoConversionTest() override = default;
+
+ private:
+ base::test::ScopedFeatureList feature_list_;
};
TEST_F(ActorProtoConversionTest, ConvertEmptyConfig) {
@@ -367,6 +396,60 @@
EXPECT_FALSE(ValidateActionsAreScriptTools(actions));
}
+TEST_F(
+ ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_ValidProto_CreatesRequestWithCorrectParameters) {
+ base::UnguessableToken token = base::UnguessableToken::Create();
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/100, token.ToString());
+
+ BuildToolRequestResult requests = BuildToolRequest(actions);
+ ASSERT_TRUE(requests.has_value());
+ ASSERT_EQ(requests.value().size(), 1u);
+
+ ToolRequest& created_request = *requests.value().front();
+ EXPECT_EQ(ScriptToolRequest::kName, created_request.Name());
+
+ const ScriptToolRequest& script_request =
+ static_cast<const ScriptToolRequest&>(created_request);
+ EXPECT_EQ(100, script_request.GetTabHandle().raw_value());
+ EXPECT_EQ(token, script_request.GetTargetDocumentIdForTesting());
+ EXPECT_EQ("echo", script_request.GetNameForTesting());
+ EXPECT_EQ(R"({"text":"sample_input"})",
+ script_request.GetInputArgumentsForTesting());
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_MissingDocumentIdentifier_ReturnsError) {
+ optimization_guide::proto::Actions actions = CreateActionsWithScriptTool(
+ /*tab_id=*/100, /*document_identifier=*/std::nullopt);
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_InvalidDocumentIdentifier_ReturnsError) {
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/100, "invalid_token");
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_MissingTabId_ReturnsError) {
+ base::UnguessableToken token = base::UnguessableToken::Create();
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/std::nullopt, token.ToString());
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
void CanConvertAnyProto(
const fuzzable::optimization_guide::proto::AgentContainerConfig&
fuzzable_config_proto) {
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 6f2c58a..307ca97c 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -188,6 +188,14 @@
R"JSON({"type":"object","properties":{"text":{"description":)JSON"
R"JSON("Value to echo","type":"string"}},"required":["text"]})JSON";
EXPECT_EQ(response->tool->input_schema, expected_input_schema);
+
+ // Navigate to a new document before invoking the tool; a request bound to
+ // the previous document's identifier must fail with kTabWentAway.
+ auto stale_action =
+ MakeScriptToolRequest(*main_frame(), "echo", input_arguments);
+ ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
+ RunScriptToolExpectingError(std::move(stale_action),
+ mojom::ActionResultCode::kTabWentAway);
}
IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, DeclarativeTool) {
@@ -807,6 +815,17 @@
auto action = MakeScriptToolRequest(subframe, "echo", input_arguments);
auto [action_result, response] = RunScriptTool(std::move(action));
EXPECT_EQ(response->result, "cross_origin_subframe");
+
+ // Navigating the subframe before invoking the tool invalidates the old
+ // subframe document ID (kTabWentAway).
+ auto stale_subframe_action =
+ MakeScriptToolRequest(subframe, "echo", input_arguments);
+ const GURL new_subframe_url =
+ embedded_https_test_server().GetURL("c.com", "/actor/script_tool.html");
+ ASSERT_TRUE(
+ content::NavigateIframeToURL(web_contents(), "iframe", new_subframe_url));
+ RunScriptToolExpectingError(std::move(stale_subframe_action),
+ mojom::ActionResultCode::kTabWentAway);
}
IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, CrossOriginSubframeNavigates) {
diff --git a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
index bea09a3..cf36bc8 100644
--- a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
+++ b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
@@ -1687,8 +1687,11 @@
TEST_F(GlicExperimentalTriggeringCoordinatorWithTabTest,
ExecuteActions_Complete) {
- ExperimentalTriggeringRequest request =
- CreateExecuteScriptToolRequest(mock_tab_.GetHandle().raw_value());
+ auto* user_data = optimization_guide::DocumentIdentifierUserData::
+ GetOrCreateForCurrentDocument(web_contents_->GetPrimaryMainFrame());
+
+ ExperimentalTriggeringRequest request = CreateExecuteScriptToolRequest(
+ mock_tab_.GetHandle().raw_value(), user_data->serialized_token());
base::test::TestFuture<ExperimentalTriggeringResponse> update_future;
coordinator_->OnRequest(
Regression Test / PoC
diff --git a/chrome/browser/actor/actor_proto_conversion_unittest.cc b/chrome/browser/actor/actor_proto_conversion_unittest.cc
index ed6fc0037..67c1f04a 100644
--- a/chrome/browser/actor/actor_proto_conversion_unittest.cc
+++ b/chrome/browser/actor/actor_proto_conversion_unittest.cc
@@ -7,6 +7,12 @@
#include <optional>
#include <vector>
+#include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
+#include "base/unguessable_token.h"
+#include "chrome/browser/actor/tools/script_tool_request.h"
+#include "components/actor/core/actor_features.h"
+#include "components/optimization_guide/proto/features/actions_data.pb.h"
#include "components/optimization_guide/proto/features/common_quality_data.pb.h"
#include "components/optimization_guide/proto/features/common_quality_data_fuzzable.pb.h"
#include "components/origin_gating/core/task_policy_config.h"
@@ -35,12 +41,35 @@
return Rule(std::move(navigation_sources), resources, capabilities);
}
+optimization_guide::proto::Actions CreateActionsWithScriptTool(
+ std::optional<int32_t> tab_id,
+ std::optional<std::string> document_identifier) {
+ optimization_guide::proto::Actions actions;
+ optimization_guide::proto::ScriptToolAction* script_action =
+ actions.add_actions()->mutable_script_tool();
+ if (tab_id.has_value()) {
+ script_action->set_tab_id(*tab_id);
+ }
+ script_action->set_tool_name("echo");
+ script_action->set_input_arguments(R"({"text":"sample_input"})");
+ if (document_identifier.has_value()) {
+ script_action->mutable_document_identifier()->set_serialized_token(
+ *document_identifier);
+ }
+ return actions;
+}
+
} // namespace
class ActorProtoConversionTest : public testing::Test {
public:
- ActorProtoConversionTest() = default;
+ ActorProtoConversionTest() {
+ feature_list_.InitAndEnableFeature(kGlicActorEnableScriptTools);
+ }
~ActorProtoConversionTest() override = default;
+
+ private:
+ base::test::ScopedFeatureList feature_list_;
};
TEST_F(ActorProtoConversionTest, ConvertEmptyConfig) {
@@ -367,6 +396,60 @@
EXPECT_FALSE(ValidateActionsAreScriptTools(actions));
}
+TEST_F(
+ ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_ValidProto_CreatesRequestWithCorrectParameters) {
+ base::UnguessableToken token = base::UnguessableToken::Create();
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/100, token.ToString());
+
+ BuildToolRequestResult requests = BuildToolRequest(actions);
+ ASSERT_TRUE(requests.has_value());
+ ASSERT_EQ(requests.value().size(), 1u);
+
+ ToolRequest& created_request = *requests.value().front();
+ EXPECT_EQ(ScriptToolRequest::kName, created_request.Name());
+
+ const ScriptToolRequest& script_request =
+ static_cast<const ScriptToolRequest&>(created_request);
+ EXPECT_EQ(100, script_request.GetTabHandle().raw_value());
+ EXPECT_EQ(token, script_request.GetTargetDocumentIdForTesting());
+ EXPECT_EQ("echo", script_request.GetNameForTesting());
+ EXPECT_EQ(R"({"text":"sample_input"})",
+ script_request.GetInputArgumentsForTesting());
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_MissingDocumentIdentifier_ReturnsError) {
+ optimization_guide::proto::Actions actions = CreateActionsWithScriptTool(
+ /*tab_id=*/100, /*document_identifier=*/std::nullopt);
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_InvalidDocumentIdentifier_ReturnsError) {
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/100, "invalid_token");
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
+TEST_F(ActorProtoConversionTest,
+ BuildToolRequest_ScriptTool_MissingTabId_ReturnsError) {
+ base::UnguessableToken token = base::UnguessableToken::Create();
+ optimization_guide::proto::Actions actions =
+ CreateActionsWithScriptTool(/*tab_id=*/std::nullopt, token.ToString());
+
+ EXPECT_THAT(BuildToolRequest(actions),
+ base::test::ErrorIs(testing::Pair(
+ 0u, mojom::ActionResultCode::kArgumentsInvalid)));
+}
+
void CanConvertAnyProto(
const fuzzable::optimization_guide::proto::AgentContainerConfig&
fuzzable_config_proto) {
diff --git a/chrome/browser/actor/tools/script_tool_browsertest.cc b/chrome/browser/actor/tools/script_tool_browsertest.cc
index 6f2c58a..307ca97c 100644
--- a/chrome/browser/actor/tools/script_tool_browsertest.cc
+++ b/chrome/browser/actor/tools/script_tool_browsertest.cc
@@ -188,6 +188,14 @@
R"JSON({"type":"object","properties":{"text":{"description":)JSON"
R"JSON("Value to echo","type":"string"}},"required":["text"]})JSON";
EXPECT_EQ(response->tool->input_schema, expected_input_schema);
+
+ // Navigate to a new document before invoking the tool; a request bound to
+ // the previous document's identifier must fail with kTabWentAway.
+ auto stale_action =
+ MakeScriptToolRequest(*main_frame(), "echo", input_arguments);
+ ASSERT_TRUE(content::NavigateToURL(web_contents(), url));
+ RunScriptToolExpectingError(std::move(stale_action),
+ mojom::ActionResultCode::kTabWentAway);
}
IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, DeclarativeTool) {
@@ -807,6 +815,17 @@
auto action = MakeScriptToolRequest(subframe, "echo", input_arguments);
auto [action_result, response] = RunScriptTool(std::move(action));
EXPECT_EQ(response->result, "cross_origin_subframe");
+
+ // Navigating the subframe before invoking the tool invalidates the old
+ // subframe document ID (kTabWentAway).
+ auto stale_subframe_action =
+ MakeScriptToolRequest(subframe, "echo", input_arguments);
+ const GURL new_subframe_url =
+ embedded_https_test_server().GetURL("c.com", "/actor/script_tool.html");
+ ASSERT_TRUE(
+ content::NavigateIframeToURL(web_contents(), "iframe", new_subframe_url));
+ RunScriptToolExpectingError(std::move(stale_subframe_action),
+ mojom::ActionResultCode::kTabWentAway);
}
IN_PROC_BROWSER_TEST_F(ActorToolsTestScriptTool, CrossOriginSubframeNavigates) {
diff --git a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
index bea09a3..cf36bc8 100644
--- a/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
+++ b/chrome/browser/glic/experimental_triggering/glic_experimental_triggering_coordinator_unittest.cc
@@ -1687,8 +1687,11 @@
TEST_F(GlicExperimentalTriggeringCoordinatorWithTabTest,
ExecuteActions_Complete) {
- ExperimentalTriggeringRequest request =
- CreateExecuteScriptToolRequest(mock_tab_.GetHandle().raw_value());
+ auto* user_data = optimization_guide::DocumentIdentifierUserData::
+ GetOrCreateForCurrentDocument(web_contents_->GetPrimaryMainFrame());
+
+ ExperimentalTriggeringRequest request = CreateExecuteScriptToolRequest(
+ mock_tab_.GetHandle().raw_value(), user_data->serialized_token());
base::test::TestFuture<ExperimentalTriggeringResponse> update_future;
coordinator_->OnRequest(
Original Bug Report
TOCTOU bypass in ScriptToolHost allows cross-origin data leak of AI action arguments
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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: When a ScriptToolAction lacks a document identifier, it defaults to capturing the current frame’s token at request creation. If the tab navigates during the AI model’s roundtrip, TimeOfUseValidation tautologically compares this new token against itself, bypassing security checks. This allows an attacker to execute the tool on a different origin, leaking sensitive input arguments intended for the original page.
Affected files:
chrome/browser/actor/actor_proto_conversion.ccchrome/browser/actor/tools/script_tool_host.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
1. Summary of the Issue (Meant for Human Triage)
A Time-of-Check to Time-of-Use (TOCTOU) vulnerability exists in the Chrome Glic ScriptTool execution pipeline, potentially leading to a cross-origin data leak. When the AI model generates a ScriptToolAction proto, it currently omits the document_identifier field. During the proto-to-request conversion step (CreateScriptToolRequest), the system compensates for this omission by capturing the document token of whatever document is currently loaded in the target tab’s main frame.
Because the AI model’s server-side inference involves a multi-second roundtrip, an attacker can navigate the target tab cross-origin during this window. If a navigation commits before CreateScriptToolRequest executes, the fallback logic will capture the attacker’s document token. Subsequently, ScriptToolHost::TimeOfUseValidation evaluates this newly generated token against the then-current main frame’s token. Because both tokens were captured after the navigation, the validation performs a tautological check (X == X), successfully validating the attacker’s document.
Unlike PageTool::TimeOfUseValidation, ScriptToolHost entirely ignores the last_observation parameter, meaning it never compares the target document against the original state observed by the model. Furthermore, default navigation throttles (kGlicConfirmNavigationToNewOrigins = false) permit this cross-origin transition. If the attacker registers a tool with the same name as the intended action, the AI agent’s sensitive input_arguments (such as transaction details or PII) will be dispatched to the attacker’s page.
2. Proof-of-Concept & Detailed Execution Flow
Below is the step-by-step execution flow detailing how a potential attacker could trigger the vulnerability. (Note: Our tooling agent does not run live code, so these are the theoretically modeled steps based on codebase analysis).
- Preconditions & Attacker Setup: The user has the Glic Actor feature enabled (
kGlicActorandkGlicActorEnableScriptToolsare enabled). The attacker establishes a mechanism to navigate a target tab cross-origin. For example, the user visitsattacker.com, which opens a popup to a sensitive site likebank.com, retaining awindow.openerreference that allows the attacker to manipulate the popup’s location. - The user activates the Glic AI Agent in the context of the sensitive
bank.comtab. - The browser gathers context about
bank.com(the observation) and sends it to the server-side AI model to compute the next actions. This network request and model inference takes several seconds. TheActorTaskstate transitions tokReflecting. - During this round-trip window, the attacker executes JavaScript on their page (e.g.,
window.opener.location = "https://attacker.com/") to navigate the target tab frombank.comtoattacker.com. - The navigation initiates.
ActorNavigationThrottle::WillStartOrRedirectRequest(chrome/browser/actor/actor_navigation_throttle.cc) inspects the navigation by callingactor::MayActOnUrl. - Inside
actor::MayActOnUrl(chrome/browser/actor/site_policy.cc:347), theorigin_gating_cacheparameter is passed asstd::nullopt. Because of this, the check(!origin_gating_cache || ...)insideMayActOnUrlInternal(chrome/browser/actor/site_policy.cc:264) evaluates to true. The logic short-circuits todecision_wrapper->Accept(), permitting the navigation. ExecutionEngine::ShouldDeferNavigation(chrome/browser/actor/execution_engine.cc:284) defers the navigation to perform safety checks viaHandleNavigationToNewOrigin.- Inside
HandleNavigationToNewOrigin(chrome/browser/actor/execution_engine.cc:486), the conditionif (!kGlicConfirmNavigationToNewOrigins.Get())is evaluated. Under default configurations,kGlicConfirmNavigationToNewOriginsis set tofalse(components/actor/core/actor_features.cc:58-62). The code automatically runs the callback withmay_continue=true(execution_engine.cc:503), allowing the navigation toattacker.comto commit. attacker.comsuccessfully loads in the target tab. The attacker’s page uses the Glic API to register a script tool with the exact name expected to be invoked on the original page (e.g.,transfer).- The server-side AI model completes its processing and returns an
optimization_guide::proto::Actionsresponse containing aScriptToolActionintended forbank.com. This action carries sensitive data in itsinput_argumentsfield, but lacks adocument_identifierfield. - The browser receives the response and
GlicActorTaskManager::PerformActionscallsactor::BuildToolRequest(actions)(chrome/browser/glic/actor/glic_actor_task_manager.cc:580). BuildToolRequestprocesses each action, callingCreateScriptToolRequest(script_tool_action)(chrome/browser/actor/actor_proto_conversion.cc:837).- Inside
CreateScriptToolRequest, becauseaction.has_document_identifier()is false, the code falls into theelseblock to generate a fallback identifier (chrome/browser/actor/actor_proto_conversion.cc:670). - The fallback logic retrieves the current primary main frame of the tab:
auto* main_rfh = tab_handle.Get()->GetContents()->GetPrimaryMainFrame();(actor_proto_conversion.cc:671). Because the navigation committed in Step 8, this frame now belongs toattacker.com. CreateScriptToolRequestcaptures the document token forattacker.comviaDocumentIdentifierUserData::GetOrCreateForCurrentDocument(main_rfh)->token()(actor_proto_conversion.cc:672) and binds it as thetarget_document_id_of the newly createdScriptToolRequestobject.- Following request creation,
ActorKeyedService::PerformActionscallsActorTask::Act(chrome/browser/actor/actor_keyed_service.cc:587), which transitions the task state tokActingand callsExecutionEngine::Act(chrome/browser/actor/actor_task.cc:380). ExecutionEngine::Actinitiates execution throughKickOffNextAction()andExecuteNextAction(), which instantiatesScriptToolHostand ultimately callsToolController::Invokeafter posting tasks across the message loop (chrome/browser/actor/execution_engine.cc).- Immediately prior to invocation,
ToolController::InvokecallsScriptToolHost::TimeOfUseValidation(last_observation)(chrome/browser/actor/tools/tool_controller.cc:198). ScriptToolHost::TimeOfUseValidationfetches the current primary main frame’s document token, stored asprimary_document_id(chrome/browser/actor/tools/script_tool_host.cc:93). Since the tab is still atattacker.com, this evaluates toattacker.com’s token.- The validation logic checks
if (primary_document_id != target_document_id_)(chrome/browser/actor/tools/script_tool_host.cc:97). Because both tokens were captured after the navigation toattacker.com, they are identical (attacker.com_token != attacker.com_token). - The comparison evaluates to false. Furthermore,
ScriptToolHostignores thelast_observationparameter (unlikePageTool::TimeOfUseValidation), meaning the document is never compared against the originalbank.comobservation state. The validation succeeds and returnsMakeOkResult()(chrome/browser/actor/tools/script_tool_host.cc:105), bypassing the intended TOCTOU boundary. - Execution proceeds to
ScriptToolHost::Invoke, which constructs aToolInvocationIPC carrying the sensitiveinput_argumentsoriginally intended forbank.com(chrome/browser/actor/tools/script_tool_host.cc:140). ScriptToolHost::Invokecallstarget_document_render_frame_->InvokeTool(...), dispatching the IPC over mojo to theattacker.comrenderer (chrome/browser/actor/tools/script_tool_host.cc:153).- The attacker’s registered script tool on
attacker.comreceives the invocation, successfully reading and exfiltrating the sensitive data.
Suggested Fix
Remove the else fallback block in CreateScriptToolRequest (chrome/browser/actor/actor_proto_conversion.cc:670-675) and strictly require the proto to specify the correct document identifier. If !action.has_document_identifier() is true, the method should simply return nullptr. Alternatively, if a fallback mechanism is functionally necessary, ScriptToolHost::TimeOfUseValidation must be updated to align with PageTool::TimeOfUseValidation by verifying the target document directly against the last_observation parameter, ensuring the document identity matches the original model input context.
3. Technical Verification Details (Automated Audit Logs)
Prior Critic verdict:
> Severity: Medium (S2)
> Brief Notes / Reasoning: Probability real: 99%, Details correct: 99%, Exploitable: 90%. The vulnerability report correctly identifies a TOCTOU flaw in CreateScriptToolRequest combined with ScriptToolHost::TimeOfUseValidation. When the document_identifier is missing from the ScriptToolAction proto, the conversion code in actor_proto_conversion.cc defaults to fetching the current document token of the main frame. Subsequently, ScriptToolHost::TimeOfUseValidation compares this token against the current document token of the main frame, which is always a tautology (X == X) if a navigation committed before CreateScriptToolRequest is called. Unlike PageTool::TimeOfUseValidation, ScriptToolHost does not validate the last_observation token, meaning it entirely fails to verify that the target document is the same one the model originally observed. By navigating the tab during the model’s round-trip, an attacker can cause the ScriptToolAction intended for the original page (e.g., bank.com) to be executed on their own origin, leading to a cross-origin data leak of any PII contained in the input_arguments. Code inspection confirms that navigation gating checks (HandleNavigationToNewOrigin) default to allowing the navigation (kGlicConfirmNavigationToNewOrigins is false). Severity Justification: S2 (Medium). The issue allows an attacker to read cross-origin data generated by the model for a different site. While cross-origin leaks can sometimes be High (S1), the requirement for the user (or an extension) to invoke the AI agent on the target page, combined with the timing requirement of navigating the tab during the model round-trip, makes Medium (S2) the appropriate rating. Per Chrome’s severity guidelines, because kGlicActor is an off-by-default feature without a field trial, we assess the severity as if the flag were enabled (effectively S2, though it carries Security_Impact-None in production today).
Audit Checks Executed & Confirmed:
- Token Retrieval TOCTOU Verification: Codebase Investigator confirmed that
actor_proto_conversion.cc:671fetchestab_handle.Get()->GetContents()->GetPrimaryMainFrame()sequentially on request creation, generating a newUnguessableTokenuniquely tied to the post-navigation frame. - Token Comparison Logic Check:
ScriptToolHost::TimeOfUseValidation(script_tool_host.cc:93-97) compares this newly retrievedtarget_document_id_with theprimary_document_id. The codebase agent confirmed via Task Lifecycle analysis that multiplePostTaskasynchronous boundaries occur between request creation (BuildToolRequest) and tool validation (ToolController::Invoke), confirming the timing gap. However, since the navigation completes before request creation, the validation is inherentlyX == Xand succeeds. - Navigation Gating Absence Verification: Evaluated
ActorNavigationThrottle::WillStartOrRedirectRequest. Determined it callsMayActOnUrlwithstd::nulloptas theorigin_gating_cache. EvaluatedMayActOnUrlInternal(site_policy.cc:264) confirming that!origin_gating_cacheevaluates to true, causing a short-circuit todecision_wrapper->Accept(). EvaluatedkGlicConfirmNavigationToNewOriginsinactor_features.cc:58-62, confirming its default state is indeedfalse, granting automatic bypass insideExecutionEngine::HandleNavigationToNewOrigin. - No Mitigation Found in Actor Controls: Evaluated auxiliary security mechanisms (
SetFocusState,IncrementCapturerCount,ForbidExternalPopupMenus). Codebase Investigator confirmed none of these block JavaScript-driven navigation (e.g.,window.opener.location), ensuring the attacker navigation path is fully unobstructed. - Observation Data Boundary: Codebase Investigator checked
script_tool_host.ccand verified that thelast_observationparameter passed toTimeOfUseValidationis never used to validate the frame identity, a critical divergence fromPageTool::TimeOfUseValidation.
Evaluated with Chrome root at commit: 849d34c7b2ab92513cebb86abb5203390a703eeb
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.