CVE-2026-87495
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ScrollRectToVisibleClampBrowserTestBasecontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified | |
ScrollRectToVisibleClampBrowserTestcontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified | |
ScrollRectToVisibleClampHighDPIBrowserTestcontent/browser/renderer_host/scroll_into_view_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/renderer_host/scroll_into_view_browsertest.cc
Patch
From 708c49844359df4225688e562ee20ab478cd8d70 Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Thu, 30 Jul 2026 08:51:27 -0700
Subject: [PATCH] [agy][content] Clamp ScrollRectToVisible rect
ScrollRectToVisibleInParentFrame receives a rect in the sending frame's
local coordinate space and forwards it to the embedder. Intersect the
rect with the frame's extent (as last reported by the embedder via
FrameVisualProperties) before forwarding.
This prevents cross-origin layout-size leak.
RectFromChildIsClampedToFrameBounds
Fixed: 517122234
Test: ScrollRectToVisibleClampBrowserTest.
Change-Id: I3b86a8622bb8393da93dd27cbae1e310457376e8
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8107015
Reviewed-by: Ken Buchanan <kenrb@chromium.org>
Commit-Queue: Tzarial <zork@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1671110}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 608401e6..d5f8e0b 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -136,6 +136,7 @@
#include "content/browser/renderer_host/clipboard_host_impl.h"
#include "content/browser/renderer_host/code_cache_host_impl.h"
#include "content/browser/renderer_host/cookie_utils.h"
+#include "content/browser/renderer_host/cross_process_frame_connector.h"
#include "content/browser/renderer_host/dip_util.h"
#include "content/browser/renderer_host/frame_tree.h"
#include "content/browser/renderer_host/frame_tree_node.h"
@@ -9783,7 +9784,20 @@
return;
}
- proxy->ScrollRectToVisible(rect_to_scroll, std::move(params));
+ // The rect is expressed in the sending frame's local coordinate space and
+ // is consumed by the embedder relative to the frame's content area. Clamp
+ // it to the frame's extent (as last reported by the embedder) so that the
+ // request only ever targets a region inside the frame.
+ gfx::RectF clamped_rect = rect_to_scroll;
+ if (CrossProcessFrameConnector* connector =
+ proxy->cross_process_frame_connector()) {
+ gfx::RectF frame_bounds(gfx::SizeF(connector->GetLocalFrameSizeInPixels()));
+ if (!frame_bounds.IsEmpty()) {
+ clamped_rect.Intersect(frame_bounds);
+ }
+ }
+
+ proxy->ScrollRectToVisible(clamped_rect, std::move(params));
}
void RenderFrameHostImpl::BubbleLogicalScrollInParentFrame(
diff --git a/content/browser/renderer_host/scroll_into_view_browsertest.cc b/content/browser/renderer_host/scroll_into_view_browsertest.cc
index 52cc9562..254b6ec 100644
--- a/content/browser/renderer_host/scroll_into_view_browsertest.cc
+++ b/content/browser/renderer_host/scroll_into_view_browsertest.cc
@@ -29,6 +29,7 @@
#include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/scroll/scroll_into_view_params.mojom.h"
#include "third_party/re2/src/re2/re2.h"
+#include "ui/display/display_switches.h"
#include "ui/events/event_constants.h"
#include "url/gurl.h"
@@ -977,6 +978,140 @@
RunTest();
}
+class ScrollRectToVisibleClampBrowserTestBase
+ : public ScrollIntoViewBrowserTestBase {
+ protected:
+ bool IsForceLocalFrames() const override { return false; }
+ bool IsWritingModeLTR() const override { return true; }
+ TestInvokeMethod GetInvokeMethod() const override { return kJavaScript; }
+
+ void VerifyRectFromChildIsClamped();
+ void VerifyValidScrollIsNotClamped();
+};
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyRectFromChildIsClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ // The child frame's location and size in the root document, at the initial
+ // (0,0) scroll position established by SetupTest.
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ // The page is built so that there is scrollable extent well beyond the
+ // child frame; otherwise the assertion below isn't meaningful.
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, 10000000, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ // The resulting scroll must stay within the child frame's extent in the
+ // embedder rather than reaching the document's maximum scroll offset.
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ EXPECT_NEAR(scroll_y, child_rect.y(), 0.5);
+ EXPECT_LT(scroll_y, max_scroll_y);
+}
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyValidScrollIsNotClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+ double dsf =
+ EvalJs(RootFrameTreeNode(), "window.devicePixelRatio").ExtractDouble();
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ // Target 50 DIPs inside the child frame.
+ // We must send it in physical pixels because the Mojo call expects physical
+ // pixels (when zoom-for-dsf is enabled).
+ double target_y_dip = child_rect.height() - 50;
+ double target_y_px = target_y_dip * dsf;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, target_y_px, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ // It should scroll to the target.
+ EXPECT_NEAR(scroll_y, child_rect.y() + target_y_dip, 0.5);
+}
+
+class ScrollRectToVisibleClampBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {};
+
+// The rect bubbled from a child frame to its embedder is in the child's local
+// frame coordinates. Ensure the embedder only scrolls within the child's
+// extent even if the requested rect lies far outside it.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
+class ScrollRectToVisibleClampHighDPIBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {
+ public:
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ ScrollRectToVisibleClampBrowserTestBase::SetUpCommandLine(command_line);
+ command_line->AppendSwitchASCII(switches::kForceDeviceScaleFactor, "2.0");
+ }
+};
+
+// Ensure clamping works correctly on High-DPI displays where the device scale
+// factor is not 1.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
Regression Test / PoC
diff --git a/content/browser/renderer_host/scroll_into_view_browsertest.cc b/content/browser/renderer_host/scroll_into_view_browsertest.cc
index 52cc9562..254b6ec 100644
--- a/content/browser/renderer_host/scroll_into_view_browsertest.cc
+++ b/content/browser/renderer_host/scroll_into_view_browsertest.cc
@@ -29,6 +29,7 @@
#include "third_party/blink/public/mojom/frame/frame.mojom-test-utils.h"
#include "third_party/blink/public/mojom/scroll/scroll_into_view_params.mojom.h"
#include "third_party/re2/src/re2/re2.h"
+#include "ui/display/display_switches.h"
#include "ui/events/event_constants.h"
#include "url/gurl.h"
@@ -977,6 +978,140 @@
RunTest();
}
+class ScrollRectToVisibleClampBrowserTestBase
+ : public ScrollIntoViewBrowserTestBase {
+ protected:
+ bool IsForceLocalFrames() const override { return false; }
+ bool IsWritingModeLTR() const override { return true; }
+ TestInvokeMethod GetInvokeMethod() const override { return kJavaScript; }
+
+ void VerifyRectFromChildIsClamped();
+ void VerifyValidScrollIsNotClamped();
+};
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyRectFromChildIsClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ // The child frame's location and size in the root document, at the initial
+ // (0,0) scroll position established by SetupTest.
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ // The page is built so that there is scrollable extent well beyond the
+ // child frame; otherwise the assertion below isn't meaningful.
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, 10000000, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ // The resulting scroll must stay within the child frame's extent in the
+ // embedder rather than reaching the document's maximum scroll offset.
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ EXPECT_NEAR(scroll_y, child_rect.y(), 0.5);
+ EXPECT_LT(scroll_y, max_scroll_y);
+}
+
+void ScrollRectToVisibleClampBrowserTestBase::VerifyValidScrollIsNotClamped() {
+ ASSERT_TRUE(SetupTest("siteA(siteB)"));
+
+ gfx::RectF child_rect = GetClientRect(RootFrameTreeNode(), "#childframe");
+ double dsf =
+ EvalJs(RootFrameTreeNode(), "window.devicePixelRatio").ExtractDouble();
+
+ double max_scroll_y = EvalJs(RootFrameTreeNode(),
+ "document.scrollingElement.scrollHeight - "
+ "document.scrollingElement.clientHeight")
+ .ExtractDouble();
+ ASSERT_GT(max_scroll_y, child_rect.bottom() + 100);
+ ASSERT_EQ(0, EvalJs(RootFrameTreeNode(), "window.scrollY"));
+
+ auto params = blink::mojom::ScrollIntoViewParams::New();
+ params->align_x = blink::mojom::ScrollAlignment::New();
+ params->align_y = blink::mojom::ScrollAlignment::New();
+ params->align_y->rect_visible = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_hidden = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->align_y->rect_partial = blink::mojom::ScrollAlignment::Behavior::kTop;
+ params->behavior = blink::mojom::ScrollBehavior::kInstant;
+ params->type = blink::mojom::ScrollType::kProgrammatic;
+ params->cross_origin_boundaries = true;
+
+ // Target 50 DIPs inside the child frame.
+ // We must send it in physical pixels because the Mojo call expects physical
+ // pixels (when zoom-for-dsf is enabled).
+ double target_y_dip = child_rect.height() - 50;
+ double target_y_px = target_y_dip * dsf;
+
+ RenderFrameSubmissionObserver frame_observer(web_contents());
+ static_cast<blink::mojom::LocalFrameHost*>(
+ InnerMostFrameTreeNode()->current_frame_host())
+ ->ScrollRectToVisibleInParentFrame(gfx::RectF(0, target_y_px, 1, 1),
+ std::move(params));
+ frame_observer.WaitForScrollOffsetAtTop(
+ /*expected_scroll_offset_at_top=*/false);
+
+ double scroll_y =
+ EvalJs(RootFrameTreeNode(), "window.scrollY").ExtractDouble();
+ // It should scroll to the target.
+ EXPECT_NEAR(scroll_y, child_rect.y() + target_y_dip, 0.5);
+}
+
+class ScrollRectToVisibleClampBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {};
+
+// The rect bubbled from a child frame to its embedder is in the child's local
+// frame coordinates. Ensure the embedder only scrolls within the child's
+// extent even if the requested rect lies far outside it.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
+class ScrollRectToVisibleClampHighDPIBrowserTest
+ : public ScrollRectToVisibleClampBrowserTestBase {
+ public:
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ ScrollRectToVisibleClampBrowserTestBase::SetUpCommandLine(command_line);
+ command_line->AppendSwitchASCII(switches::kForceDeviceScaleFactor, "2.0");
+ }
+};
+
+// Ensure clamping works correctly on High-DPI displays where the device scale
+// factor is not 1.
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ RectFromChildIsClampedToFrameBounds) {
+ VerifyRectFromChildIsClamped();
+}
+
+IN_PROC_BROWSER_TEST_F(ScrollRectToVisibleClampHighDPIBrowserTest,
+ ValidScrollInsideChildIsNotClamped) {
+ VerifyValidScrollIsNotClamped();
+}
+
IN_PROC_BROWSER_TEST_F(ScrollIntoViewFencedFrameBrowserTest,
ProgrammaticScrollIntoViewDoesntCrossFencedFrame) {
ASSERT_TRUE(SetupTest("siteA{FencedFrame}(siteB)"));
Original Bug Report
Cross-origin layout-size oracle via unclipped ScrollRectToVisible
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: A potential cross-origin layout-size oracle exists in Chromium due to a lack of coordinate clipping during scroll-into-view requests across frames. An untrusted cross-origin iframe can submit extreme coordinates to force ancestor scroll containers in the parent frame to scroll to their boundaries. The resulting layout shift is leaked back to the iframe via ViewportIntersectionState feedback, allowing it to measure parent container dimensions.
Affected files:
content/browser/renderer_host/render_frame_host_impl.ccthird_party/blink/renderer/core/frame/remote_frame.cc
Estimated timestamp from git blame: 2017-10-23
Summary
A potential cross-origin layout-size disclosure (XS-Leak) exists in Chromium’s frame scrolling code. A malicious or compromised cross-origin iframe can exploit this behavior to measure the scrollable content dimensions of its ancestor scroll containers in the parent document (such as div blocks with overflow: scroll), bypassing Site Isolation and the Same-Origin Policy.
Potential Root Cause Analysis
-
Unchecked Coordinate Passing in the Browser Process The Mojo IPC handler
RenderFrameHostImpl::ScrollRectToVisibleInParentFrameincontent/browser/renderer_host/render_frame_host_impl.ccreceives a client-providedgfx::RectF rect_to_scrolland forwards it directly to the parent’s frame proxy viaRenderFrameProxyHost::ScrollRectToVisible. No bounds validation or clipping of this coordinate against the sender frame’s viewport is performed. -
Unclipped Transform in Parent Renderer In the parent’s renderer process,
RemoteFrame::ScrollRectToVisibleinthird_party/blink/renderer/core/frame/remote_frame.ccconvertsrect_to_scrollto absolute parent coordinates:PhysicalRect absolute_rect = owner_object->LocalToAncestorRect( PhysicalRect::EnclosingRect(rect_to_scroll), owner_object->View());LocalToAncestorRectperforms a pure coordinate transform. It does not clipabsolute_rectto the actual bounds of the iframe’s layout box. An attacker can supply arbitrarily large coordinates (e.g.,±1e7) which map to points far outside the iframe element. -
Unrestricted Ancestor Bubbling
scroll_into_view_util::ScrollRectToVisibleprocesses the converted rect. It traverses the ancestor containment chain and scrolls all scrollable containers in the parent document. Each container clamps the scrolling to its minimum or maximum limits (ClampScrollOffset). -
Feedback via ViewportIntersectionState As the parent’s containers scroll, the position of the iframe relative to the viewport shifts. During layout updates,
FrameView::UpdateViewportIntersectioninthird_party/blink/renderer/core/frame/frame_view.cccaptures this translation shift inmain_frame_transformand transmits the updatedViewportIntersectionStateback to the child iframe, leaking the exact scroll offsets.
Potential Attack Steps
An attacker in a cross-origin iframe (or with code execution in the iframe’s renderer process) could potentially measure cross-origin layout dimensions by following these steps (note: these are suggested steps, as our analysis has been conducted statically without live code execution capability):
- Call
ScrollRectToVisibleInParentFramewith a large negative scroll coordinate (e.g.,rect.y = -1e7) to force ancestor scrollable containers to scroll to their minimum scroll limits. Record the resulting translation matrixT0fromViewportIntersectionState. - Call
ScrollRectToVisibleInParentFramewith a large positive scroll coordinate (e.g.,rect.y = +1e7) to force ancestor containers to their maximum scroll limits. Record the resulting translation matrixT1. - Compute the delta
(T0 - T1).y, which exposes the sum of the maximum scroll offsets (content_size - viewport_size) across the parent containers. By using a binary search over intermediate coordinates, the attacker could isolate individual ancestor container dimensions.
Suggested Fix
To remediate this issue, coordinate conversions in RemoteFrame::ScrollRectToVisible should clip the incoming rect_to_scroll coordinates against the layout boundaries of the subframe’s element (or the subframe’s viewport size) before applying the coordinate space transformation and bubbling the scroll action upward.
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.