CVE-2026-87575
Overview
Background
- Transient user activation
- a short-lived per-frame flag, set by a real user interaction (click, keypress) and queried via
LocalFrame::HasTransientUserActivation, that gates privileged actions such as popups, downloads, and fullscreen. - `has_user_gesture_` (`ResourceRequest`)
- a per-request boolean, set with
SetHasUserGestureand read withHasUserGesture, that records whether the navigation request was driven by a user gesture and drives theSec-Fetch-User: ?1request header and browser-side user-activation gates. - `FrameLoadRequest`
- the Blink object bundling a navigation’s parameters (its
origin_window/initiator, requestor origin, referrer, andResourceRequest) that is passed intoFrameLoader::StartNavigation. - Initiator vs. target frame
- the initiator is the
origin_window’s frame that requested the navigation, while the target is the frame that will actually load the URL; for security the gesture must belong to the initiator, not the target.
Root Cause Analysis
FrameLoader::StartNavigation in frame_loader.cc unconditionally overwrote the request’s gesture flag with resource_request.SetHasUserGesture(LocalFrame::HasTransientUserActivation(frame_.Get())), where frame_.Get() is the target frame being navigated rather than the initiator. When a same-process cross-origin initiator that had no user activation navigated a target frame that did hold transient user activation, the request incorrectly inherited the target’s gesture, so Sec-Fetch-User: ?1 was attached and browser-process user-gesture gates were satisfied by a gesture that never belonged to the initiator. The invariant violated is that a navigation’s user-gesture attribution must reflect the initiating context’s activation, not any frame that happens to hold activation.
The fix removes the target-frame overwrite in StartNavigation and instead has the FrameLoadRequest constructor OR the request’s existing gesture with LocalFrame::HasTransientUserActivation(origin_window->GetFrame()), binding the gesture to the initiator at request-construction time. Because the capture now lives in the shared constructor (alongside RequestorOrigin, referrer, and source location), every current and future entry point is covered by design, and the removed per-call-site SetHasUserGesture calls become redundant.
FrameLoadRequest’s constructor keyed on origin_window, so attribution follows the initiator and can no longer be spoofed by the navigation target’s state.Attack Path
- Set up a same-process cross-origin pair An attacker page arranges to navigate a target frame that is same-process but cross-origin, where the target frame currently holds transient user activation (e.g. from a prior legitimate interaction) while the attacker’s initiator context has none.
- Trigger a gesture-free navigation of the target
The initiator programmatically navigates the target (e.g. via
window.open/location/framesrc), producing aResourceRequestwithhas_user_gesture_= false. - Target activation is misattributed
FrameLoader::StartNavigationoverwrites the flag with the target frame’sHasTransientUserActivation, flippinghas_user_gesture_to true for a request the initiator never activated. - User-gesture gates are bypassed
Sec-Fetch-User: ?1is attached and browser-process checks that require a genuine user gesture (e.g. popup/download/activation-gated flows) are satisfied, so actions that should be blocked proceed (inferred — the patch demonstrates the misattribution but does not enumerate the specific downstream gate reached).
Impact Assessment
Sec-Fetch-User: ?1 and bypassing browser-process gates that are meant to require a real user interaction. Preconditions are a same-process, cross-origin initiator/target relationship in which the target frame holds transient user activation while the initiator does not; the escalation is limited to defeating user-gesture authorization (inferred to enable gesture-gated actions such as popups or downloads), not memory corruption. The advisory classifies this as incorrect authorization in the Loader component and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifthird_party/blink/renderer/core/html/anchor_element_utils.cc |
modified | |
UserGestureNavigationTestWebFrameClientthird_party/blink/renderer/core/loader/frame_loader_test.cc |
modified | |
TEST_Fthird_party/blink/renderer/core/loader/frame_loader_test.cc |
modified |
Files Changed
third_party/blink/renderer/core/loader/frame_loader_test.ccthird_party/blink/renderer/core/html/html_anchor_element.ccthird_party/blink/renderer/core/loader/frame_load_request.ccthird_party/blink/renderer/core/frame/location.ccthird_party/blink/renderer/core/loader/frame_loader.ccthird_party/blink/renderer/core/loader/frame_load_request.hthird_party/blink/renderer/core/html/anchor_element_utils.ccthird_party/blink/renderer/core/svg/svg_a_element.ccthird_party/blink/renderer/core/html/html_frame_owner_element.ccthird_party/blink/renderer/core/mathml/mathml_anchor_element.ccthird_party/blink/renderer/core/loader/form_submission.ccthird_party/blink/renderer/core/frame/local_dom_window.cc
Audit Directions
- Target-vs-initiator confusionAudit any code that reads security-relevant state (
HasTransientUserActivation, origin, referrer policy) from the frame being acted upon rather than the frame that initiated the action. - Centralized-vs-scattered attributionLook for security attributes set independently at many call sites; scattered writes drift and one can key off the wrong object, so verify a single authoritative capture point exists (as
FrameLoadRequestnow provides). - Sec-Fetch / user-gesture propagationTrace every producer of
has_user_gesture_feedingSec-Fetch-Userand browser-side gesture gates to confirm the value is derived from the initiating context and cannot be inflated by a navigation target or unrelated frame.
Patch
From f44ff6bf5ee043562369b9f184234010246ed09e Mon Sep 17 00:00:00 2001
From: Takashi Toyoshima <toyoshim@chromium.org>
Date: Mon, 17 Aug 2026 11:52:49 -0700
Subject: [PATCH] Fix user gesture misattribution in same-process navigation
FrameLoader::StartNavigation unconditionally set the target LocalFrame's
transient user activation on ResourceRequest::has_user_gesture_.
When a same-process cross-origin initiator navigated a target frame that
had transient user activation, the resulting navigation request
incorrectly inherited the target frame's user gesture. This resulted in
Sec-Fetch-User: ?1 being incorrectly attached and bypassed user-gesture
gates in the browser process.
This CL:
1. Removes the target frame activation overwrite in FrameLoader.
2. Updates FrameLoadRequest's constructor to automatically capture the
initiator frame's transient user activation from origin_window
(aligning with RequestorOrigin, InitiatorStateToken, and Referrer),
preventing user gesture dropping across all current and future entry
points by design.
3. Cleans up redundant SetHasUserGesture() calls at individual call sites
now that FrameLoadRequest handles it automatically, and ensures
AnchorElementUtils::HandleDownloadAttribute sets user gesture for
<a download> downloads.
TAG=agy
CONV=96f28c27-e299-44c6-b29d-a2883ed49956
Bug: 540013886
Change-Id: I3b17b79ce66a99173ec22ad8aeab693b679bdbf8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8254506
Commit-Queue: Takashi Toyoshima <toyoshim@chromium.org>
Reviewed-by: Nate Chapin <japhet@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1680668}
---
diff --git a/third_party/blink/renderer/core/frame/local_dom_window.cc b/third_party/blink/renderer/core/frame/local_dom_window.cc
index e60957d..74cc1c0b 100644
--- a/third_party/blink/renderer/core/frame/local_dom_window.cc
+++ b/third_party/blink/renderer/core/frame/local_dom_window.cc
@@ -2533,9 +2533,6 @@
frame_request.GetResourceRequest().SetReferrerPolicy(
referrer.referrer_policy);
- bool has_user_gesture = LocalFrame::HasTransientUserActivation(GetFrame());
- frame_request.GetResourceRequest().SetHasUserGesture(has_user_gesture);
-
FrameTree::FindResult result =
GetFrame()->Tree().FindOrCreateFrameForNavigation(
frame_request, target.empty() ? AtomicString("_blank") : target);
diff --git a/third_party/blink/renderer/core/frame/location.cc b/third_party/blink/renderer/core/frame/location.cc
index 81b9b79..69e701e 100644
--- a/third_party/blink/renderer/core/frame/location.cc
+++ b/third_party/blink/renderer/core/frame/location.cc
@@ -333,9 +333,6 @@
}
ResourceRequestHead resource_request(completed_url);
- resource_request.SetHasUserGesture(
- LocalFrame::HasTransientUserActivation(incumbent_window->GetFrame()));
-
FrameLoadRequest request(incumbent_window, resource_request);
request.SetClientNavigationReason(ClientNavigationReason::kFrameNavigation);
WebFrameLoadType frame_load_type = WebFrameLoadType::kStandard;
diff --git a/third_party/blink/renderer/core/html/anchor_element_utils.cc b/third_party/blink/renderer/core/html/anchor_element_utils.cc
index 2faa155f..5e2c18e8 100644
--- a/third_party/blink/renderer/core/html/anchor_element_utils.cc
+++ b/third_party/blink/renderer/core/html/anchor_element_utils.cc
@@ -151,6 +151,7 @@
request.SetSuggestedFilename(download_attr);
request.SetRequestContext(mojom::blink::RequestContextType::DOWNLOAD);
request.SetRequestorOrigin(window->GetSecurityOrigin());
+ request.SetHasUserGesture(LocalFrame::HasTransientUserActivation(frame));
network::mojom::ReferrerPolicy referrer_policy = request.GetReferrerPolicy();
if (referrer_policy == network::mojom::ReferrerPolicy::kDefault) {
diff --git a/third_party/blink/renderer/core/html/html_anchor_element.cc b/third_party/blink/renderer/core/html/html_anchor_element.cc
index 512d24e..f647249f 100644
--- a/third_party/blink/renderer/core/html/html_anchor_element.cc
+++ b/third_party/blink/renderer/core/html/html_anchor_element.cc
@@ -494,8 +494,6 @@
link_relations_, GetDocument());
LocalFrame* frame = window->GetFrame();
- request.SetHasUserGesture(LocalFrame::HasTransientUserActivation(frame));
-
NavigationPolicy navigation_policy = NavigationPolicyFromEvent(&event);
// Respect the download attribute only if we can read the content, and the
diff --git a/third_party/blink/renderer/core/html/html_frame_owner_element.cc b/third_party/blink/renderer/core/html/html_frame_owner_element.cc
index eb18037..934a600 100644
--- a/third_party/blink/renderer/core/html/html_frame_owner_element.cc
+++ b/third_party/blink/renderer/core/html/html_frame_owner_element.cc
@@ -772,8 +772,6 @@
KURL url_to_request = url.IsNull() ? BlankUrl() : url;
ResourceRequestHead request(url_to_request);
request.SetReferrerPolicy(ReferrerPolicyAttribute());
- request.SetHasUserGesture(
- LocalFrame::HasTransientUserActivation(GetDocument().GetFrame()));
network::mojom::blink::TrustTokenParamsPtr trust_token_params =
ConstructTrustTokenParams();
diff --git a/third_party/blink/renderer/core/loader/form_submission.cc b/third_party/blink/renderer/core/loader/form_submission.cc
index 2a0243f5..dec95d4f 100644
--- a/third_party/blink/renderer/core/loader/form_submission.cc
+++ b/third_party/blink/renderer/core/loader/form_submission.cc
@@ -349,8 +349,6 @@
}
}
LocalFrame* form_local_frame = form->GetDocument().GetFrame();
- resource_request->SetHasUserGesture(
- LocalFrame::HasTransientUserActivation(form_local_frame));
resource_request->SetFormSubmission(true);
mojom::blink::TriggeringEventInfo triggering_event_info;
diff --git a/third_party/blink/renderer/core/loader/frame_load_request.cc b/third_party/blink/renderer/core/loader/frame_load_request.cc
index b10e999d..5bc9cd2f 100644
--- a/third_party/blink/renderer/core/loader/frame_load_request.cc
+++ b/third_party/blink/renderer/core/loader/frame_load_request.cc
@@ -13,6 +13,7 @@
#include "third_party/blink/renderer/core/events/current_input_event.h"
#include "third_party/blink/renderer/core/fileapi/public_url_manager.h"
#include "third_party/blink/renderer/core/frame/local_dom_window.h"
+#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/policy_container.h"
#include "third_party/blink/renderer/core/html/forms/html_form_element.h"
#include "third_party/blink/renderer/core/script_tools/script_tool_context.h"
@@ -116,6 +117,12 @@
SetReferrerForRequest(origin_window, resource_request_);
+ if (origin_window->GetFrame()) {
+ resource_request_.SetHasUserGesture(
+ resource_request_.HasUserGesture() ||
+ LocalFrame::HasTransientUserActivation(origin_window->GetFrame()));
+ }
+
SetSourceLocation(CaptureSourceLocation(origin_window));
// If a Script Tool (WebMCP tool) execution is currently active in the
diff --git a/third_party/blink/renderer/core/loader/frame_load_request.h b/third_party/blink/renderer/core/loader/frame_load_request.h
index 1f9520bd..1b4367c 100644
--- a/third_party/blink/renderer/core/loader/frame_load_request.h
+++ b/third_party/blink/renderer/core/loader/frame_load_request.h
@@ -60,6 +60,9 @@
STACK_ALLOCATED();
public:
+ // Automatically populates resource_request.has_user_gesture from
+ // `origin_window`'s transient user activation state when `origin_window` is
+ // non-null (defaults to false otherwise, unless already set).
FrameLoadRequest(LocalDOMWindow* origin_window, const ResourceRequest&);
FrameLoadRequest(LocalDOMWindow* origin_window, const ResourceRequestHead&);
FrameLoadRequest(const FrameLoadRequest&) = delete;
diff --git a/third_party/blink/renderer/core/loader/frame_loader.cc b/third_party/blink/renderer/core/loader/frame_loader.cc
index 08bd9c2..cd5f06b 100644
--- a/third_party/blink/renderer/core/loader/frame_loader.cc
+++ b/third_party/blink/renderer/core/loader/frame_loader.cc
@@ -715,9 +715,6 @@
url.GetString().Utf8(), "load_type",
static_cast<int>(frame_load_type));
- resource_request.SetHasUserGesture(
- LocalFrame::HasTransientUserActivation(frame_.Get()));
-
if (!AllowRequestForThisFrame(request))
return;
diff --git a/third_party/blink/renderer/core/mathml/mathml_anchor_element.cc b/third_party/blink/renderer/core/mathml/mathml_anchor_element.cc
index 3e3dcac..200ad7ee 100644
--- a/third_party/blink/renderer/core/mathml/mathml_anchor_element.cc
+++ b/third_party/blink/renderer/core/mathml/mathml_anchor_element.cc
@@ -132,7 +132,6 @@
request, FastGetAttribute(html_names::kReferrerpolicyAttr),
link_relations_, GetDocument());
- request.SetHasUserGesture(LocalFrame::HasTransientUserActivation(frame));
NavigationPolicy navigation_policy = NavigationPolicyFromEvent(&event);
if (FastHasAttribute(html_names::kDownloadAttr) &&
diff --git a/third_party/blink/renderer/core/svg/svg_a_element.cc b/third_party/blink/renderer/core/svg/svg_a_element.cc
index f54e5ce..d321cb6 100644
--- a/third_party/blink/renderer/core/svg/svg_a_element.cc
+++ b/third_party/blink/renderer/core/svg/svg_a_element.cc
@@ -163,8 +163,6 @@
request, FastGetAttribute(svg_names::kReferrerpolicyAttr),
link_relations_, GetDocument());
- request.SetHasUserGesture(LocalFrame::HasTransientUserActivation(frame));
-
// Respect the download attribute only if we can read the content, and the
// event is not an alt-click or similar.
if (FastHasAttribute(svg_names::kDownloadAttr) &&
diff --git a/third_party/blink/renderer/core/loader/frame_loader_test.cc b/third_party/blink/renderer/core/loader/frame_loader_test.cc
index fd9c455..7a915a29 100644
--- a/third_party/blink/renderer/core/loader/frame_loader_test.cc
+++ b/third_party/blink/renderer/core/loader/frame_loader_test.cc
@@ -18,6 +18,7 @@
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/policy_container.h"
#include "third_party/blink/renderer/core/html/html_anchor_element.h"
+#include "third_party/blink/renderer/core/loader/frame_load_request.h"
#include "third_party/blink/renderer/core/page/chrome_client_impl.h"
#include "third_party/blink/renderer/core/testing/mock_policy_container_host.h"
#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
@@ -325,4 +326,87 @@
SchemeRegistry::RemoveURLSchemeAsDirectLaunchForTest(kScheme);
}
+class UserGestureNavigationTestWebFrameClient
+ : public frame_test_helpers::TestWebFrameClient {
+ public:
+ void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override {
+ last_has_user_gesture_ = info->url_request.HasUserGesture();
+ }
+
+ std::optional<bool> last_has_user_gesture() const {
+ return last_has_user_gesture_;
+ }
+
+ private:
+ std::optional<bool> last_has_user_gesture_;
+};
+
+TEST_F(FrameLoaderTest, StartNavigationDoesNotSpoofTargetUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* target_frame = helper.LocalMainFrame()->GetFrame();
+
+ // Target frame has transient user activation.
+ LocalFrame::NotifyUserActivation(
+ target_frame, mojom::UserActivationNotificationType::kTest);
+ ASSERT_TRUE(LocalFrame::HasTransientUserActivation(target_frame));
+
+ // Initiator frame load request without user activation.
+ ResourceRequest resource_request(KURL("https://example.com/foo.html"));
+ resource_request.SetHasUserGesture(false);
+ FrameLoadRequest request(nullptr, resource_request);
+
+ target_frame->Loader().StartNavigation(request);
+
+ ASSERT_TRUE(client.last_has_user_gesture().has_value());
+ EXPECT_FALSE(client.last_has_user_gesture().value());
+}
+
+TEST_F(FrameLoaderTest, StartNavigationPropagatesInitiatorUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* target_frame = helper.LocalMainFrame()->GetFrame();
+
+ // Target frame has NO transient user activation.
+ ASSERT_FALSE(LocalFrame::HasTransientUserActivation(target_frame));
+
+ // Initiator frame load request with user activation.
+ ResourceRequest resource_request(KURL("https://example.com/foo.html"));
+ resource_request.SetHasUserGesture(true);
+ FrameLoadRequest request(nullptr, resource_request);
+
+ target_frame->Loader().StartNavigation(request);
+
+ ASSERT_TRUE(client.last_has_user_gesture().has_value());
+ EXPECT_TRUE(client.last_has_user_gesture().value());
+}
+
+TEST_F(FrameLoaderTest, FrameLoadRequestCapturesInitiatorUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* initiator_frame = helper.LocalMainFrame()->GetFrame();
+
+ // 1. Without user activation on initiator.
+ ASSERT_FALSE(LocalFrame::HasTransientUserActivation(initiator_frame));
+ FrameLoadRequest request1(
+ initiator_frame->DomWindow(),
+ ResourceRequest(KURL("https://example.com/foo.html")));
+ EXPECT_FALSE(request1.GetResourceRequest().HasUserGesture());
+
+ // 2. With user activation on initiator.
+ LocalFrame::NotifyUserActivation(
+ initiator_frame, mojom::UserActivationNotificationType::kTest);
+ ASSERT_TRUE(LocalFrame::HasTransientUserActivation(initiator_frame));
+ FrameLoadRequest request2(
+ initiator_frame->DomWindow(),
+ ResourceRequest(KURL("https://example.com/foo.html")));
+ EXPECT_TRUE(request2.GetResourceRequest().HasUserGesture());
+}
+
} // namespace blink
Regression Test / PoC
diff --git a/third_party/blink/renderer/core/loader/frame_loader_test.cc b/third_party/blink/renderer/core/loader/frame_loader_test.cc
index fd9c455..7a915a29 100644
--- a/third_party/blink/renderer/core/loader/frame_loader_test.cc
+++ b/third_party/blink/renderer/core/loader/frame_loader_test.cc
@@ -18,6 +18,7 @@
#include "third_party/blink/renderer/core/frame/local_frame.h"
#include "third_party/blink/renderer/core/frame/policy_container.h"
#include "third_party/blink/renderer/core/html/html_anchor_element.h"
+#include "third_party/blink/renderer/core/loader/frame_load_request.h"
#include "third_party/blink/renderer/core/page/chrome_client_impl.h"
#include "third_party/blink/renderer/core/testing/mock_policy_container_host.h"
#include "third_party/blink/renderer/core/testing/sim/sim_request.h"
@@ -325,4 +326,87 @@
SchemeRegistry::RemoveURLSchemeAsDirectLaunchForTest(kScheme);
}
+class UserGestureNavigationTestWebFrameClient
+ : public frame_test_helpers::TestWebFrameClient {
+ public:
+ void BeginNavigation(std::unique_ptr<WebNavigationInfo> info) override {
+ last_has_user_gesture_ = info->url_request.HasUserGesture();
+ }
+
+ std::optional<bool> last_has_user_gesture() const {
+ return last_has_user_gesture_;
+ }
+
+ private:
+ std::optional<bool> last_has_user_gesture_;
+};
+
+TEST_F(FrameLoaderTest, StartNavigationDoesNotSpoofTargetUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* target_frame = helper.LocalMainFrame()->GetFrame();
+
+ // Target frame has transient user activation.
+ LocalFrame::NotifyUserActivation(
+ target_frame, mojom::UserActivationNotificationType::kTest);
+ ASSERT_TRUE(LocalFrame::HasTransientUserActivation(target_frame));
+
+ // Initiator frame load request without user activation.
+ ResourceRequest resource_request(KURL("https://example.com/foo.html"));
+ resource_request.SetHasUserGesture(false);
+ FrameLoadRequest request(nullptr, resource_request);
+
+ target_frame->Loader().StartNavigation(request);
+
+ ASSERT_TRUE(client.last_has_user_gesture().has_value());
+ EXPECT_FALSE(client.last_has_user_gesture().value());
+}
+
+TEST_F(FrameLoaderTest, StartNavigationPropagatesInitiatorUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* target_frame = helper.LocalMainFrame()->GetFrame();
+
+ // Target frame has NO transient user activation.
+ ASSERT_FALSE(LocalFrame::HasTransientUserActivation(target_frame));
+
+ // Initiator frame load request with user activation.
+ ResourceRequest resource_request(KURL("https://example.com/foo.html"));
+ resource_request.SetHasUserGesture(true);
+ FrameLoadRequest request(nullptr, resource_request);
+
+ target_frame->Loader().StartNavigation(request);
+
+ ASSERT_TRUE(client.last_has_user_gesture().has_value());
+ EXPECT_TRUE(client.last_has_user_gesture().value());
+}
+
+TEST_F(FrameLoaderTest, FrameLoadRequestCapturesInitiatorUserGesture) {
+ UserGestureNavigationTestWebFrameClient client;
+ frame_test_helpers::WebViewHelper helper;
+ helper.Initialize(&client);
+
+ LocalFrame* initiator_frame = helper.LocalMainFrame()->GetFrame();
+
+ // 1. Without user activation on initiator.
+ ASSERT_FALSE(LocalFrame::HasTransientUserActivation(initiator_frame));
+ FrameLoadRequest request1(
+ initiator_frame->DomWindow(),
+ ResourceRequest(KURL("https://example.com/foo.html")));
+ EXPECT_FALSE(request1.GetResourceRequest().HasUserGesture());
+
+ // 2. With user activation on initiator.
+ LocalFrame::NotifyUserActivation(
+ initiator_frame, mojom::UserActivationNotificationType::kTest);
+ ASSERT_TRUE(LocalFrame::HasTransientUserActivation(initiator_frame));
+ FrameLoadRequest request2(
+ initiator_frame->DomWindow(),
+ ResourceRequest(KURL("https://example.com/foo.html")));
+ EXPECT_TRUE(request2.GetResourceRequest().HasUserGesture());
+}
+
} // namespace blink