CVE-2026-79116
Overview
Background
- Back/forward cache (BFCache)
- A browser-process feature that keeps a fully constructed, but inactive, document (its
RenderFrameHost) alive after the user navigates away, so a back/forward navigation can restore it instantly. - `LocalMainFrameHost::SetWindowRect`
- A Mojo IPC exposed to the renderer’s main frame that asks the browser to move/resize the window hosting that frame, ultimately reaching
WebContentsDelegate::SetContentsBounds. - `RenderFrameHostImpl`
- The browser-side object backing a document; an instance can be the active primary main frame, a child/inner frame, or an inactive document sitting in BFCache.
- `ValidateOutermostMainFrameWindowChange`
- A helper (pre-existing, used elsewhere for window-affecting IPCs) that rejects window changes from documents that are not the active outermost main frame and evicts an inactive page from BFCache.
Root Cause Analysis
The pre-patch RenderFrameHostImpl::SetWindowRect only rejected the IPC when GetParentOrOuterDocument() was non-null (i.e. from a child/inner frame); it had no check for whether the calling document was still active. A document that had entered the back/forward cache is neither a child nor an outer-document frame, so it passed that guard and reached delegate_->SetWindowRect(bounds), letting a cached (background) renderer move or resize the window that is now displaying a completely different primary main frame.
The fix replaces the child-frame-only check with ValidateOutermostMainFrameWindowChange("SetWindowRect"), which returns false for an inactive/cached document and additionally evicts it from BFCache so the renderer’s window-bounds state cannot desync on restore. It also wraps the callback in a base::ScopedClosureRunner so the renderer is always acked (and can clear its pending window rect) even on the new early-return paths, whereas previously the ack (std::move(callback).Run()) only ran on the success path.
ValidateOutermostMainFrameWindowChange, which enforces that only the active outermost main frame may change window bounds and evicts stale cached pages.Attack Path
- Cache a document
A page A is loaded, then the user (or script) navigates to page B, causing A’s
RenderFrameHostImplto be stored in the back/forward cache while B becomes the active primary main frame. - Cached renderer sends `SetWindowRect`
The still-alive, backgrounded renderer for A issues
LocalMainFrameHost::SetWindowRectwith attacker-chosen bounds. - Old guard passes
Because A is a cached main-frame document (not a child/outer frame),
GetParentOrOuterDocument()is null, so the pre-patch code skips the bad-message rejection and callsdelegate_->SetWindowRect(bounds). - Window of the active page is moved/resized The browser applies A’s requested bounds to the window that is now showing B, allowing a background document to manipulate the geometry of the foreground page’s window without authorization.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
BoundsTrackingDelegatecontent/browser/back_forward_cache_internal_browsertest.cc |
modified |
Files Changed
content/browser/renderer_host/render_frame_host_impl.cccontent/browser/back_forward_cache_internal_browsertest.cc
Audit Directions
- Window/geometry IPCs missing active-document checksAudit sibling handlers such as
MoveWindowToand otherLocalMainFrameHost/window-affecting Mojo methods to confirm each validates that the caller is the active outermost main frame, not merely “not a subframe.” - BFCache-inactive documents reaching side-effecting delegatesSearch browser-side IPC handlers that call into
delegate_/WebContentsDelegatefor cases that check frame hierarchy (GetParentOrOuterDocument) but omit the inactive/prerender/BFCache state, since a cached renderer can still send IPCs. - Ack/callback paths on early returnReview handlers that only run their Mojo response callback on the success path; a dropped ack can leave the renderer with permanently pending state and mask incorrect early-return refactors.
Patch
From a8d1167fe626a0a8b6876f1de94bf56d08e928bc Mon Sep 17 00:00:00 2001
From: Tzarial <zork@google.com>
Date: Fri, 17 Jul 2026 04:28:20 -0700
Subject: [PATCH] [agy][content] Ignore SetWindowRect in BFCache
LocalMainFrameHost::SetWindowRect arriving for a document that has
entered the back/forward cache must not move or resize the window,
which is now displaying a different primary main frame.
Fixed: 502232151
Test: BackForwardCacheBrowserTest.SetWindowRectIgnoredForCachedFrame
Change-Id: Id4e872b4c4de6cc7b43cb3ae4b4cab1a89a26b44
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8087302
Commit-Queue: Tzarial <zork@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1663804}
---
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index 08e2dcb..27e1428 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -7841,15 +7841,22 @@
"SetWindowRect called during prerendering.");
return;
}
- // Throw out SetWindowRects that are not from the outermost document.
- if (GetParentOrOuterDocument()) {
- local_main_frame_host_receiver_.ReportBadMessage(
- "SetWindowRect called from child frame.");
+
+ // Always ack the renderer so it can clear its pending window rect, even on
+ // the early-return paths below.
+ base::ScopedClosureRunner ack(std::move(callback));
+
+ // An inactive document (e.g. one that has entered the back/forward cache)
+ // must not move or resize the window, which now hosts a different primary
+ // main frame. We use ValidateOutermostMainFrameWindowChange to evict the
+ // page from BFCache if this happens, to prevent the renderer from getting
+ // out of sync with the browser's window bounds if the page is later
+ // restored. See https://crbug.com/502232151.
+ if (!ValidateOutermostMainFrameWindowChange("SetWindowRect")) {
return;
}
delegate_->SetWindowRect(bounds);
- std::move(callback).Run();
}
void RenderFrameHostImpl::MoveWindowTo(const gfx::Point& origin,
diff --git a/content/browser/back_forward_cache_internal_browsertest.cc b/content/browser/back_forward_cache_internal_browsertest.cc
index 926210f..96975c30 100644
--- a/content/browser/back_forward_cache_internal_browsertest.cc
+++ b/content/browser/back_forward_cache_internal_browsertest.cc
@@ -26,6 +26,7 @@
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/disallow_activation_reason.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/web_contents_delegate.h"
#include "content/public/common/content_client.h"
#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
@@ -2022,6 +2023,57 @@
{reason}, FROM_HERE);
}
+// LocalMainFrameHost::SetWindowRect arriving for a document that has entered
+// the back/forward cache must not move or resize the window, which is now
+// displaying a different primary main frame. See https://crbug.com/502232151.
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+ SetWindowRectIgnoredForCachedFrame) {
+ // A WebContentsDelegate that records SetContentsBounds() calls.
+ class BoundsTrackingDelegate : public WebContentsDelegate {
+ public:
+ void SetContentsBounds(WebContents* source,
+ const gfx::Rect& bounds) override {
+ ++set_contents_bounds_call_count_;
+ }
+ int set_contents_bounds_call_count() const {
+ return set_contents_bounds_call_count_;
+ }
+
+ private:
+ int set_contents_bounds_call_count_ = 0;
+ };
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+ // 1) Navigate to A.
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImplWrapper rfh_a(current_frame_host());
+
+ // 2) Navigate to B. A is stored in the back/forward cache.
+ EXPECT_TRUE(NavigateToURL(shell(), url_b));
+ ASSERT_TRUE(rfh_a->IsInBackForwardCache());
+
+ BoundsTrackingDelegate tracking_delegate;
+ WebContentsDelegate* old_delegate = web_contents()->GetDelegate();
+ web_contents()->SetDelegate(&tracking_delegate);
+
+ // 3) Simulate the cached renderer sending SetWindowRect. The request must be
+ // dropped without forwarding new bounds to the WebContentsDelegate.
+ bool callback_ran = false;
+ rfh_a->SetWindowRect(
+ gfx::Rect(10, 10, 300, 200),
+ base::BindLambdaForTesting([&] { callback_ran = true; }));
+ EXPECT_TRUE(callback_ran);
+ EXPECT_EQ(0, tracking_delegate.set_contents_bounds_call_count());
+
+ // The page must be evicted from the back/forward cache.
+ ASSERT_TRUE(rfh_a.WaitUntilRenderFrameDeleted());
+
+ web_contents()->SetDelegate(old_delegate);
+}
+
// Test scenarios where the "BackForwardCache" content flag is enabled but
// the command line flag "DisableBackForwardCache" is turned on, resulting in
// the feature being disabled.
Regression Test / PoC
diff --git a/content/browser/back_forward_cache_internal_browsertest.cc b/content/browser/back_forward_cache_internal_browsertest.cc
index 926210f..96975c30 100644
--- a/content/browser/back_forward_cache_internal_browsertest.cc
+++ b/content/browser/back_forward_cache_internal_browsertest.cc
@@ -26,6 +26,7 @@
#include "content/public/browser/content_browser_client.h"
#include "content/public/browser/disallow_activation_reason.h"
#include "content/public/browser/navigation_handle.h"
+#include "content/public/browser/web_contents_delegate.h"
#include "content/public/common/content_client.h"
#include "content/public/common/content_features.h"
#include "content/public/common/content_switches.h"
@@ -2022,6 +2023,57 @@
{reason}, FROM_HERE);
}
+// LocalMainFrameHost::SetWindowRect arriving for a document that has entered
+// the back/forward cache must not move or resize the window, which is now
+// displaying a different primary main frame. See https://crbug.com/502232151.
+IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTest,
+ SetWindowRectIgnoredForCachedFrame) {
+ // A WebContentsDelegate that records SetContentsBounds() calls.
+ class BoundsTrackingDelegate : public WebContentsDelegate {
+ public:
+ void SetContentsBounds(WebContents* source,
+ const gfx::Rect& bounds) override {
+ ++set_contents_bounds_call_count_;
+ }
+ int set_contents_bounds_call_count() const {
+ return set_contents_bounds_call_count_;
+ }
+
+ private:
+ int set_contents_bounds_call_count_ = 0;
+ };
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+ // 1) Navigate to A.
+ EXPECT_TRUE(NavigateToURL(shell(), url_a));
+ RenderFrameHostImplWrapper rfh_a(current_frame_host());
+
+ // 2) Navigate to B. A is stored in the back/forward cache.
+ EXPECT_TRUE(NavigateToURL(shell(), url_b));
+ ASSERT_TRUE(rfh_a->IsInBackForwardCache());
+
+ BoundsTrackingDelegate tracking_delegate;
+ WebContentsDelegate* old_delegate = web_contents()->GetDelegate();
+ web_contents()->SetDelegate(&tracking_delegate);
+
+ // 3) Simulate the cached renderer sending SetWindowRect. The request must be
+ // dropped without forwarding new bounds to the WebContentsDelegate.
+ bool callback_ran = false;
+ rfh_a->SetWindowRect(
+ gfx::Rect(10, 10, 300, 200),
+ base::BindLambdaForTesting([&] { callback_ran = true; }));
+ EXPECT_TRUE(callback_ran);
+ EXPECT_EQ(0, tracking_delegate.set_contents_bounds_call_count());
+
+ // The page must be evicted from the back/forward cache.
+ ASSERT_TRUE(rfh_a.WaitUntilRenderFrameDeleted());
+
+ web_contents()->SetDelegate(old_delegate);
+}
+
// Test scenarios where the "BackForwardCache" content flag is enabled but
// the command line flag "DisableBackForwardCache" is turned on, resulting in
// the feature being disabled.