CVE-2026-11184
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/actor/actor_navigation_throttle.cc |
modified |
Files Changed
chrome/browser/actor/actor_navigation_throttle.ccchrome/browser/actor/execution_engine.ccchrome/browser/actor/execution_engine_origin_gating_browsertest.cc
Patch
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.
Regression Test / PoC
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) {
Original Bug Report
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.ccchrome/browser/actor/site_policy.cccomponents/actor/core/origin_checker.ccchrome/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.
- A user has an active Actor task running on a legitimate, allowlisted page (e.g.,
https://legit.com). - The
legit.compage embeds a cross-origin iframe controlled by an attacker (e.g.,https://attacker.com). This domain is intentionally not on the Glic Allowlist. - Inside the iframe, the attacker executes JavaScript to navigate the top frame to their own origin:
top.location = 'https://attacker.com/payload'. - The
ActorNavigationThrottleintercepts the navigation. Theinitiator_origin(attacker.com) matches thenavigation_url(attacker.com/payload). - The throttle takes the fast-path and returns
PROCEED, skipping theMayActOnUrlcheck that would have blocked the navigation due to the allowlist policy. - 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