Medium chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Input
DescriptionIncorrect authorization in Input
ComponentInput
Sub-componentIntersectionObserver
Bug ClassAuthorization Error
Tracker517649552
Fix commit59f7e060fac5 (chromium/src) +103/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Files Changed

  • third_party/blink/renderer/core/frame/frame.cc
  • third_party/blink/renderer/core/input/event_handler_test.cc
  • third_party/blink/renderer/core/input/event_handling_util.cc
  • third_party/blink/renderer/core/frame/frame.h
From 59f7e060fac50d61df2080ac72110aa56fb69e93 Mon Sep 17 00:00:00 2001
From: Stefan Zager <[email protected]>
Date: Wed, 09 Sep 2026 12:00:06 -0700
Subject: [PATCH] [IntersectionObserver] Harden cross-origin check for suppressing input

Any cross-origin intermediate frame could maliciously move a target
frame, so check for that.

Fixed: 517649552
Change-Id: I7950aa2d20ea06e2ed21c6e6819f5cbb57f54e1b
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8373066
Reviewed-by: Scott Haseley <[email protected]>
Commit-Queue: Stefan Zager <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1694786}
---
diff --git a/third_party/blink/renderer/core/frame/frame.cc b/third_party/blink/renderer/core/frame/frame.cc
index 6b2e005b..7d612562 100644
--- a/third_party/blink/renderer/core/frame/frame.cc
+++ b/third_party/blink/renderer/core/frame/frame.cc
@@ -238,6 +238,26 @@
   return !security_origin->CanAccess(parent_security_origin);
 }
 
+bool Frame::HasCrossOriginAncestorFrame() const {
+  DCHECK(GetSecurityContext());
+  if (IsInFencedFrameTree()) {
+    return true;
+  }
+  if (IsMainFrame()) {
+    return false;
+  }
+  const SecurityOrigin* security_origin =
+      GetSecurityContext()->GetSecurityOrigin();
+  for (const Frame* f = this; f; f = f->Tree().Parent()) {
+    const SecurityOrigin* ancestor_security_origin =
+        f->GetSecurityContext()->GetSecurityOrigin();
+    if (!security_origin->CanAccess(ancestor_security_origin)) {
+      return true;
+    }
+  }
+  return false;
+}
+
 HTMLFrameOwnerElement* Frame::DeprecatedLocalOwner() const {
   return DynamicTo<HTMLFrameOwnerElement>(owner_.Get());
 }
diff --git a/third_party/blink/renderer/core/frame/frame.h b/third_party/blink/renderer/core/frame/frame.h
index 3a90f88d..1dce5c54 100644
--- a/third_party/blink/renderer/core/frame/frame.h
+++ b/third_party/blink/renderer/core/frame/frame.h
@@ -180,6 +180,10 @@
   // See |IsCrossOriginToNearestMainFrame| for important notes.
   bool IsCrossOriginToParentOrOuterDocument() const;
 
+  // Returns true if this frame is cross-origin to any of its ancestors up to
+  // the outermost main frame.
+  bool HasCrossOriginAncestorFrame() const;
+
   FrameOwner* Owner() const;
   void SetOwner(FrameOwner*);
   HTMLFrameOwnerElement* DeprecatedLocalOwner() const;
diff --git a/third_party/blink/renderer/core/input/event_handling_util.cc b/third_party/blink/renderer/core/input/event_handling_util.cc
index 51615ed..93f008f 100644
--- a/third_party/blink/renderer/core/input/event_handling_util.cc
+++ b/third_party/blink/renderer/core/input/event_handling_util.cc
@@ -115,7 +115,7 @@
 bool ShouldDiscardEventTargetingFrame(const WebInputEvent& event,
                                       const LocalFrame& frame) {
   // Under certain circumstances, we discard input events to a recently moved
-  // cross-origin iframe:
+  // iframe that is cross-origin to any of its ancestor frames:
   //
   // - If javascript in the frame's context is using
   //   IntersectionObserver V2 to track the visibility of an element, we
@@ -123,7 +123,7 @@
   //   preventing mis-clicks. This behavior was added by:
   //   https://chromium-review.googlesource.com/c/chromium/src/+/1686824
   //
-  // - The feature flag kDiscardEventsToRecentlyMovedFrames expands this
+  // - The feature flag kDiscardInputEventsToRecentlyMovedFrames expands this
   //   behavior to all cross-origin iframes, regardless of whether they are
   //   using IntersectionObserver V2.
   //
@@ -133,9 +133,8 @@
   // event.GetModifiers(). For in-process iframes, frame movement is tracked
   // during lifecycle updates, in FrameView::UpdateViewportIntersection, and
   // propagated via FrameView::RectInParentIsStable.
-
   bool should_discard = false;
-  if (frame.IsCrossOriginToOutermostMainFrame()) {
+  if (frame.HasCrossOriginAncestorFrame()) {
     if (frame.NeedsOcclusionTracking()) {
       should_discard =
           (event.GetModifiers() &
diff --git a/third_party/blink/renderer/core/input/event_handler_test.cc b/third_party/blink/renderer/core/input/event_handler_test.cc
index dc7d7e6..f08d22a3 100644
--- a/third_party/blink/renderer/core/input/event_handler_test.cc
+++ b/third_party/blink/renderer/core/input/event_handler_test.cc
@@ -3737,6 +3737,82 @@
               WebFeature::kInputEventToRecentlyMovedIframeMistakenlyDiscarded));
 }
 
+TEST_F(EventHandlerSimTest,
+       DiscardEventsToRecentlyMovedNestedSameOriginIframe) {
+  base::FieldTrialParams field_trial_params;
+  field_trial_params["time_ms"] = "500";
+  field_trial_params["distance_factor"] = "0.5";
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeatureWithParameters(
+      features::kDiscardInputEventsToRecentlyMovedFrames, field_trial_params);
+  // To make the new `time_ms` and `distance_factor` affect the test,
+  // reset the cached values inside FrameVisualProperties.
+  FrameVisualProperties::ResetForTesting();
+
+  WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+  SimRequest main_resource("https://example.com/test.html", "text/html");
+  SimRequest intermediate_resource("https://cross-origin.com/intermediate.html",
+                                   "text/html");
+  SimRequest inner_resource("https://example.com/inner.html", "text/html");
+  LoadURL("https://example.com/test.html");
+  main_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>body { margin: 0 } iframe { border: none }</style>
+    <iframe id='outer' src='https://cross-origin.com/intermediate.html'
+            style='position:absolute;top:0;left:0;width:500px;height:300px'>
+    </iframe>
+  )HTML");
+  intermediate_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>body { margin: 0 } iframe { border: none }</style>
+    <iframe id='inner' src='https://example.com/inner.html'
+            style='position:absolute;top:0;left:0;width:200px;height:100px'>
+    </iframe>
+  )HTML");
+  inner_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <div>Hello, world!</div>
+  )HTML");
+
+  // The first BeginFrame() sets the last known position of each iframe. The
+  // second BeginFrame(), after a delay with no layout changes, will mark the
+  // iframes as having a stable position.
+  Compositor().BeginFrame();
+  GetDocument().GetFrame()->View()->ScheduleAnimation();
+  Compositor().BeginFrame(0.5);
+
+  auto* outer = To<HTMLIFrameElement>(
+      GetDocument().getElementById(AtomicString("outer")));
+  ASSERT_TRUE(outer);
+  Document* intermediate_document = outer->contentDocument();
+  ASSERT_TRUE(intermediate_document);
+  Element* inner = intermediate_document->getElementById(AtomicString("inner"));
+  ASSERT_TRUE(inner);
+
+  // The inner iframe is same-origin with the main frame, but its parent is
+  // cross-origin. Moving the inner iframe past the threshold should cause
+  // events to be discarded.
+  inner->SetInlineStyleProperty(CSSPropertyID::kLeft, "200px");
+  Compositor().BeginFrame();
+  base::TimeTicks event_time =
+      Compositor().LastFrameTime() + base::Milliseconds(400);
+
+  WebInputEventResult event_result =
+      GetDocument().GetFrame()->GetEventHandler().HandleMousePressEvent(
+          WebMouseEvent(WebInputEvent::Type::kMouseDown, gfx::PointF(250, 50),
+                        gfx::PointF(250, 50),
+                        WebPointerProperties::Button::kLeft, 1,
+                        WebInputEvent::Modifiers::kLeftButtonDown, event_time));
+  EXPECT_EQ(event_result, WebInputEventResult::kHandledSuppressed);
+  event_result =
+      GetDocument().GetFrame()->GetEventHandler().HandleMouseReleaseEvent(
+          WebMouseEvent(WebInputEvent::Type::kMouseUp, gfx::PointF(250, 50),
+                        gfx::PointF(250, 50),
+                        WebPointerProperties::Button::kLeft, 1,
+                        WebInputEvent::kNoModifiers, event_time));
+  EXPECT_EQ(event_result, WebInputEventResult::kHandledSuppressed);
+}
+
 // Tests that click.pointerId is valid for a gesture tap for which no low-level
 // pointer events (pointerdown, pointermove, pointerup etc) are sent from the
 // browser to the renderer because of absence of relevant event listeners.
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/third_party/blink/renderer/core/input/event_handler_test.cc b/third_party/blink/renderer/core/input/event_handler_test.cc
index dc7d7e6..f08d22a3 100644
--- a/third_party/blink/renderer/core/input/event_handler_test.cc
+++ b/third_party/blink/renderer/core/input/event_handler_test.cc
@@ -3737,6 +3737,82 @@
               WebFeature::kInputEventToRecentlyMovedIframeMistakenlyDiscarded));
 }
 
+TEST_F(EventHandlerSimTest,
+       DiscardEventsToRecentlyMovedNestedSameOriginIframe) {
+  base::FieldTrialParams field_trial_params;
+  field_trial_params["time_ms"] = "500";
+  field_trial_params["distance_factor"] = "0.5";
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitAndEnableFeatureWithParameters(
+      features::kDiscardInputEventsToRecentlyMovedFrames, field_trial_params);
+  // To make the new `time_ms` and `distance_factor` affect the test,
+  // reset the cached values inside FrameVisualProperties.
+  FrameVisualProperties::ResetForTesting();
+
+  WebView().MainFrameViewWidget()->Resize(gfx::Size(800, 600));
+  SimRequest main_resource("https://example.com/test.html", "text/html");
+  SimRequest intermediate_resource("https://cross-origin.com/intermediate.html",
+                                   "text/html");
+  SimRequest inner_resource("https://example.com/inner.html", "text/html");
+  LoadURL("https://example.com/test.html");
+  main_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>body { margin: 0 } iframe { border: none }</style>
+    <iframe id='outer' src='https://cross-origin.com/intermediate.html'
+            style='position:absolute;top:0;left:0;width:500px;height:300px'>
+    </iframe>
+  )HTML");
+  intermediate_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <style>body { margin: 0 } iframe { border: none }</style>
+    <iframe id='inner' src='https://example.com/inner.html'
+            style='position:absolute;top:0;left:0;width:200px;height:100px'>
+    </iframe>
+  )HTML");
+  inner_resource.Complete(R"HTML(
+    <!DOCTYPE html>
+    <div>Hello, world!</div>
+  )HTML");
+
+  // The first BeginFrame() sets the last known position of each iframe. The
+  // second BeginFrame(), after a delay with no layout changes, will mark the
+  // iframes as having a stable position.
+  Compositor().BeginFrame();
+  GetDocument().GetFrame()->View()->ScheduleAnimation();
+  Compositor().BeginFrame(0.5);
+
+  auto* outer = To<HTMLIFrameElement>(
+      GetDocument().getElementById(AtomicString("outer")));
+  ASSERT_TRUE(outer);
+  Document* intermediate_document = outer->contentDocument();
+  ASSERT_TRUE(intermediate_document);
+  Element* inner = intermediate_document->getElementById(AtomicString("inner"));
+  ASSERT_TRUE(inner);
+
+  // The inner iframe is same-origin with the main frame, but its parent is
+  // cross-origin. Moving the inner iframe past the threshold should cause
+  // events to be discarded.
+  inner->SetInlineStyleProperty(CSSPropertyID::kLeft, "200px");
+  Compositor().BeginFrame();
+  base::TimeTicks event_time =
+      Compositor().LastFrameTime() + base::Milliseconds(400);
+
+  WebInputEventResult event_result =
+      GetDocument().GetFrame()->GetEventHandler().HandleMousePressEvent(
+          WebMouseEvent(WebInputEvent::Type::kMouseDown, gfx::PointF(250, 50),
+                        gfx::PointF(250, 50),
+                        WebPointerProperties::Button::kLeft, 1,
+                        WebInputEvent::Modifiers::kLeftButtonDown, event_time));
+  EXPECT_EQ(event_result, WebInputEventResult::kHandledSuppressed);
+  event_result =
+      GetDocument().GetFrame()->GetEventHandler().HandleMouseReleaseEvent(
+          WebMouseEvent(WebInputEvent::Type::kMouseUp, gfx::PointF(250, 50),
+                        gfx::PointF(250, 50),
+                        WebPointerProperties::Button::kLeft, 1,
+                        WebInputEvent::kNoModifiers, event_time));
+  EXPECT_EQ(event_result, WebInputEventResult::kHandledSuppressed);
+}
+
 // Tests that click.pointerId is valid for a gesture tap for which no low-level
 // pointer events (pointerdown, pointermove, pointerup etc) are sent from the
 // browser to the renderer because of absence of relevant event listeners.
Loading diff…

Original Bug Report

reported by [email protected]

Clickjacking guard bypass in nested same-origin-to-top frame under cross-origin parent

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. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The clickjacking guard can potentially be bypassed when a same-origin-to-top frame is nested under a cross-origin parent. Because the origin check only compares the target frame to the outermost main frame, the guard is skipped entirely for the same-origin leaf frame. This could allow a cross-origin parent to reposition the victim frame under the cursor without input events being discarded.

Affected files:

  • third_party/blink/renderer/core/input/event_handling_util.cc

Estimated timestamp from git blame: 2019-07-03

Summary

A potential security issue in Blink’s input event handling logic can allow a cross-origin intermediate iframe to bypass the kDiscardInputEventsToRecentlyMovedFrames and IntersectionObserver-V2 input-discard mitigations. This bypass can enable clickjacking or tapjacking attacks against a nested frame that is same-origin with the outermost main frame, even when it is embedded inside a hostile cross-origin frame (topology: A (top, same-origin) -> B (attacker, cross-origin) -> A2 (victim, same-origin)).

Root Cause Analysis

In third_party/blink/renderer/core/input/event_handling_util.cc, the helper function ShouldDiscardEventTargetingFrame verifies whether input events targeting a frame should be discarded due to recent frame movement:

bool should_discard = false;
if (frame.IsCrossOriginToOutermostMainFrame()) {
  if (frame.NeedsOcclusionTracking()) {
    should_discard =
        (event.GetModifiers() & WebInputEvent::kTargetFrameMovedRecentlyForIOv2) ||
        !frame.View()->RectInParentIsStableForIOv2(event.TimeStamp());
  } else if (base::FeatureList::IsEnabled(
                 features::kDiscardInputEventsToRecentlyMovedFrames)) {
    should_discard =
        (event.GetModifiers() & WebInputEvent::kTargetFrameMovedRecently) ||
        !frame.View()->RectInParentIsStable(event.TimeStamp());
  }
}

The entire check is gated on frame.IsCrossOriginToOutermostMainFrame(). This method is defined in third_party/blink/renderer/core/frame/frame.cc as:

bool Frame::IsCrossOriginToNearestMainFrame() const {
  const SecurityOrigin* security_origin =
      GetSecurityContext()->GetSecurityOrigin();
  return !security_origin->CanAccess(
      Tree().Top().GetSecurityContext()->GetSecurityOrigin());
}
bool Frame::IsCrossOriginToOutermostMainFrame() const {
  return IsCrossOriginToNearestMainFrame() || IsInFencedFrameTree();
}

If the victim leaf frame A2 is same-origin with the outermost main frame A, then A2.IsCrossOriginToOutermostMainFrame() returns false. As a result, the recently-moved-frame check is skipped entirely.

However, the relevant threat actor is the intermediate ancestor frame B (which is cross-origin to both A and A2). Because B controls the <iframe> element hosting A2, it can reposition A2 to intercept user input. Since the input-discard gate is only evaluated for the deepest hit-test leaf frame (A2), not for the intermediate frames, the input-discard check is never successfully triggered for B and is incorrectly skipped for A2.

Potential Exploitation Path

As our tooling agent does not yet have the ability to run code, these are potential/suggested steps that an attacker could follow to trigger the vulnerability:

  1. Serve a top-level page A (e.g., https://a.example.com) that embeds a cross-origin iframe B (e.g., https://b.example.com/attacker.html).
  2. The intermediate iframe B embeds A2 (e.g., https://a.example.com/authorize.html), which hosts a sensitive action button and registers an IntersectionObserver with {trackVisibility: true}. A2 does not restrict framing via CSP frame-ancestors or X-Frame-Options.
  3. Frame B initially hides A2 or positions it off-screen, displays bait UI, and upon receiving a user interaction or cursor movement, instantly relocates A2’s iframe directly under the user’s cursor.
  4. When the user clicks, the event is delivered to A2 instead of being suppressed, despite the iframe having been moved within the minimum stability interval.

Suggested Fix

Update the origin gate in ShouldDiscardEventTargetingFrame to check whether the frame is cross-origin to its parent (e.g., using IsCrossOriginToParentOrOuterDocument()), or walk up the ancestor tree to verify if any frame between the target and the outermost main frame is cross-origin to the target.

Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

View on issue tracker