Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Actor
DescriptionInsufficient policy enforcement in Actor
ComponentActor
Bug ClassLogic Error
Tracker502777516
Fix commit86dcf29b4de3 (chromium/src) +107/-52
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/browser/actor/actor_navigation_throttle.cc
modified

Files Changed

  • chrome/browser/actor/actor_navigation_throttle.cc
  • chrome/browser/actor/execution_engine.cc
  • chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
From 86dcf29b4de36f5b436961d82109df9494b4a24d Mon Sep 17 00:00:00 2001
From: Chris Fredrickson <cfredric@chromium.org>
Date: Mon, 27 Apr 2026 08:30:57 -0700
Subject: [PATCH] Remove same-origin fast-paths for initiator

The initiator isn't trusted, so there shouldn't be a fast-path that
skips other safety checks when the initiator is same-origin with the
destination.

Bug: 502777516
Change-Id: Iaaeef42b1bb446c8dc644ef8400d06623eddce18
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7792953
Reviewed-by: Dave Tapuska <dtapuska@chromium.org>
Commit-Queue: Chris Fredrickson <cfredric@chromium.org>
Auto-Submit: Chris Fredrickson <cfredric@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1621109}
---

diff --git a/chrome/browser/actor/actor_navigation_throttle.cc b/chrome/browser/actor/actor_navigation_throttle.cc
index 5f65def..0a83a849 100644
--- a/chrome/browser/actor/actor_navigation_throttle.cc
+++ b/chrome/browser/actor/actor_navigation_throttle.cc
@@ -202,18 +202,6 @@
     return content::NavigationThrottle::PROCEED;
   }
 
-  if (initiator_origin && initiator_origin->IsSameOriginWith(navigation_url)) {
-    journal.Log(navigation_url, task_id_, "NavThrottle",
-                JournalDetailsBuilder()
-                    .Add("navigate", is_redirection ? "Same origin redirect"
-                                                    : "Same origin navigation")
-                    .Build());
-    // 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.
-    return content::NavigationThrottle::PROCEED;
-  }
-
   actor::ActorTask* task =
       ActorKeyedService::Get(GetProfile())->GetTask(task_id_);
   if (!task) {
diff --git a/chrome/browser/actor/execution_engine.cc b/chrome/browser/actor/execution_engine.cc
index d76b4f6..afa3783e 100644
--- a/chrome/browser/actor/execution_engine.cc
+++ b/chrome/browser/actor/execution_engine.cc
@@ -409,7 +409,7 @@
   // If not sensitive, check if it's an origin the actor has previously
   // interacted with or received instructions from the server to interact with.
   if (not_sensitive &&
-      origin_checker_.IsNavigationAllowed(initiator, destination)) {
+      origin_checker_.IsNavigationAllowed(source, destination)) {
     LogNavigationGating(source, initiator, destination,
                         /*applied_gate=*/false);
     ukm::builders::Actor_OriginGating builder(ukm_source_id);
diff --git a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
index 9f005ac9..6367967 100644
--- a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
+++ b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
@@ -28,6 +28,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #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 "services/metrics/public/cpp/ukm_builders.h"
 #include "url/origin.h"
@@ -1035,6 +1036,55 @@
 }
 
 IN_PROC_BROWSER_TEST_F(ExecutionEngineOriginGatingBrowserTest,
+                       NavigationBlockedByStaticList_CrossOriginIframe) {
+  SafetyListManager::GetInstance()->ParseSafetyLists(R"json(
+    {
+      "navigation_blocked": [
+        { "from": "*", "to": "blocked.example.com" }
+      ]
+    }
+  )json");
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/iframe.html");
+  const GURL blocked_url =
+      embedded_https_test_server().GetURL("blocked.example.com", "/empty.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  ASSERT_TRUE(
+      content::NavigateIframeToURL(web_contents(), "test", blocked_url));
+
+  OpenGlicAndCreateTask();
+
+  // No need to wait for the callback, since the tab is added to the controlled
+  // set synchronously.
+  actor_task().AddTab(active_tab()->GetHandle(), /*stop_task_on_detach=*/true,
+                      base::DoNothing());
+
+  RunTestSequence(CreateMockWebClientRequest(
+      content::JsReplace(kHandleUserConfirmationDialogTempl, false)));
+
+  content::TestNavigationObserver observer(web_contents());
+  ASSERT_TRUE(content::ExecJs(
+      content::ChildFrameAt(web_contents()->GetPrimaryMainFrame(), 0),
+      content::JsReplace(R"(
+      const a = document.createElement('a');
+      a.target = "_parent";
+      a.href = $1;
+      document.body.appendChild(a);
+      a.click();
+      )",
+                         blocked_url)));
+  observer.Wait();
+
+  // The navigation is blocked by the blocklist even though the initiator is
+  // same-origin with the destination.
+  histogram_tester.ExpectBucketCount(
+      "Actor.NavigationGating.GatingDecision2",
+      ExecutionEngine::GatingDecision::kBlockByStaticList, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ExecutionEngineOriginGatingBrowserTest,
                        NavigationWithOpaqueSourceOriginBlockedUnderWildcard) {
   base::HistogramTester histogram_tester;
   const GURL blocked_url =
@@ -1503,12 +1553,52 @@
 };
 
 IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
-                       ConfirmNavigationToNewSite_Denied) {
+                       ConfirmNavigation_SameOrigin) {
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/actor/link.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  OpenGlicAndCreateTask();
+
+  // Same origin source should never trigger gating
+  ASSERT_TRUE(content::ExecJs(web_contents(),
+                              content::JsReplace("setLink($1);", start_url)));
+  ClickTarget("#link", mojom::ActionResultCode::kOk);
+}
+
+IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
+                       ConfirmNavigation_CrossOrigin_Denied) {
   base::HistogramTester histogram_tester;
   const GURL start_url =
       embedded_https_test_server().GetURL("example.com", "/actor/link.html");
   const GURL same_site = embedded_https_test_server().GetURL(
       "other.example.com", "/actor/link.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  OpenGlicAndCreateTask();
+
+  RunTestSequence(CreateMockWebClientRequest(
+      content::JsReplace(kHandleNavigationConfirmationTempl, false)));
+
+  // Cross origin but same site source should trigger when we're gating on
+  // origin
+  ASSERT_TRUE(content::ExecJs(web_contents(),
+                              content::JsReplace("setLink($1);", same_site)));
+  ClickTarget("#link",
+              should_gate_by_site()
+                  ? mojom::ActionResultCode::kOk
+                  : mojom::ActionResultCode::kTriggeredNavigationBlocked);
+
+  histogram_tester.ExpectBucketCount("Actor.NavigationGating.PermissionGranted",
+                                     false, should_gate_by_site() ? 0 : 1);
+}
+
+IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
+                       ConfirmNavigation_CrossSite_Denied) {
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/actor/link.html");
   const GURL cross_site =
       embedded_https_test_server().GetURL("foo.com", "/actor/blank.html");
 
@@ -1518,27 +1608,13 @@
   RunTestSequence(CreateMockWebClientRequest(
       content::JsReplace(kHandleNavigationConfirmationTempl, false)));
 
-  // Same origin should never trigger gating
-  ASSERT_TRUE(content::ExecJs(web_contents(),
-                              content::JsReplace("setLink($1);", start_url)));
-  ClickTarget("#link", mojom::ActionResultCode::kOk);
-
-  // Cross origin but same site should only trigger when we're gating on origin
-  ASSERT_TRUE(content::ExecJs(web_contents(),
-                              content::JsReplace("setLink($1);", same_site)));
-  ClickTarget("#link",
-              should_gate_by_site()
-                  ? mojom::ActionResultCode::kOk
-                  : mojom::ActionResultCode::kTriggeredNavigationBlocked);
-
-  // Cross site will always trigger gating
+  // Cross site source will always trigger gating
   ASSERT_TRUE(content::ExecJs(web_contents(),
                               content::JsReplace("setLink($1);", cross_site)));
   ClickTarget("#link", mojom::ActionResultCode::kTriggeredNavigationBlocked);
 
-  // Should log that permission was *denied* once.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
index 9f005ac9..6367967 100644
--- a/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
+++ b/chrome/browser/actor/execution_engine_origin_gating_browsertest.cc
@@ -28,6 +28,7 @@
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #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 "services/metrics/public/cpp/ukm_builders.h"
 #include "url/origin.h"
@@ -1035,6 +1036,55 @@
 }
 
 IN_PROC_BROWSER_TEST_F(ExecutionEngineOriginGatingBrowserTest,
+                       NavigationBlockedByStaticList_CrossOriginIframe) {
+  SafetyListManager::GetInstance()->ParseSafetyLists(R"json(
+    {
+      "navigation_blocked": [
+        { "from": "*", "to": "blocked.example.com" }
+      ]
+    }
+  )json");
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/iframe.html");
+  const GURL blocked_url =
+      embedded_https_test_server().GetURL("blocked.example.com", "/empty.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  ASSERT_TRUE(
+      content::NavigateIframeToURL(web_contents(), "test", blocked_url));
+
+  OpenGlicAndCreateTask();
+
+  // No need to wait for the callback, since the tab is added to the controlled
+  // set synchronously.
+  actor_task().AddTab(active_tab()->GetHandle(), /*stop_task_on_detach=*/true,
+                      base::DoNothing());
+
+  RunTestSequence(CreateMockWebClientRequest(
+      content::JsReplace(kHandleUserConfirmationDialogTempl, false)));
+
+  content::TestNavigationObserver observer(web_contents());
+  ASSERT_TRUE(content::ExecJs(
+      content::ChildFrameAt(web_contents()->GetPrimaryMainFrame(), 0),
+      content::JsReplace(R"(
+      const a = document.createElement('a');
+      a.target = "_parent";
+      a.href = $1;
+      document.body.appendChild(a);
+      a.click();
+      )",
+                         blocked_url)));
+  observer.Wait();
+
+  // The navigation is blocked by the blocklist even though the initiator is
+  // same-origin with the destination.
+  histogram_tester.ExpectBucketCount(
+      "Actor.NavigationGating.GatingDecision2",
+      ExecutionEngine::GatingDecision::kBlockByStaticList, 1);
+}
+
+IN_PROC_BROWSER_TEST_F(ExecutionEngineOriginGatingBrowserTest,
                        NavigationWithOpaqueSourceOriginBlockedUnderWildcard) {
   base::HistogramTester histogram_tester;
   const GURL blocked_url =
@@ -1503,12 +1553,52 @@
 };
 
 IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
-                       ConfirmNavigationToNewSite_Denied) {
+                       ConfirmNavigation_SameOrigin) {
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/actor/link.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  OpenGlicAndCreateTask();
+
+  // Same origin source should never trigger gating
+  ASSERT_TRUE(content::ExecJs(web_contents(),
+                              content::JsReplace("setLink($1);", start_url)));
+  ClickTarget("#link", mojom::ActionResultCode::kOk);
+}
+
+IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
+                       ConfirmNavigation_CrossOrigin_Denied) {
   base::HistogramTester histogram_tester;
   const GURL start_url =
       embedded_https_test_server().GetURL("example.com", "/actor/link.html");
   const GURL same_site = embedded_https_test_server().GetURL(
       "other.example.com", "/actor/link.html");
+
+  ASSERT_TRUE(content::NavigateToURL(web_contents(), start_url));
+  OpenGlicAndCreateTask();
+
+  RunTestSequence(CreateMockWebClientRequest(
+      content::JsReplace(kHandleNavigationConfirmationTempl, false)));
+
+  // Cross origin but same site source should trigger when we're gating on
+  // origin
+  ASSERT_TRUE(content::ExecJs(web_contents(),
+                              content::JsReplace("setLink($1);", same_site)));
+  ClickTarget("#link",
+              should_gate_by_site()
+                  ? mojom::ActionResultCode::kOk
+                  : mojom::ActionResultCode::kTriggeredNavigationBlocked);
+
+  histogram_tester.ExpectBucketCount("Actor.NavigationGating.PermissionGranted",
+                                     false, should_gate_by_site() ? 0 : 1);
+}
+
+IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
+                       ConfirmNavigation_CrossSite_Denied) {
+  base::HistogramTester histogram_tester;
+  const GURL start_url =
+      embedded_https_test_server().GetURL("example.com", "/actor/link.html");
   const GURL cross_site =
       embedded_https_test_server().GetURL("foo.com", "/actor/blank.html");
 
@@ -1518,27 +1608,13 @@
   RunTestSequence(CreateMockWebClientRequest(
       content::JsReplace(kHandleNavigationConfirmationTempl, false)));
 
-  // Same origin should never trigger gating
-  ASSERT_TRUE(content::ExecJs(web_contents(),
-                              content::JsReplace("setLink($1);", start_url)));
-  ClickTarget("#link", mojom::ActionResultCode::kOk);
-
-  // Cross origin but same site should only trigger when we're gating on origin
-  ASSERT_TRUE(content::ExecJs(web_contents(),
-                              content::JsReplace("setLink($1);", same_site)));
-  ClickTarget("#link",
-              should_gate_by_site()
-                  ? mojom::ActionResultCode::kOk
-                  : mojom::ActionResultCode::kTriggeredNavigationBlocked);
-
-  // Cross site will always trigger gating
+  // Cross site source will always trigger gating
   ASSERT_TRUE(content::ExecJs(web_contents(),
                               content::JsReplace("setLink($1);", cross_site)));
   ClickTarget("#link", mojom::ActionResultCode::kTriggeredNavigationBlocked);
 
-  // Should log that permission was *denied* once.
   histogram_tester.ExpectBucketCount("Actor.NavigationGating.PermissionGranted",
-                                     false, should_gate_by_site() ? 1 : 2);
+                                     false, 1);
 }
 
 IN_PROC_BROWSER_TEST_P(ExecutionEngineSiteGatingBrowserTest,
diff --git a/components/actor/core/origin_checker_unittest.cc b/components/actor/core/origin_checker_unittest.cc
index dee6e3e..1f562cc 100644
--- a/components/actor/core/origin_checker_unittest.cc
+++ b/components/actor/core/origin_checker_unittest.cc
@@ -40,13 +40,14 @@
 }
 
 TEST(OriginCheckerTest, AllowNavigationTo_Opaque) {
+  const url::Origin example = url::Origin::Create(GURL(kExample));
   const url::Origin opaque;
   OriginChecker origin_checker;
   origin_checker.AllowNavigationTo(opaque,
                                    /*is_user_confirmed=*/false);
 
-  EXPECT_TRUE(origin_checker.IsNavigationAllowed(std::nullopt, opaque));
-  EXPECT_FALSE(origin_checker.IsNavigationAllowed(std::nullopt, url::Origin()));
+  EXPECT_TRUE(origin_checker.IsNavigationAllowed(example, opaque));
+  EXPECT_FALSE(origin_checker.IsNavigationAllowed(example, url::Origin()));
   EXPECT_FALSE(origin_checker.IsNavigationAllowed(
       opaque, url::Origin::Create(GURL(kExample))));
 }
@@ -117,16 +118,6 @@
       opaque, url::Origin::Create(GURL(kAnother))));
 }
 
-TEST(OriginCheckerTest, IsNavigationAllowed_OmittedInitiator) {
-  const url::Origin example = url::Origin::Create(GURL(kExample));
-  OriginChecker origin_checker;
-  origin_checker.AllowNavigationTo(example, /*is_user_confirmed=*/false);
-
-  EXPECT_TRUE(origin_checker.IsNavigationAllowed(std::nullopt, example));
-  EXPECT_FALSE(origin_checker.IsNavigationAllowed(
-      std::nullopt, url::Origin::Create(GURL(kAnother))));
-}
-
 TEST(OriginCheckerTest, ConfirmOrigin_Query) {
   const url::Origin origin = url::Origin::Create(GURL("https://example.com"));
 
@@ -141,12 +132,13 @@
 
 TEST(OriginCheckerTest, ConfirmOrigin_AllowsNavigation) {
   const url::Origin example = url::Origin::Create(GURL(kExample));
+  const url::Origin another_origin = url::Origin::Create(GURL(kAnother));
 
   OriginChecker origin_checker;
   origin_checker.AllowNavigationTo(example,
                                    /*is_user_confirmed=*/true);
 
-  EXPECT_TRUE(origin_checker.IsNavigationAllowed(std::nullopt, example));
+  EXPECT_TRUE(origin_checker.IsNavigationAllowed(another_origin, example));
 }
 
 TEST(OriginCheckerTest, ConfirmOrigin_Opaque) {
Loading diff…

Original Bug Report

reported by vm...@google.com

ActorNavigationThrottle policy bypass via same-origin subframe navigation

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 without the Chrome Security team. Please see go/chrome-ai-generated-security-bugs-faq for more information.

Overview: Logic flaws in ActorNavigationThrottle and OriginChecker allow same-origin navigations to skip MayActOnUrl safety checks. An attacker-controlled cross-origin subframe can exploit this by navigating the top-level frame to its own origin. This bypasses user policies, such as the Glic Allowlist, forcing the Actor to observe the attacker’s site.

Affected files:

  • chrome/browser/actor/actor_navigation_throttle.cc
  • chrome/browser/actor/site_policy.cc
  • components/actor/core/origin_checker.cc
  • chrome/browser/actor/execution_engine.cc

Estimated timestamp from git blame: 2026-02-06

Background

The ActorNavigationThrottle gates navigations in tabs controlled by a Glic Actor task, ensuring they comply with security policies defined in MayActOnUrl. These policies enforce restrictions such as the Glic Allowlist, SafeBrowsing checks, and Enterprise Policies, preventing the Actor from running on untrusted sites.

The Vulnerability

A logic flaw exists in ActorNavigationThrottle::WillStartOrRedirectRequest where it immediately returns PROCEED (skipping the MayActOnUrl check) if the navigation’s initiator_origin matches the destination navigation_url:

// chrome/browser/actor/actor_navigation_throttle.cc:204
if (initiator_origin && initiator_origin->IsSameOriginWith(navigation_url)) {
  // ...
  return content::NavigationThrottle::PROCEED;
}

A secondary flaw exists in OriginChecker::IsNavigationAllowed (called via ExecutionEngine::OnNavigationSensitiveUrlListChecked), which also allows navigations where the initiator matches the destination, bypassing user confirmation prompts.

Because the initiator_origin of a navigation triggered by a subframe is the origin of the subframe itself, a cross-origin subframe can navigate the top-level frame to its own origin and trigger this fast-path bypass.

Potential Attack Scenario

Note: These are suggested steps; we have not yet verified them with a working proof of concept.

  1. A user has an active Actor task running on a legitimate, allowlisted page (e.g., https://legit.com).
  2. The legit.com page embeds a cross-origin iframe controlled by an attacker (e.g., https://attacker.com). This domain is intentionally not on the Glic Allowlist.
  3. Inside the iframe, the attacker executes JavaScript to navigate the top frame to their own origin: top.location = 'https://attacker.com/payload'.
  4. The ActorNavigationThrottle intercepts the navigation. The initiator_origin (attacker.com) matches the navigation_url (attacker.com/payload).
  5. The throttle takes the fast-path and returns PROCEED, skipping the MayActOnUrl check that would have blocked the navigation due to the allowlist policy.
  6. The top frame commits to attacker.com. The Actor’s background observation loop (BuildActionsResultWithObservations) fetches the page context and sends the attacker’s payload to the LLM.

Impact

This vulnerability allows an attacker to bypass user-configured policies (like the Glic Allowlist or specific Enterprise blocklists) intended to restrict the domains the Actor can observe.

Note on limitations: It is not possible to use this bypass to navigate the Actor to sensitive internal IP addresses (e.g., 192.168.1.1) or victim sites. The bypass strictly requires the initiator_origin to match the destination. An attacker cannot use attacker.com to navigate to 192.168.1.1 under this fast-path, and the fallback MayActOnUrl explicitly blocks IP addresses. The only impact is that the Actor is forced to observe the attacker’s own site, potentially exposing the Actor to prompt injection payloads.

Suggested Fix

The assumption that any same-origin navigation is safe because the Actor or a trusted top-frame initiated it is incorrect when considering cross-origin subframes.

The same-origin fast-paths in ActorNavigationThrottle::WillStartOrRedirectRequest and OriginChecker::IsNavigationAllowed should be removed, ensuring that all navigations in the primary main frame, regardless of initiator, are subjected to MayActOnUrl and Optimization Guide safety checks.

Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09


Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.

Raised in root component due to access or custom field issues on 1707859

View on issue tracker