Medium watch CVSS 5.4 chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
5.4
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in BFCache
DescriptionInappropriate implementation in BFCache
ComponentBFCache
Sub-componentCCNS
Bug ClassLogic 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.
Tracker417215501
Fix commitc287a87a87fd (chromium/src) +212/-57
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:L/A:N
CISA KEVNot listed
CreditedJesper van den Ende (Pelican Party Studios)
Reported2025-05-12 (per vendor advisory)
Fix landed2025-05-20 (public git)
Disclosed2025-05-27
Patch gap7 days — fix public in source before the advisory

Background

BFCache with `Cache-Control: no-store`
under features::kCacheControlNoStoreEnterBackForwardCache, pages served with Cache-Control: no-store may enter the back/forward cache. Whether they can be restored depends on whether their cookies changed while cached; otherwise the eviction reason is NotRestoredReason::kCacheControlNoStoreCookieModified.
`CookieChangeListener`
a per-document listener, set up during the navigation, that records cookie changes for the page’s URL so BFCache can refuse to restore a no-store page whose cookies have changed.
Prerendering and page activation
a prerendered page is loaded in a hidden main frame (IsInPrerenderedMainFrame()) and later activated into the primary frame tree. Activation (IsPageActivation()) reuses the existing RenderFrameHost and does not create a new document.
`NavigationRequest`
the browser-side object for a navigation. It decides, through the predicate in this hunk, whether a CookieChangeListener should be created for the committing document.

Root Cause Analysis

The predicate in content/browser/renderer_host/navigation_request.cc decides whether to set up a CookieChangeListener. The hunk does not name the enclosing function. Before the patch it required !IsPageActivation() && !IsSameDocument() && IsInPrimaryMainFrame() plus an HTTP(S) URL. A prerender navigation runs in a prerendered main frame, not the primary one, so it never got a listener. Its later activation is a page activation, which is excluded on purpose, as the comment explains, on the assumption that the reused RenderFrameHost “would already have an existing listener”. For prerendered pages that assumption was false. A no-store page that arrived by prerender activation had no cookie tracking at all, so cookie changes before activation or while the page sat in BFCache went unnoticed.

The fix widens the frame condition to (IsInPrimaryMainFrame() || IsInPrerenderedMainFrame()), so the listener is created during the prerender navigation and carried into activation. The new browser test checks that a cookie set before activation now leads to kCacheControlNoStoreCookieModified eviction. The condition is relaxed (more navigations qualify), and the effect is stricter BFCache restoration.

Key insight
The page-activation exclusion assumed that every activated RenderFrameHost already had a cookie listener, but the frame-type check also excluded the prerender navigation that should have created it. Including IsInPrerenderedMainFrame() closes that gap.

Attack Path

  1. No-store page is prerendered a sensitive Cache-Control: no-store page, for example an authenticated account page, is loaded through prerendering. Before the patch, no CookieChangeListener was created because the frame was not the primary main frame.
  2. Activation skips setup the user navigates and the prerender is activated. IsPageActivation() is true, so setup is skipped again and the page becomes primary with no cookie tracking.
  3. Cookies change the user logs out or the session cookie is otherwise changed in another tab, either before activation (as in the new test) or after the page enters BFCache.
  4. Stale restore on Back, BFCache has no record of the cookie change and may restore the no-store page from cache instead of evicting it, showing content from the old session. This depends on the configured no-store BFCache policy level (inferred).

Impact Assessment

This is a browser-process logic flaw in BFCache eligibility. It can make a Cache-Control: no-store page that was entered through prerender activation be restored after its cookies changed. That can expose content from a session that has since ended, such as after logout, to someone using the same browser (inferred). Preconditions are that the no-store BFCache feature is enabled and the page was reached through prerendering. The vendor rates it medium (“Inappropriate implementation in BFCache”), with no in-the-wild exploitation reported.

Changed Functions

FunctionChangeNotes
BackForwardCacheBrowserTestWithPrerendering
content/browser/back_forward_cache_no_store_browsertest.cc
modified
switch
content/browser/back_forward_cache_no_store_browsertest.cc
modified
BackForwardCacheBrowserTestAllowCacheControlNoStore
content/browser/back_forward_cache_no_store_browsertest.cc
modified

Files Changed

  • content/browser/back_forward_cache_no_store_browsertest.cc
  • content/browser/renderer_host/navigation_request.cc

Audit Directions

  • Activation reusing state that was never initialized
    find other NavigationRequest/RenderFrameHostImpl setup gated on IsInPrimaryMainFrame() and skipped for IsPageActivation(). Examples include policy containers, permissions, and per-document listeners. Confirm that the prerender (and fenced-frame) navigation sets them up instead.
  • Restore-time security checks for alternative entry paths
    check that BFCache eviction signals (cookie changes, no-store, Clear-Site-Data, credential changes) also cover pages reached through prerender, portals or other activation flows, not only regular primary-frame navigations.
  • Frame-type predicates
    search for places where IsInPrimaryMainFrame() was used to mean “main frame” and should also accept IsInPrerenderedMainFrame() because the page will eventually become primary.
diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index deb3de2..62f577b2 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -3004,19 +3004,21 @@
   // The `CookieChangeListener` will only be set up if all of these are true:
   // (1) the navigation's protocol is HTTP(s).
   // (2) we allow a document with `Cache-control: no-store` header to
-  // enter BFCache.
+  // enter back/forward.
   // (3) the navigation is neither a same-document navigation nor a page
   // activation, since in these cases, an existing `RenderFrameHost` will be
   // used, and it would already have an existing listener, so we should skip the
   // initialization.
-  // (4) the navigation is a primary main frame navigation, as the cookie
-  // change information will only be used in the inactive document control
-  // logic.
+  // (4) the navigation is a primary main frame navigation or it's for
+  // prerendering a main frame, as the cookie change information will only be
+  // used to determined if a page can be restored from back/forward cache, so
+  // subframe navigation can be ignored.
   return frame_tree_node_->navigator()
              .controller()
              .GetBackForwardCache()
              .should_allow_storing_pages_with_cache_control_no_store() &&
-         !IsPageActivation() && !IsSameDocument() && IsInPrimaryMainFrame() &&
+         !IsPageActivation() && !IsSameDocument() &&
+         (IsInPrimaryMainFrame() || IsInPrerenderedMainFrame()) &&
          common_params_->url.SchemeIsHTTPOrHTTPS();
 }
 
diff --git a/content/browser/back_forward_cache_no_store_browsertest.cc b/content/browser/back_forward_cache_no_store_browsertest.cc
index 9eec1c6..2f482a6 100644
--- a/content/browser/back_forward_cache_no_store_browsertest.cc
+++ b/content/browser/back_forward_cache_no_store_browsertest.cc
@@ -14,10 +14,12 @@
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/public/browser/content_browser_client.h"
+#include "content/public/browser/web_contents.h"
 #include "content/public/common/content_switches.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/prerender_test_util.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "content/shell/browser/shell.h"
 #include "net/base/features.h"
@@ -160,41 +162,111 @@
                     {}, {}, {}, FROM_HERE);
 }
 
-namespace {
+enum class TestNavigationType {
+  kNonPrerender,
+  kPrerender,
+};
+
+class BackForwardCacheBrowserTestWithPrerendering
+    : public BackForwardCacheBrowserTest,
+      public ::testing::WithParamInterface<TestNavigationType> {
+ public:
+  static std::string DescribeParams(
+      const ::testing::TestParamInfo<ParamType>& info) {
+    switch (info.param) {
+      case TestNavigationType::kNonPrerender:
+        return "NonPrerender";
+      case TestNavigationType::kPrerender:
+        return "Prerender";
+    }
+  }
+
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    BackForwardCacheBrowserTest::SetUpCommandLine(command_line);
+    // `prerender_helper_` has a ScopedFeatureList so we needed to delay its
+    // creation until the BackForwardCacheBrowserTest finishes setting up the
+    // ScopedFeatureList.
+    prerender_helper_ =
+        std::make_unique<test::PrerenderTestHelper>(base::BindRepeating(
+            &BackForwardCacheBrowserTestWithPrerendering::GetWebContents,
+            base::Unretained(this)));
+    prerender_helper_->RegisterServerRequestMonitor(embedded_test_server());
+  }
+
+  WebContents* GetWebContents() { return web_contents(); }
+
+  test::PrerenderTestHelper& prerender_helper() { return *prerender_helper_; }
+
+ protected:
+  void NavigateToPageWithResponseFromMainWebContents(
+      GURL& url,
+      net::test_server::ControllableHttpResponse& response,
+      const char* response_bytes) {
+    switch (GetParam()) {
+      case TestNavigationType::kNonPrerender: {
+        TestNavigationObserver observer(web_contents());
+        shell()->LoadURL(url);
+        response.WaitForRequest();
+        response.Send(response_bytes);
+        response.Done();
+        observer.Wait();
+        break;
+      }
+      case TestNavigationType::kPrerender: {
+        prerender_helper().AddPrerenderAsync(url);
+        response.WaitForRequest();
+        response.Send(response_bytes);
+        response.Done();
+        TestActivationManager activation_manager(web_contents(), url);
+        ASSERT_TRUE(ExecJs(web_contents()->GetPrimaryMainFrame(),
+                           JsReplace("location = $1", url)));
+        activation_manager.WaitForNavigationFinished();
+        EXPECT_TRUE(activation_manager.was_activated());
+        break;
+      }
+    }
+  }
+
+ private:
+  std::unique_ptr<test::PrerenderTestHelper> prerender_helper_;
+};
 
 class BackForwardCacheBrowserTestAllowCacheControlNoStore
-    : public BackForwardCacheBrowserTest {
+    : public BackForwardCacheBrowserTestWithPrerendering {
  protected:
   void SetUpCommandLine(base::CommandLine* command_line) override {
     EnableFeatureAndSetParams(features::kBackForwardCache, "", "");
     EnableFeatureAndSetParams(
         features::kCacheControlNoStoreEnterBackForwardCache, "level",
         "store-and-evict");
-    BackForwardCacheBrowserTest::SetUpCommandLine(command_line);
+    BackForwardCacheBrowserTestWithPrerendering::SetUpCommandLine(command_line);
   }
 };
 
-}  // namespace
+INSTANTIATE_TEST_SUITE_P(
+    All,
+    BackForwardCacheBrowserTestAllowCacheControlNoStore,
+    ::testing::Values(TestNavigationType::kNonPrerender,
+                      TestNavigationType::kPrerender),
+    &BackForwardCacheBrowserTestAllowCacheControlNoStore::DescribeParams);
 
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // but does not get restored and gets evicted.
-IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTestAllowCacheControlNoStore,
+IN_PROC_BROWSER_TEST_P(BackForwardCacheBrowserTestAllowCacheControlNoStore,
                        PagesWithCacheControlNoStoreEnterBfcacheAndEvicted) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
 
   // 1) Load the document and specify no-store for the main resource.
-  TestNavigationObserver observer(web_contents());
-  shell()->LoadURL(url_a);
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
-  response.WaitForRequest();
-  response.Send(kResponseWithNoCache);
-  response.Done();
-  observer.Wait();
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
   // 2) Navigate away. |rfh_a| should enter the bfcache.
@@ -216,12 +288,14 @@
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and if a cookie is modified while it is in bfcache via JavaScript, gets
 // evicted with cookie modified marked.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreCookieModifiedThroughJavaScript) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
@@ -231,13 +305,9 @@
   Shell* tab_to_modify_cookie = CreateBrowser();
 
   // 1) Load the document and specify no-store for the main resource.
-  TestNavigationObserver observer(tab_to_be_bfcached->web_contents());
-  tab_to_be_bfcached->LoadURL(url_a);
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
-  response.WaitForRequest();
-  response.Send(kResponseWithNoCache);
-  response.Done();
-  observer.Wait();
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
   // 2) Set a normal cookie from JavaScript.
@@ -269,24 +339,89 @@
                   BlockListedFeatures()));
 }
 
+// Test that a prerendered page with cache-control:no-store enters bfcache with
+// the flag on, and if a cookie is modified before the prerendered page is
+// activated via JavaScript, gets evicted with cookie modified marked.
+IN_PROC_BROWSER_TEST_F(
+    BackForwardCacheBrowserTestAllowCacheControlNoStore,
+    PagesWithCacheControlNoStoreCookieModifiedBeforePrerendererActivationThroughJavaScript) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
+
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title3.html"));
+
+  Shell* tab_to_be_bfcached = shell();
+  Shell* tab_to_modify_cookie = CreateBrowser();
+
+  // 1) Prerender the document and specify no-store for the main resource.
+  prerender_helper().AddPrerenderAsync(url_a);
+  response.WaitForRequest();
+  response.Send(kResponseWithNoCache);
+  response.Done();
+  TestActivationManager activation_manager(web_contents(), url_a);
+  ASSERT_TRUE(ExecJs(web_contents()->GetPrimaryMainFrame(),
+                     JsReplace("location = $1", url_a)));
+  ASSERT_TRUE(activation_manager.WaitForAfterChecks());
+
+  // 2) Navigate to a.com in |tab_to_modify_cookie| and modify cookie from
+  // JavaScript before the page is activated.
+  EXPECT_TRUE(NavigateToURL(tab_to_modify_cookie, url_a_2));
+  EXPECT_TRUE(ExecJs(tab_to_modify_cookie, "document.cookie='foo=baz'"));
+  EXPECT_EQ("foo=baz", EvalJs(tab_to_modify_cookie, "document.cookie"));
+
+  // 3) Resume the activation.
+  activation_manager.WaitForNavigationFinished();
+  EXPECT_TRUE(activation_manager.was_activated());
+  RenderFrameHostImplWrapper rfh_a(current_frame_host());
+  rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
+
+  // 4) Navigate away. |rfh_a| should enter bfcache.
+  EXPECT_TRUE(NavigateToURL(tab_to_be_bfcached, url_b));
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // 5) Go back. |rfh_a| should be evicted upon restoration.
+  ASSERT_TRUE(HistoryGoBack(tab_to_be_bfcached->web_contents()));
+
+  EXPECT_EQ("foo=baz", EvalJs(tab_to_be_bfcached, "document.cookie"));
+  ExpectNotRestored({NotRestoredReason::kCacheControlNoStoreCookieModified}, {},
+                    {}, {}, {}, FROM_HERE);
+  // Make sure that the tree result also has the same reason.
+  EXPECT_THAT(GetTreeResult()->GetDocumentResult(),
+              MatchesDocumentResult(
+                  NotRestoredReasons(
+                      {NotRestoredReason::kCacheControlNoStoreCookieModified}),
+                  BlockListedFeatures()));
+}
+
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and if a cookie is modified, it gets evicted with cookie changed, but if
 // navigated away again and navigated back, it gets evicted without cookie
 // change marked.
-IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTestAllowCacheControlNoStore,
+IN_PROC_BROWSER_TEST_P(BackForwardCacheBrowserTestAllowCacheControlNoStore,
                        PagesWithCacheControlNoStoreCookieModifiedBackTwice) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
+  net::test_server::ControllableHttpResponse response_back(
+      embedded_test_server(), "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
-  GURL url_a(embedded_test_server()->GetURL(
-      "a.com", "/set-header?Cache-Control: no-store"));
-  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title1.html"));
-  GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
 
   Shell* tab_to_be_bfcached = shell();
   Shell* tab_to_modify_cookie = CreateBrowser();
 
   // 1) Load the document and specify no-store for the main resource.
-  EXPECT_TRUE(NavigateToURL(tab_to_be_bfcached, url_a));
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
@@ -306,7 +441,12 @@
   EXPECT_EQ("foo=baz", EvalJs(tab_to_modify_cookie, "document.cookie"));
 
   // 5) Go back. |rfh_a| should be evicted upon restoration.
-  ASSERT_TRUE(HistoryGoBack(tab_to_be_bfcached->web_contents()));
+  TestNavigationObserver observer(tab_to_be_bfcached->web_contents());
+  tab_to_be_bfcached->web_contents()->GetController().GoBack();
+  response_back.WaitForRequest();
+  response_back.Send(kResponseWithNoCache);
+  response_back.Done();
+  observer.Wait();
 
   EXPECT_EQ("foo=baz", EvalJs(tab_to_be_bfcached, "document.cookie"));
   ExpectNotRestored({NotRestoredReason::kCacheControlNoStoreCookieModified}, {},
@@ -337,12 +477,14 @@
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and even if a cookie is modified on a different domain than the entry, the
 // entry is not marked as cookie modified.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreCookieModifiedThroughJavaScriptOnDifferentDomain) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
@@ -352,13 +494,9 @@
   Shell* tab_to_modify_cookie = CreateBrowser();
 
   // 1) Load the document and specify no-store for the main resource.
-  TestNavigationObserver observer(tab_to_be_bfcached->web_contents());
-  tab_to_be_bfcached->LoadURL(url_a);
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
-  response.WaitForRequest();
-  response.Send(kResponseWithNoCache);
-  response.Done();
-  observer.Wait();
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
   // 2) Navigate away. |rfh_a| should enter bfcache.
@@ -384,17 +522,21 @@
 
 // Test that a page with cache-control:no-store records other not restored
 // reasons along with kCacheControlNoStore when eviction happens.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreRecordOtherReasonsWhenEvictionHappens) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
-  GURL url_a(embedded_test_server()->GetURL(
-      "a.com", "/set-header?Cache-Control: no-store"));
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
 
   // 1) Load the document and specify no-store for the main resource.
-  EXPECT_TRUE(NavigateToURL(shell(), url_a));
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
@@ -421,6 +563,9 @@
 // Test that a page with cache-control:no-store records other not restored
 // reasons along with kCacheControlNoStore when there are other blocking reasons
 // upon entering bfcache.
+// TODO(crbug.com/417215501): this test is not using `embedded_test_server()` so
+// the current set up for prerendering doesn't work, we will only test the
+// normal navigation.
 IN_PROC_BROWSER_TEST_F(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreRecordOtherReasonsUponEntrance) {
@@ -456,17 +601,21 @@
 
 // Test that a page with cache-control:no-store records eviction reasons along
 // with kCacheControlNoStore when the entry is evicted for other reasons.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreRecordOtherReasonsForEviction) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
-  GURL url_a(embedded_test_server()->GetURL(
-      "a.com", "/set-header?Cache-Control: no-store"));
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
 
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/back_forward_cache_no_store_browsertest.cc b/content/browser/back_forward_cache_no_store_browsertest.cc
index 9eec1c6..2f482a6 100644
--- a/content/browser/back_forward_cache_no_store_browsertest.cc
+++ b/content/browser/back_forward_cache_no_store_browsertest.cc
@@ -14,10 +14,12 @@
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/public/browser/content_browser_client.h"
+#include "content/public/browser/web_contents.h"
 #include "content/public/common/content_switches.h"
 #include "content/public/test/browser_test.h"
 #include "content/public/test/browser_test_utils.h"
 #include "content/public/test/content_browser_test_utils.h"
+#include "content/public/test/prerender_test_util.h"
 #include "content/public/test/test_navigation_observer.h"
 #include "content/shell/browser/shell.h"
 #include "net/base/features.h"
@@ -160,41 +162,111 @@
                     {}, {}, {}, FROM_HERE);
 }
 
-namespace {
+enum class TestNavigationType {
+  kNonPrerender,
+  kPrerender,
+};
+
+class BackForwardCacheBrowserTestWithPrerendering
+    : public BackForwardCacheBrowserTest,
+      public ::testing::WithParamInterface<TestNavigationType> {
+ public:
+  static std::string DescribeParams(
+      const ::testing::TestParamInfo<ParamType>& info) {
+    switch (info.param) {
+      case TestNavigationType::kNonPrerender:
+        return "NonPrerender";
+      case TestNavigationType::kPrerender:
+        return "Prerender";
+    }
+  }
+
+  void SetUpCommandLine(base::CommandLine* command_line) override {
+    BackForwardCacheBrowserTest::SetUpCommandLine(command_line);
+    // `prerender_helper_` has a ScopedFeatureList so we needed to delay its
+    // creation until the BackForwardCacheBrowserTest finishes setting up the
+    // ScopedFeatureList.
+    prerender_helper_ =
+        std::make_unique<test::PrerenderTestHelper>(base::BindRepeating(
+            &BackForwardCacheBrowserTestWithPrerendering::GetWebContents,
+            base::Unretained(this)));
+    prerender_helper_->RegisterServerRequestMonitor(embedded_test_server());
+  }
+
+  WebContents* GetWebContents() { return web_contents(); }
+
+  test::PrerenderTestHelper& prerender_helper() { return *prerender_helper_; }
+
+ protected:
+  void NavigateToPageWithResponseFromMainWebContents(
+      GURL& url,
+      net::test_server::ControllableHttpResponse& response,
+      const char* response_bytes) {
+    switch (GetParam()) {
+      case TestNavigationType::kNonPrerender: {
+        TestNavigationObserver observer(web_contents());
+        shell()->LoadURL(url);
+        response.WaitForRequest();
+        response.Send(response_bytes);
+        response.Done();
+        observer.Wait();
+        break;
+      }
+      case TestNavigationType::kPrerender: {
+        prerender_helper().AddPrerenderAsync(url);
+        response.WaitForRequest();
+        response.Send(response_bytes);
+        response.Done();
+        TestActivationManager activation_manager(web_contents(), url);
+        ASSERT_TRUE(ExecJs(web_contents()->GetPrimaryMainFrame(),
+                           JsReplace("location = $1", url)));
+        activation_manager.WaitForNavigationFinished();
+        EXPECT_TRUE(activation_manager.was_activated());
+        break;
+      }
+    }
+  }
+
+ private:
+  std::unique_ptr<test::PrerenderTestHelper> prerender_helper_;
+};
 
 class BackForwardCacheBrowserTestAllowCacheControlNoStore
-    : public BackForwardCacheBrowserTest {
+    : public BackForwardCacheBrowserTestWithPrerendering {
  protected:
   void SetUpCommandLine(base::CommandLine* command_line) override {
     EnableFeatureAndSetParams(features::kBackForwardCache, "", "");
     EnableFeatureAndSetParams(
         features::kCacheControlNoStoreEnterBackForwardCache, "level",
         "store-and-evict");
-    BackForwardCacheBrowserTest::SetUpCommandLine(command_line);
+    BackForwardCacheBrowserTestWithPrerendering::SetUpCommandLine(command_line);
   }
 };
 
-}  // namespace
+INSTANTIATE_TEST_SUITE_P(
+    All,
+    BackForwardCacheBrowserTestAllowCacheControlNoStore,
+    ::testing::Values(TestNavigationType::kNonPrerender,
+                      TestNavigationType::kPrerender),
+    &BackForwardCacheBrowserTestAllowCacheControlNoStore::DescribeParams);
 
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // but does not get restored and gets evicted.
-IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTestAllowCacheControlNoStore,
+IN_PROC_BROWSER_TEST_P(BackForwardCacheBrowserTestAllowCacheControlNoStore,
                        PagesWithCacheControlNoStoreEnterBfcacheAndEvicted) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
 
   // 1) Load the document and specify no-store for the main resource.
-  TestNavigationObserver observer(web_contents());
-  shell()->LoadURL(url_a);
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
-  response.WaitForRequest();
-  response.Send(kResponseWithNoCache);
-  response.Done();
-  observer.Wait();
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
   // 2) Navigate away. |rfh_a| should enter the bfcache.
@@ -216,12 +288,14 @@
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and if a cookie is modified while it is in bfcache via JavaScript, gets
 // evicted with cookie modified marked.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreCookieModifiedThroughJavaScript) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
@@ -231,13 +305,9 @@
   Shell* tab_to_modify_cookie = CreateBrowser();
 
   // 1) Load the document and specify no-store for the main resource.
-  TestNavigationObserver observer(tab_to_be_bfcached->web_contents());
-  tab_to_be_bfcached->LoadURL(url_a);
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
-  response.WaitForRequest();
-  response.Send(kResponseWithNoCache);
-  response.Done();
-  observer.Wait();
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
   // 2) Set a normal cookie from JavaScript.
@@ -269,24 +339,89 @@
                   BlockListedFeatures()));
 }
 
+// Test that a prerendered page with cache-control:no-store enters bfcache with
+// the flag on, and if a cookie is modified before the prerendered page is
+// activated via JavaScript, gets evicted with cookie modified marked.
+IN_PROC_BROWSER_TEST_F(
+    BackForwardCacheBrowserTestAllowCacheControlNoStore,
+    PagesWithCacheControlNoStoreCookieModifiedBeforePrerendererActivationThroughJavaScript) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
+  ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
+
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title3.html"));
+
+  Shell* tab_to_be_bfcached = shell();
+  Shell* tab_to_modify_cookie = CreateBrowser();
+
+  // 1) Prerender the document and specify no-store for the main resource.
+  prerender_helper().AddPrerenderAsync(url_a);
+  response.WaitForRequest();
+  response.Send(kResponseWithNoCache);
+  response.Done();
+  TestActivationManager activation_manager(web_contents(), url_a);
+  ASSERT_TRUE(ExecJs(web_contents()->GetPrimaryMainFrame(),
+                     JsReplace("location = $1", url_a)));
+  ASSERT_TRUE(activation_manager.WaitForAfterChecks());
+
+  // 2) Navigate to a.com in |tab_to_modify_cookie| and modify cookie from
+  // JavaScript before the page is activated.
+  EXPECT_TRUE(NavigateToURL(tab_to_modify_cookie, url_a_2));
+  EXPECT_TRUE(ExecJs(tab_to_modify_cookie, "document.cookie='foo=baz'"));
+  EXPECT_EQ("foo=baz", EvalJs(tab_to_modify_cookie, "document.cookie"));
+
+  // 3) Resume the activation.
+  activation_manager.WaitForNavigationFinished();
+  EXPECT_TRUE(activation_manager.was_activated());
+  RenderFrameHostImplWrapper rfh_a(current_frame_host());
+  rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
+
+  // 4) Navigate away. |rfh_a| should enter bfcache.
+  EXPECT_TRUE(NavigateToURL(tab_to_be_bfcached, url_b));
+  EXPECT_TRUE(rfh_a->IsInBackForwardCache());
+
+  // 5) Go back. |rfh_a| should be evicted upon restoration.
+  ASSERT_TRUE(HistoryGoBack(tab_to_be_bfcached->web_contents()));
+
+  EXPECT_EQ("foo=baz", EvalJs(tab_to_be_bfcached, "document.cookie"));
+  ExpectNotRestored({NotRestoredReason::kCacheControlNoStoreCookieModified}, {},
+                    {}, {}, {}, FROM_HERE);
+  // Make sure that the tree result also has the same reason.
+  EXPECT_THAT(GetTreeResult()->GetDocumentResult(),
+              MatchesDocumentResult(
+                  NotRestoredReasons(
+                      {NotRestoredReason::kCacheControlNoStoreCookieModified}),
+                  BlockListedFeatures()));
+}
+
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and if a cookie is modified, it gets evicted with cookie changed, but if
 // navigated away again and navigated back, it gets evicted without cookie
 // change marked.
-IN_PROC_BROWSER_TEST_F(BackForwardCacheBrowserTestAllowCacheControlNoStore,
+IN_PROC_BROWSER_TEST_P(BackForwardCacheBrowserTestAllowCacheControlNoStore,
                        PagesWithCacheControlNoStoreCookieModifiedBackTwice) {
+  net::test_server::ControllableHttpResponse response(embedded_test_server(),
+                                                      "/title1.html");
+  net::test_server::ControllableHttpResponse response_back(
+      embedded_test_server(), "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
-  GURL url_a(embedded_test_server()->GetURL(
-      "a.com", "/set-header?Cache-Control: no-store"));
-  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title1.html"));
-  GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
+  GURL url_b(embedded_test_server()->GetURL("b.com", "/title2.html"));
 
   Shell* tab_to_be_bfcached = shell();
   Shell* tab_to_modify_cookie = CreateBrowser();
 
   // 1) Load the document and specify no-store for the main resource.
-  EXPECT_TRUE(NavigateToURL(tab_to_be_bfcached, url_a));
+  NavigateToPageWithResponseFromMainWebContents(url_a, response,
+                                                kResponseWithNoCache);
   RenderFrameHostImplWrapper rfh_a(current_frame_host());
   rfh_a->GetBackForwardCacheMetrics()->SetObserverForTesting(this);
 
@@ -306,7 +441,12 @@
   EXPECT_EQ("foo=baz", EvalJs(tab_to_modify_cookie, "document.cookie"));
 
   // 5) Go back. |rfh_a| should be evicted upon restoration.
-  ASSERT_TRUE(HistoryGoBack(tab_to_be_bfcached->web_contents()));
+  TestNavigationObserver observer(tab_to_be_bfcached->web_contents());
+  tab_to_be_bfcached->web_contents()->GetController().GoBack();
+  response_back.WaitForRequest();
+  response_back.Send(kResponseWithNoCache);
+  response_back.Done();
+  observer.Wait();
 
   EXPECT_EQ("foo=baz", EvalJs(tab_to_be_bfcached, "document.cookie"));
   ExpectNotRestored({NotRestoredReason::kCacheControlNoStoreCookieModified}, {},
@@ -337,12 +477,14 @@
 // Test that a page with cache-control:no-store enters bfcache with the flag on,
 // and even if a cookie is modified on a different domain than the entry, the
 // entry is not marked as cookie modified.
-IN_PROC_BROWSER_TEST_F(
+IN_PROC_BROWSER_TEST_P(
     BackForwardCacheBrowserTestAllowCacheControlNoStore,
     PagesWithCacheControlNoStoreCookieModifiedThroughJavaScriptOnDifferentDomain) {
   net::test_server::ControllableHttpResponse response(embedded_test_server(),
                                                       "/title1.html");
   ASSERT_TRUE(embedded_test_server()->Start());
+  GURL url_initial(embedded_test_server()->GetURL("a.com", "/title3.html"));
+  EXPECT_TRUE(NavigateToURL(shell(), url_initial));
 
   GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
   GURL url_a_2(embedded_test_server()->GetURL("a.com", "/title2.html"));
@@ -352,13 +494,9 @@
   Shell* tab_to_modify_cookie = CreateBrowser();
... (truncated)
Loading diff…

Original Bug Report

reported by je...@gmail.com

Speculation rules conflict with BFCache, causing potentially sensitive pages to be cached when they shouldn't

Security Bug

Important: Please do not change the component of this bug manually.

Please READ THIS FAQ before filing a bug: https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md

Please see the following link for instructions on filing security bugs: https://www.chromium.org/Home/chromium-security/reporting-security-bugs

Reports may be eligible for reward payments under the Chrome VRP: https://g.co/chrome/vrp

NOTE: Security bugs are normally made public once a fix has been widely deployed.


VULNERABILITY DETAILS

Normally bfcache is cleared on pages with a Cache-Control: no-cache header when cookies change. See https://developer.chrome.com/docs/web-platform/bfcache-ccns for details. However, it seems like the Speculation Rules Api conflicts with this mechanism, causing pages to remain cached after logging out from a site for example.

VERSION

Chrome Version: 123.0.6300.0 (Developer Build) (arm64)

Operating System: macOS 15.4.1 (24E263)

A bisect gives the following info:

You are probably looking for a change made after 1260232 (known good), but no later than 1260262 (first known bad).

CHANGELOG URL: https://chromium.googlesource.com/chromium/src/+log/57c9ed1ccc03628d9088996ad57899deec1a4aee..29c4db2dad1378977a2334ffd62e0a8876677ace

And this commit seems to be the most likely culprit, it is the only one touching bfcache as far as I can tell: https://chromium.googlesource.com/chromium/src/+/9ec5a4888be7a555a95aeb7d88cc7bb603a9f0ca

REPRODUCTION CASE

First let’s check the expected behavior

  1. Visit https://bfcache-speculation-rules.deno.dev/
  2. Click the Login button
  3. Click Home
  4. Click View the secret page
  5. Click Logout
  6. Click the browser back button

Observe how the page now shows ‘Access restricted’, as expected since the user has been logged out.

Now for the actual behavior with speculation rules enabled

  1. Visit https://bfcache-speculation-rules.deno.dev/
  2. This time, make sure speculation rules are enabled (click Toggle speculation rules)
  3. Click the Login button
  4. Click Home
  5. Click View the secret page
  6. Click Logout
  7. Click the browser back button

This time, the secret content is still visible, presumably due to bfcache showing the cached content. Only after refreshing the page will it show the ‘Access restricted’ message.

CREDIT INFORMATION

Externally reported security bugs may appear in Chrome release notes. If this bug is included, how would you like to be credited?

Reporter credit: Jesper van den Ende - Pelican Party Studios

View on issue tracker