Medium watch CVSS 3.1 chrome Authorization Error 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactConfused deputy in Prerender
DescriptionConfused deputy in Prerender
ComponentPrerender
Sub-componentPrerenderer
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker497443419
Fix commit8c63c26c6598 (chromium/src) +156/-9
CWECWE-441
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-29 (per vendor advisory)
Fix landed2026-08-14 (public git)
Disclosed2026-09-08
Patch gap25 days — fix public in source before the advisory

Background

`PrerendererImpl`
a content-browser object, owned by PreloadingDecider as DocumentUserData, that turns speculation candidates into prerender/prefetch requests on behalf of a specific initiating document bound via render_frame_host_.
Back/forward cache (BFCache)
the mechanism that keeps a navigated-away document alive but inactive so it can be restored; a BFCached frame returns false from RenderFrameHost::IsActive().
`GetPrimaryMainFrame()` vs `GetOutermostMainFrame()`
GetPrimaryMainFrame() returns the tab’s current primary main frame, which after a navigation is a different document than the one that owns the PrerendererImpl, whereas render_frame_host_->GetOutermostMainFrame() stays attached to the initiating document.
`PrefetchDocumentManager`
per-document bookkeeping for prefetch-ahead-of-prerender, fetched with GetOrCreateForCurrentDocument(frame).

Root Cause Analysis

PrerendererImpl outlives its owning document being the primary main frame — for example while that document sits in the back/forward cache — yet MaybePrerender and its helpers kept running and resolved the “current” document via WebContents::FromRenderFrameHost(...)->GetPrimaryMainFrame(). After the initiating document navigated away, that call returned a different, now-primary document, so prefetch-ahead-of-prerender bookkeeping (PrefetchDocumentManager::GetOrCreateForCurrentDocument) and the page UKM source id were attached to and acted upon on behalf of the wrong document — a confused-deputy condition where a stale/inactive initiator drives preloading against another page.

The fix stops honoring calls from an inactive bound frame: MaybePrerender returns false early when !render_frame_host_->IsActive(), and CHECK(render_frame_host_->IsActive()) is asserted at the top of the candidate-processing path and in OnLCPPredicted. It also rebinds all bookkeeping to the initiating document by replacing web_contents->GetPrimaryMainFrame() with render_frame_host_->GetOutermostMainFrame(), so prefetch state and UKM ids stay attached to the initiator. The companion AnchorElementInteractionHostImpl handlers add an if (!render_frame_host().IsActive()) return; guard so pointer/viewport IPCs from an inactive frame no longer feed PreloadingDecider. The removed CHECK(!render_frame_host_->IsInLifecycleState(kPrerendering)) was replaced by the broader active-frame gate.

Key insight
The core mistake was resolving the “current document” through GetPrimaryMainFrame() while the PrerendererImpl was bound to a possibly-inactive initiator, letting a stale frame act as a confused deputy against whatever document is now primary; the fix ignores calls from inactive frames and re-anchors bookkeeping to render_frame_host_->GetOutermostMainFrame().

Attack Path

  1. Establish a prerendering initiator A page with speculation rules / anchor-interaction heuristics creates a PrerendererImpl bound to its render_frame_host_ via PreloadingDecider.
  2. Navigate away into BFCache The user (or script-induced navigation) moves that document into the back/forward cache, so render_frame_host_->IsActive() is now false while the PrerendererImpl and pending candidates remain alive.
  3. Deliver a candidate/interaction from the inactive frame A queued or renderer-sent speculation candidate reaches MaybePrerender (or an AnchorElementInteractionHostImpl pointer/viewport IPC arrives) while the frame is inactive.
  4. Bookkeeping attaches to the wrong document Pre-patch, WebContents::FromRenderFrameHost(...)->GetPrimaryMainFrame() resolves to the now-primary (different) document, so PrefetchDocumentManager state and the page UKM source id are created/mutated against that other document.
  5. Confused-deputy preloading (inferred) — The stale initiator thereby drives prefetch/prerender bookkeeping on behalf of a document that never requested it; the patch demonstrates the misattribution but no further memory-corruption or data-read primitive.

Impact Assessment

An inactive (e.g. BFCached) initiating document could cause preloading/prefetch bookkeeping to be misattributed to the tab’s current primary document, a confused-deputy logic error in the browser-process Prerender path. Preconditions are a document that registers speculation/anchor-interaction preloading and then becomes inactive while candidates are still pending. The advisory classifies this as an AuthorizationError / confused deputy in Prerender and reports no in-the-wild exploitation; any consequence beyond misattributed bookkeeping is (inferred).

Changed Functions

FunctionChangeNotes
for
content/browser/preloading/prerenderer_impl.cc
modified
switch
content/browser/preloading/prerenderer_impl.cc
modified
if
content/browser/preloading/prerenderer_impl.cc
modified
TEST_F
content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
modified
PrerendererBackForwardCacheTest
content/browser/preloading/prerenderer_impl_browsertest.cc
modified
PrerendererBackForwardCacheTest
content/browser/preloading/prerenderer_impl_browsertest.cc
modified
PrerendererImplBrowserTestPrefetchAhead
content/browser/preloading/prerenderer_impl_browsertest.cc
modified
if
content/browser/preloading/prerenderer_impl_browsertest.cc
modified
TEST_F
content/browser/preloading/prerenderer_impl_unittest.cc
modified

Files Changed

  • content/browser/preloading/prerenderer_impl_unittest.cc
  • content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
  • content/browser/preloading/anchor_element_interaction_host_impl.cc
  • content/browser/preloading/prerenderer_impl_browsertest.cc
  • content/browser/preloading/prerenderer_impl.cc

Audit Directions

  • `GetPrimaryMainFrame()` from a bound object
    Flag any DocumentUserData-owned or frame-bound object that resolves “the current document” via WebContents::GetPrimaryMainFrame() instead of its own render_frame_host_, since the two diverge after navigation/BFCache.
  • Missing `IsActive()` gate on IPC handlers
    Audit mojo host implementations bound to a RenderFrameHost that act on PreloadingDecider/preloading state without first checking render_frame_host().IsActive().
  • Objects that outlive frame primary-ness
    Review lifecycles where a browser-side helper can persist past its document leaving the primary main frame (BFCache, pending prerender) and still processes queued candidates or callbacks.
From 8c63c26c65982f9951e81a10dc56affaff974a47 Mon Sep 17 00:00:00 2001
From: Zainab Rizvi <[email protected]>
Date: Fri, 14 Aug 2026 10:56:14 -0700
Subject: [PATCH] Prerenderer: use initiator RFH and ignore inactive frames

PrerendererImpl is owned by PreloadingDecider (DocumentUserData) and is
bound to a specific document via render_frame_host_. It can therefore
outlive that document being the primary main frame, e.g. when the
document is in the back/forward cache.

Obtain the PrefetchDocumentManager and the page UKM source id from
render_frame_host_ rather than GetPrimaryMainFrame() so that
prefetch-ahead-of-prerender bookkeeping stays attached to the initiating
document, and ignore MaybePrerender() calls when the bound frame is no
longer active.

Bug: 497443419
Change-Id: I0d3ae61523570aedd984163175e92debe4a56486
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8217707
Reviewed-by: Hiroki Nakagawa <[email protected]>
Commit-Queue: Zainab Rizvi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1679687}
---
diff --git a/content/browser/preloading/anchor_element_interaction_host_impl.cc b/content/browser/preloading/anchor_element_interaction_host_impl.cc
index 6f7d7a3a..e0f1471 100644
--- a/content/browser/preloading/anchor_element_interaction_host_impl.cc
+++ b/content/browser/preloading/anchor_element_interaction_host_impl.cc
@@ -140,6 +140,9 @@
 
 void AnchorElementInteractionHostImpl::OnPointerDown(const GURL& url,
                                                      bool renderer_enacted) {
+  if (!render_frame_host().IsActive()) {
+    return;
+  }
   auto* preloading_decider =
       PreloadingDecider::GetOrCreateForCurrentDocument(&render_frame_host());
   preloading_decider->OnPointerDown(url, renderer_enacted);
@@ -151,6 +154,9 @@
     const GURL& url,
     blink::mojom::AnchorElementPointerDataPtr mouse_data,
     bool renderer_enacted) {
+  if (!render_frame_host().IsActive()) {
+    return;
+  }
   auto* preloading_decider =
       PreloadingDecider::GetOrCreateForCurrentDocument(&render_frame_host());
   preloading_decider->OnPointerHover(url, std::move(mouse_data),
@@ -162,6 +168,9 @@
     const GURL& url,
     blink::mojom::AnchorElementPointerDataPtr mouse_data,
     bool renderer_enacted) {
+  if (!render_frame_host().IsActive()) {
+    return;
+  }
   auto* preloading_decider =
       PreloadingDecider::GetOrCreateForCurrentDocument(&render_frame_host());
   preloading_decider->OnPointerHover(
@@ -174,6 +183,9 @@
 void AnchorElementInteractionHostImpl::OnModerateViewportHeuristicTriggered(
     const GURL& url,
     bool renderer_enacted) {
+  if (!render_frame_host().IsActive()) {
+    return;
+  }
   if (!base::FeatureList::IsEnabled(
           blink::features::kPreloadingModerateViewportHeuristics)) {
     ReportBadMessageAndDeleteThis(
@@ -191,6 +203,9 @@
 
 void AnchorElementInteractionHostImpl::OnEagerViewportHeuristicTriggered(
     std::vector<blink::mojom::AnchorElementInteractionTargetPtr> targets) {
+  if (!render_frame_host().IsActive()) {
+    return;
+  }
   if (!base::FeatureList::IsEnabled(
           blink::features::kPreloadingEagerViewportHeuristics)) {
     ReportBadMessageAndDeleteThis(
diff --git a/content/browser/preloading/prerenderer_impl.cc b/content/browser/preloading/prerenderer_impl.cc
index 1538de3a..ca75f15 100644
--- a/content/browser/preloading/prerenderer_impl.cc
+++ b/content/browser/preloading/prerenderer_impl.cc
@@ -168,6 +168,8 @@
     return;
   }
 
+  CHECK(render_frame_host_->IsActive());
+
   // Extract only the candidates which apply to prerender, and sort them by URL
   // so we can efficiently compare them to `started_prerenders_`.
   // If both prerender and prerender-until-script are applied to the same URL,
@@ -317,11 +319,9 @@
               PrerenderFinalStatus::kSpeculationRuleRemoved));
   if (base::FeatureList::IsEnabled(
           features::kPrerender2FallbackPrefetchSpecRules)) {
-    WebContents* web_contents =
-        WebContents::FromRenderFrameHost(&render_frame_host_.get());
     auto* prefetch_document_manager =
         content::PrefetchDocumentManager::GetOrCreateForCurrentDocument(
-            web_contents->GetPrimaryMainFrame());
+            render_frame_host_->GetOutermostMainFrame());
     for (const auto& [url, preloading_type] : to_be_cancelled_prerender_list) {
       prefetch_document_manager->ResetPrefetchAheadOfPrerenderIfExist(
           preloading_type, url);
@@ -352,6 +352,7 @@
 }
 
 void PrerendererImpl::OnLCPPredicted() {
+  CHECK(render_frame_host_->IsActive());
   blocked_ = false;
   for (auto& [candidate, enacting_predictor, confidence] :
        std::move(blocked_candidates_)) {
@@ -363,6 +364,10 @@
     const blink::mojom::SpeculationCandidatePtr& candidate,
     const PreloadingPredictor& enacting_predictor,
     PreloadingConfidence confidence) {
+  if (!render_frame_host_->IsActive()) {
+    return false;
+  }
+
   // Check actions. Only Prerender and PrerenderUntilScript are allowed.
   switch (candidate->action) {
     case blink::mojom::SpeculationAction::kPrerender:
@@ -392,10 +397,6 @@
     return false;
   }
 
-  // Prerendering frames should not trigger any prerender request.
-  CHECK(!render_frame_host_->IsInLifecycleState(
-      RenderFrameHost::LifecycleState::kPrerendering));
-
   if (!registry_) {
     return false;
   }
@@ -529,7 +530,7 @@
 
           auto* prefetch_document_manager =
               content::PrefetchDocumentManager::GetOrCreateForCurrentDocument(
-                  web_contents->GetPrimaryMainFrame());
+                  render_frame_host_->GetOutermostMainFrame());
           prefetch_document_manager->PrefetchAheadOfPrerender(
               attributes.preload_pipeline_info, candidate.Clone(),
               enacting_predictor);
@@ -547,7 +548,8 @@
                 creating_predictor, enacting_predictor,
                 ConvertSpeculationActionToPreloadingType(candidate->action),
                 std::move(same_url_matcher),
-                web_contents->GetPrimaryMainFrame()->GetPageUkmSourceId()));
+                render_frame_host_->GetOutermostMainFrame()
+                    ->GetPageUkmSourceId()));
         preloading_attempt->SetSpeculationEagerness(candidate->eagerness);
         return registry_->CreateAndStartHost(attributes, preloading_attempt);
       }
diff --git a/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc b/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
index b556fc6b..ba2123a 100644
--- a/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
+++ b/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
@@ -213,5 +213,66 @@
       PredictorConfusionMatrix::kFalseNegative, 1);
 }
 
+TEST_F(AnchorElementInteractionHostImplTest, IgnoreMessagesWhenInactive) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {blink::features::kPreloadingModerateViewportHeuristics,
+       blink::features::kPreloadingEagerViewportHeuristics},
+      /*disabled_features=*/{});
+
+  auto* render_frame_host = static_cast<RenderFrameHostImpl*>(main_rfh());
+
+  std::vector<blink::mojom::SpeculationCandidatePtr> candidates;
+  PreloadingDecider::GetOrCreateForCurrentDocument(render_frame_host)
+      ->UpdateSpeculationCandidates(candidates);
+
+  mojo::Remote<blink::mojom::AnchorElementInteractionHost> remote;
+  AnchorElementInteractionHostImpl::Create(render_frame_host,
+                                           remote.BindNewPipeAndPassReceiver());
+
+  ScopedPreloadingDeciderObserver observer(render_frame_host);
+  auto* preloading_data =
+      PreloadingDataImpl::GetOrCreateForWebContents(web_contents());
+
+  render_frame_host->SetLifecycleState(
+      RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  EXPECT_FALSE(render_frame_host->IsActive());
+
+  const auto kUrl = GURL("https://example.com/page1.html");
+
+  // 1. OnPointerDown
+  remote->OnPointerDown(kUrl, /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_down_url_.has_value());
+
+  // 2. OnPointerHoverModerate
+  remote->OnPointerHoverModerate(
+      kUrl, blink::mojom::AnchorElementPointerData::New(false, 0.0, 0.0),
+      /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_hover_url_.has_value());
+
+  // 3. OnPointerHoverEager
+  remote->OnPointerHoverEager(
+      kUrl, blink::mojom::AnchorElementPointerData::New(false, 0.0, 0.0),
+      /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_hover_url_.has_value());
+
+  // 4. OnModerateViewportHeuristicTriggered
+  remote->OnModerateViewportHeuristicTriggered(kUrl,
+                                               /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_EQ(preloading_data->GetPredictionsSizeForTesting(), 0u);
+
+  // 5. OnEagerViewportHeuristicTriggered
+  std::vector<blink::mojom::AnchorElementInteractionTargetPtr> targets;
+  targets.push_back(blink::mojom::AnchorElementInteractionTarget::New(
+      kUrl, /*renderer_enacted=*/false));
+  remote->OnEagerViewportHeuristicTriggered(std::move(targets));
+  remote.FlushForTesting();
+  EXPECT_EQ(preloading_data->GetPredictionsSizeForTesting(), 0u);
+}
+
 }  // namespace
 }  // namespace content
diff --git a/content/browser/preloading/prerenderer_impl_browsertest.cc b/content/browser/preloading/prerenderer_impl_browsertest.cc
index f750517..111ef485f 100644
--- a/content/browser/preloading/prerenderer_impl_browsertest.cc
+++ b/content/browser/preloading/prerenderer_impl_browsertest.cc
@@ -7,6 +7,7 @@
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
+#include "content/browser/preloading/prefetch/prefetch_document_manager.h"
 #include "content/browser/preloading/prefetch/prefetch_features.h"
 #include "content/browser/preloading/prefetch/prefetch_match_resolver.h"
 #include "content/browser/preloading/prefetch/prefetch_service.h"
@@ -20,6 +21,7 @@
 #include "content/browser/preloading/prerender/prerender_host_registry.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/isolated_world_ids.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/content_browser_test.h"
@@ -230,6 +232,17 @@
   }
 };
 
+class PrerendererBackForwardCacheTest : public PrerendererImplBrowserTestBase {
+ public:
+  PrerendererBackForwardCacheTest() {
+    feature_list_.InitWithFeatures(
+        {features::kBackForwardCache,
+         features::kPrerender2FallbackPrefetchSpecRules},
+        {features::kBackForwardCacheMemoryControls,
+         blink::features::kLCPTimingPredictorPrerender2});
+  }
+};
+
 class PrerendererImplBrowserTestPrefetchAhead
     : public PrerendererImplBrowserTestBase,
       public ::testing::WithParamInterface<
@@ -1773,5 +1786,41 @@
   ASSERT_EQ(expected, GetObservedRequests());
 }
 
+// Tests that PrerendererImpl::MaybePrerender ignores candidates from an
+// inactive frame in the back/forward cache.
+IN_PROC_BROWSER_TEST_F(PrerendererBackForwardCacheTest,
+                       MaybePrerenderIgnoredWhenInactive) {
+  EXPECT_TRUE(NavigateToURL(shell(), GetUrl("/empty.html")));
+  RenderFrameHostImpl* rfh_a = static_cast<RenderFrameHostImpl*>(
+      web_contents_impl().GetPrimaryMainFrame());
+  const GlobalRenderFrameHostId rfh_id = rfh_a->GetGlobalId();
+  PrerendererImpl& prerenderer_a = GetPrerendererImpl();
+
+  // Navigate away so that rfh_a enters the back/forward cache.
+  EXPECT_TRUE(NavigateToURL(shell(), GetCrossSiteUrl("/empty.html")));
+  rfh_a = static_cast<RenderFrameHostImpl*>(RenderFrameHost::FromID(rfh_id));
+  EXPECT_TRUE(rfh_a);
+  if (!rfh_a) {
+    return;
+  }
+  EXPECT_TRUE(rfh_a->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kInBackForwardCache));
+  EXPECT_FALSE(rfh_a->IsActive());
+
+  const GURL prerender_url = GetCrossSiteUrl("/title1.html");
+  blink::mojom::SpeculationCandidatePtr candidate =
+      CreateSpeculationCandidate(prerender_url);
+
+  PreloadingPredictor enacting_predictor = GetPredictorForPreloadingTriggerType(
+      PreloadingTriggerType::kSpeculationRule);
+  EXPECT_FALSE(prerenderer_a.MaybePrerender(candidate, enacting_predictor,
+                                            PreloadingConfidence{100}));
+
+  RenderFrameHostImpl* rfh_b = static_cast<RenderFrameHostImpl*>(
+      web_contents_impl().GetPrimaryMainFrame());
+  EXPECT_NE(rfh_a, rfh_b);
+  EXPECT_FALSE(PrefetchDocumentManager::GetForCurrentDocument(rfh_b));
+}
+
 }  // namespace
 }  // namespace content
diff --git a/content/browser/preloading/prerenderer_impl_unittest.cc b/content/browser/preloading/prerenderer_impl_unittest.cc
index efb8f9a4..5903913 100644
--- a/content/browser/preloading/prerenderer_impl_unittest.cc
+++ b/content/browser/preloading/prerenderer_impl_unittest.cc
@@ -355,5 +355,25 @@
   EXPECT_TRUE(registry->FindHostByUrlForTesting(kPrerenderingUrl));
 }
 
+// Tests that PrerendererImpl::MaybePrerender ignores preloading attempts when
+// the associated frame is no longer active (e.g., in back/forward cache).
+TEST_F(PrerendererTest, MaybePrerenderIgnoredWhenInactive) {
+  PrerenderHostRegistry* registry = GetPrerenderHostRegistry();
+  PrerendererImpl prerenderer(*GetRenderFrameHost());
+
+  GetRenderFrameHost()->SetLifecycleState(
+      RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  EXPECT_FALSE(GetRenderFrameHost()->IsActive());
+
+  const GURL kPrerenderingUrl = GetSameOriginUrl("/empty.html");
+  const auto candidate = CreatePrerenderCandidate(kPrerenderingUrl);
+
+  EXPECT_FALSE(prerenderer.MaybePrerender(
+      candidate, content_preloading_predictor::kSpeculationRules,
+      PreloadingConfidence{100}));
+  EXPECT_FALSE(prerenderer.ShouldWaitForPrerenderResult(kPrerenderingUrl));
+  EXPECT_FALSE(registry->FindHostByUrlForTesting(kPrerenderingUrl));
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc b/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
index b556fc6b..ba2123a 100644
--- a/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
+++ b/content/browser/preloading/anchor_element_interaction_host_impl_unittest.cc
@@ -213,5 +213,66 @@
       PredictorConfusionMatrix::kFalseNegative, 1);
 }
 
+TEST_F(AnchorElementInteractionHostImplTest, IgnoreMessagesWhenInactive) {
+  base::test::ScopedFeatureList feature_list;
+  feature_list.InitWithFeatures(
+      {blink::features::kPreloadingModerateViewportHeuristics,
+       blink::features::kPreloadingEagerViewportHeuristics},
+      /*disabled_features=*/{});
+
+  auto* render_frame_host = static_cast<RenderFrameHostImpl*>(main_rfh());
+
+  std::vector<blink::mojom::SpeculationCandidatePtr> candidates;
+  PreloadingDecider::GetOrCreateForCurrentDocument(render_frame_host)
+      ->UpdateSpeculationCandidates(candidates);
+
+  mojo::Remote<blink::mojom::AnchorElementInteractionHost> remote;
+  AnchorElementInteractionHostImpl::Create(render_frame_host,
+                                           remote.BindNewPipeAndPassReceiver());
+
+  ScopedPreloadingDeciderObserver observer(render_frame_host);
+  auto* preloading_data =
+      PreloadingDataImpl::GetOrCreateForWebContents(web_contents());
+
+  render_frame_host->SetLifecycleState(
+      RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  EXPECT_FALSE(render_frame_host->IsActive());
+
+  const auto kUrl = GURL("https://example.com/page1.html");
+
+  // 1. OnPointerDown
+  remote->OnPointerDown(kUrl, /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_down_url_.has_value());
+
+  // 2. OnPointerHoverModerate
+  remote->OnPointerHoverModerate(
+      kUrl, blink::mojom::AnchorElementPointerData::New(false, 0.0, 0.0),
+      /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_hover_url_.has_value());
+
+  // 3. OnPointerHoverEager
+  remote->OnPointerHoverEager(
+      kUrl, blink::mojom::AnchorElementPointerData::New(false, 0.0, 0.0),
+      /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_FALSE(observer.on_pointer_hover_url_.has_value());
+
+  // 4. OnModerateViewportHeuristicTriggered
+  remote->OnModerateViewportHeuristicTriggered(kUrl,
+                                               /*renderer_enacted=*/false);
+  remote.FlushForTesting();
+  EXPECT_EQ(preloading_data->GetPredictionsSizeForTesting(), 0u);
+
+  // 5. OnEagerViewportHeuristicTriggered
+  std::vector<blink::mojom::AnchorElementInteractionTargetPtr> targets;
+  targets.push_back(blink::mojom::AnchorElementInteractionTarget::New(
+      kUrl, /*renderer_enacted=*/false));
+  remote->OnEagerViewportHeuristicTriggered(std::move(targets));
+  remote.FlushForTesting();
+  EXPECT_EQ(preloading_data->GetPredictionsSizeForTesting(), 0u);
+}
+
 }  // namespace
 }  // namespace content
diff --git a/content/browser/preloading/prerenderer_impl_browsertest.cc b/content/browser/preloading/prerenderer_impl_browsertest.cc
index f750517..111ef485f 100644
--- a/content/browser/preloading/prerenderer_impl_browsertest.cc
+++ b/content/browser/preloading/prerenderer_impl_browsertest.cc
@@ -7,6 +7,7 @@
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
 #include "base/test/test_future.h"
+#include "content/browser/preloading/prefetch/prefetch_document_manager.h"
 #include "content/browser/preloading/prefetch/prefetch_features.h"
 #include "content/browser/preloading/prefetch/prefetch_match_resolver.h"
 #include "content/browser/preloading/prefetch/prefetch_service.h"
@@ -20,6 +21,7 @@
 #include "content/browser/preloading/prerender/prerender_host_registry.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/isolated_world_ids.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/content_browser_test.h"
@@ -230,6 +232,17 @@
   }
 };
 
+class PrerendererBackForwardCacheTest : public PrerendererImplBrowserTestBase {
+ public:
+  PrerendererBackForwardCacheTest() {
+    feature_list_.InitWithFeatures(
+        {features::kBackForwardCache,
+         features::kPrerender2FallbackPrefetchSpecRules},
+        {features::kBackForwardCacheMemoryControls,
+         blink::features::kLCPTimingPredictorPrerender2});
+  }
+};
+
 class PrerendererImplBrowserTestPrefetchAhead
     : public PrerendererImplBrowserTestBase,
       public ::testing::WithParamInterface<
@@ -1773,5 +1786,41 @@
   ASSERT_EQ(expected, GetObservedRequests());
 }
 
+// Tests that PrerendererImpl::MaybePrerender ignores candidates from an
+// inactive frame in the back/forward cache.
+IN_PROC_BROWSER_TEST_F(PrerendererBackForwardCacheTest,
+                       MaybePrerenderIgnoredWhenInactive) {
+  EXPECT_TRUE(NavigateToURL(shell(), GetUrl("/empty.html")));
+  RenderFrameHostImpl* rfh_a = static_cast<RenderFrameHostImpl*>(
+      web_contents_impl().GetPrimaryMainFrame());
+  const GlobalRenderFrameHostId rfh_id = rfh_a->GetGlobalId();
+  PrerendererImpl& prerenderer_a = GetPrerendererImpl();
+
+  // Navigate away so that rfh_a enters the back/forward cache.
+  EXPECT_TRUE(NavigateToURL(shell(), GetCrossSiteUrl("/empty.html")));
+  rfh_a = static_cast<RenderFrameHostImpl*>(RenderFrameHost::FromID(rfh_id));
+  EXPECT_TRUE(rfh_a);
+  if (!rfh_a) {
+    return;
+  }
+  EXPECT_TRUE(rfh_a->IsInLifecycleState(
+      RenderFrameHost::LifecycleState::kInBackForwardCache));
+  EXPECT_FALSE(rfh_a->IsActive());
+
+  const GURL prerender_url = GetCrossSiteUrl("/title1.html");
+  blink::mojom::SpeculationCandidatePtr candidate =
+      CreateSpeculationCandidate(prerender_url);
+
+  PreloadingPredictor enacting_predictor = GetPredictorForPreloadingTriggerType(
+      PreloadingTriggerType::kSpeculationRule);
+  EXPECT_FALSE(prerenderer_a.MaybePrerender(candidate, enacting_predictor,
+                                            PreloadingConfidence{100}));
+
+  RenderFrameHostImpl* rfh_b = static_cast<RenderFrameHostImpl*>(
+      web_contents_impl().GetPrimaryMainFrame());
+  EXPECT_NE(rfh_a, rfh_b);
+  EXPECT_FALSE(PrefetchDocumentManager::GetForCurrentDocument(rfh_b));
+}
+
 }  // namespace
 }  // namespace content
diff --git a/content/browser/preloading/prerenderer_impl_unittest.cc b/content/browser/preloading/prerenderer_impl_unittest.cc
index efb8f9a4..5903913 100644
--- a/content/browser/preloading/prerenderer_impl_unittest.cc
+++ b/content/browser/preloading/prerenderer_impl_unittest.cc
@@ -355,5 +355,25 @@
   EXPECT_TRUE(registry->FindHostByUrlForTesting(kPrerenderingUrl));
 }
 
+// Tests that PrerendererImpl::MaybePrerender ignores preloading attempts when
+// the associated frame is no longer active (e.g., in back/forward cache).
+TEST_F(PrerendererTest, MaybePrerenderIgnoredWhenInactive) {
+  PrerenderHostRegistry* registry = GetPrerenderHostRegistry();
+  PrerendererImpl prerenderer(*GetRenderFrameHost());
+
+  GetRenderFrameHost()->SetLifecycleState(
+      RenderFrameHostImpl::LifecycleStateImpl::kInBackForwardCache);
+  EXPECT_FALSE(GetRenderFrameHost()->IsActive());
+
+  const GURL kPrerenderingUrl = GetSameOriginUrl("/empty.html");
+  const auto candidate = CreatePrerenderCandidate(kPrerenderingUrl);
+
+  EXPECT_FALSE(prerenderer.MaybePrerender(
+      candidate, content_preloading_predictor::kSpeculationRules,
+      PreloadingConfidence{100}));
+  EXPECT_FALSE(prerenderer.ShouldWaitForPrerenderResult(kPrerenderingUrl));
+  EXPECT_FALSE(registry->FindHostByUrlForTesting(kPrerenderingUrl));
+}
+
 }  // namespace
 }  // namespace content
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.