CVE-2025-5281
Overview
Background
- BFCache with `Cache-Control: no-store`
- under
features::kCacheControlNoStoreEnterBackForwardCache, pages served withCache-Control: no-storemay enter the back/forward cache. Whether they can be restored depends on whether their cookies changed while cached; otherwise the eviction reason isNotRestoredReason::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 existingRenderFrameHostand does not create a new document. - `NavigationRequest`
- the browser-side object for a navigation. It decides, through the predicate in this hunk, whether a
CookieChangeListenershould 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.
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
- No-store page is prerendered
a sensitive
Cache-Control: no-storepage, for example an authenticated account page, is loaded through prerendering. Before the patch, noCookieChangeListenerwas created because the frame was not the primary main frame. - 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. - 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.
- 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
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
| Function | Change | Notes |
|---|---|---|
BackForwardCacheBrowserTestWithPrerenderingcontent/browser/back_forward_cache_no_store_browsertest.cc |
modified | |
switchcontent/browser/back_forward_cache_no_store_browsertest.cc |
modified | |
BackForwardCacheBrowserTestAllowCacheControlNoStorecontent/browser/back_forward_cache_no_store_browsertest.cc |
modified |
Files Changed
content/browser/back_forward_cache_no_store_browsertest.cccontent/browser/renderer_host/navigation_request.cc
Audit Directions
- Activation reusing state that was never initializedfind other
NavigationRequest/RenderFrameHostImplsetup gated onIsInPrimaryMainFrame()and skipped forIsPageActivation(). 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 pathscheck 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 predicatessearch for places where
IsInPrimaryMainFrame()was used to mean “main frame” and should also acceptIsInPrerenderedMainFrame()because the page will eventually become primary.
Patch
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)
Regression Test / PoC
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)
Original Bug Report
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
- Visit https://bfcache-speculation-rules.deno.dev/
- Click the Login button
- Click Home
- Click View the secret page
- Click Logout
- 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
- Visit https://bfcache-speculation-rules.deno.dev/
- This time, make sure speculation rules are enabled (click Toggle speculation rules)
- Click the Login button
- Click Home
- Click View the secret page
- Click Logout
- 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
- https://bfcache-speculation-rules.deno.dev/
- https://chromium.googlesource.com/chromium/src/+/9ec5a4888be7a555a95aeb7d88cc7bb603a9f0ca
- https://chromium.googlesource.com/chromium/src/+/HEAD/docs/security/faq.md
- https://chromium.googlesource.com/chromium/src/+log/57c9ed1ccc03628d9088996ad57899deec1a4aee..29c4db2dad1378977a2334ffd62e0a8876677ace
- https://developer.chrome.com/docs/web-platform/bfcache-ccns
- https://developer.mozilla.org/en-US/docs/Web/API/Speculation_Rules_API
- https://g.co/chrome/vrp
- https://www.chromium.org/Home/chromium-security/reporting-security-bugs