CVE-2026-7959
Overview
Background
- Navigation API history entries
- the
navigation.entries()list exposed to a document by the web Navigation API, which must only include entries that are same-origin with the current document. - `GetNavigationApiHistoryEntryVectors`
- the browser-side function that builds the back/forward entry arrays for a frame, filtering entries by comparing a
pending_originagainst each entry’s origin. - Opaque origin
- the unique, non-matching origin assigned to sandboxed (e.g.
sandboxflag) or otherwise origin-isolated documents; it is never same-origin with anything, even a document whose URL looks identical. - `url::Origin::Create(GURL)` vs `GetLastCommittedOrigin()`
- the former derives an origin purely from a URL’s scheme/host/port (ignoring sandboxing), while the latter returns the frame’s actual committed origin, which is opaque for sandboxed documents.
Root Cause Analysis
In GetNavigationApiHistoryEntryVectors, when there is no in-flight request, the pending_origin used to filter which history entries are same-origin was computed as url::Origin::Create(node->current_url()). Reconstructing an origin from the current URL discards the document’s real security context: a sandboxed subframe has an opaque committed origin, but its URL (e.g. http://a.com/subframe2) reconstructs into the ordinary http://a.com origin. Consequently the filter would treat the sandboxed frame as same-origin with real a.com entries and include them in the returned back/forward arrays, exposing cross-document history entries to a document that should match nothing.
The fix replaces the URL-derived origin with node->current_frame_host()->GetLastCommittedOrigin(), the frame’s actual origin, which is opaque for the sandboxed case and therefore matches no entries — as the regression test asserts (both arrays empty). This corrects a logic error in origin derivation; it neither tightens nor relaxes a conditional but swaps the source of truth for the origin comparison to the authoritative committed origin.
url::Origin::Create(current_url())), which silently upgrades an opaque/sandboxed origin into a normal one; the fix uses the frame’s real GetLastCommittedOrigin() so opaque origins correctly fail the same-origin match and leak no entries.Attack Path
- Create a sandboxed same-URL frame
An attacker navigates a subframe to a URL that is textually same-origin with existing history entries but commits with an opaque origin via the
sandbox(kOrigin) flag. - Query the Navigation API
Script (or the browser building entries) causes
GetNavigationApiHistoryEntryVectorsto run for that frame with no pendingrequest, so theelsebranch computes the origin from the URL. - Origin mismatch masked
Pre-patch,
url::Origin::Create(current_url())yields the non-opaquea.comorigin, so the same-origin filter admits entries that belong to the real origin rather than the opaque one. - Cross-document entry disclosure The sandboxed document receives back/forward history entries (e.g. URLs/state) it should not be able to observe, an information leak across the sandbox origin boundary (the extent of observable state is inferred; the diff shows the filtering-origin error and the test shows entries should be empty).
Impact Assessment
request branch. The vendor advisory rates this medium (inappropriate implementation in Navigation) and does not report exploitation in the wild.Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fcontent/browser/renderer_host/navigation_controller_impl_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/navigation_controller_impl.cccontent/browser/renderer_host/navigation_controller_impl_unittest.cc
Audit Directions
- Origin from URL instead of committed originAudit for
url::Origin::Create(...->current_url())or similar URL-to-origin reconstructions used in security decisions; they ignore sandboxing/opaqueness and should useGetLastCommittedOrigin(). - Same-origin filters on exposed listsReview other APIs that expose history, entries, or cross-document state and confirm the origin used for the same-origin check is the authoritative committed origin.
- Opaque/sandbox-origin handlingLook for comparison paths that would incorrectly treat a sandboxed document as same-origin with its host, including DSE/NTP and fenced-frame contexts.
Patch
From 4747f87461ba68ec4e2b3a3c4f217d3614b0692d Mon Sep 17 00:00:00 2001 From: Nate Chapin <[email protected]> Date: Fri, 03 Apr 2026 10:14:01 -0700 Subject: [PATCH] Use GetLastCommittedOrigin() instead of constructing from the current url in GetNavigationApiHistoryEntryVectors() Test mostly-written by Gemini. Fixed: 496645205 Change-Id: I59fd55327ee8d67e2ebb733384a41b136f78a17a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7707865 Reviewed-by: Rakina Zata Amni <[email protected]> Commit-Queue: Nate Chapin <[email protected]> Cr-Commit-Position: refs/heads/main@{#1609838} --- diff --git a/content/browser/renderer_host/navigation_controller_impl.cc b/content/browser/renderer_host/navigation_controller_impl.cc index 16248f8..21de0c4 100644 --- a/content/browser/renderer_host/navigation_controller_impl.cc +++ b/content/browser/renderer_host/navigation_controller_impl.cc @@ -5448,9 +5448,9 @@ NavigationControllerImpl::GetNavigationApiHistoryEntryVectors( FrameTreeNode* node, NavigationRequest* request) { - url::Origin pending_origin = request - ? request->GetOriginToCommit().value() - : url::Origin::Create(node->current_url()); + url::Origin pending_origin = + request ? request->GetOriginToCommit().value() + : node->current_frame_host()->GetLastCommittedOrigin(); scoped_refptr<SiteInstance> site_instance = node->current_frame_host()->GetSiteInstance(); diff --git a/content/browser/renderer_host/navigation_controller_impl_unittest.cc b/content/browser/renderer_host/navigation_controller_impl_unittest.cc index 29d5b174..f5a6ffc 100644 --- a/content/browser/renderer_host/navigation_controller_impl_unittest.cc +++ b/content/browser/renderer_host/navigation_controller_impl_unittest.cc @@ -4439,4 +4439,59 @@ BrowserURLHandlerImpl::GetInstance()->RemoveHandlerForTesting(&URLRewriter); } +TEST_F(NavigationControllerTest, NavigationApiHistoryEntries_OpaqueOrigin) { + NavigationControllerImpl& controller = controller_impl(); + + // 1. Navigate main frame to a.com. + const GURL url_a("http://a.com"); + NavigationSimulator::NavigateAndCommitFromDocument(url_a, main_test_rfh()); + EXPECT_EQ(1U, navigation_entry_committed_counter_); + navigation_entry_committed_counter_ = 0; + + // 2. Append a child frame and navigate it to a.com/subframe1. + // This updates the current entry (Entry 1). + const GURL subframe_url1("http://a.com/subframe1"); + TestRenderFrameHost* subframe = static_cast<TestRenderFrameHost*>( + main_test_rfh()->AppendChild("subframe")); + subframe = static_cast<TestRenderFrameHost*>( + NavigationSimulator::NavigateAndCommitFromDocument(subframe_url1, + subframe)); + EXPECT_EQ(1U, navigation_entry_changed_counter_); + navigation_entry_changed_counter_ = 0; + + // 3. Navigate the child frame to a.com/subframe2. + // This creates a new entry (Entry 2). + const GURL subframe_url2("http://a.com/subframe2"); + subframe = static_cast<TestRenderFrameHost*>( + NavigationSimulator::NavigateAndCommitFromDocument(subframe_url2, + subframe)); + EXPECT_EQ(1U, navigation_entry_committed_counter_); + navigation_entry_committed_counter_ = 0; + EXPECT_EQ(2, controller.GetEntryCount()); + + // 4. Navigate the child frame to the same URL but with an opaque origin + // (sandboxed). + blink::FramePolicy sandbox_policy; + sandbox_policy.sandbox_flags = network::mojom::WebSandboxFlags::kOrigin; + subframe->frame_tree_node()->SetPendingFramePolicy(sandbox_policy); + + subframe = static_cast<TestRenderFrameHost*>( + NavigationSimulator::NavigateAndCommitFromDocument(subframe_url2, + subframe)); + EXPECT_EQ(1U, navigation_entry_changed_counter_); + navigation_entry_changed_counter_ = 0; + navigation_entry_committed_counter_ = 0; + + // 5. Call GetNavigationApiHistoryEntryVectors for the child frame. + blink::mojom::NavigationApiHistoryEntryArraysPtr arrays = + controller.GetNavigationApiHistoryEntryVectors( + subframe->frame_tree_node(), nullptr); + + // The returned arrays should be empty because the current origin is opaque, + // preventing it from matching any same-origin entries (even though the URL + // looks same-origin). + EXPECT_TRUE(arrays->back_entries.empty()); + EXPECT_TRUE(arrays->forward_entries.empty()); +} + } // namespace content
Regression Test / PoC
diff --git a/content/browser/renderer_host/navigation_controller_impl_unittest.cc b/content/browser/renderer_host/navigation_controller_impl_unittest.cc
index 29d5b174..f5a6ffc 100644
--- a/content/browser/renderer_host/navigation_controller_impl_unittest.cc
+++ b/content/browser/renderer_host/navigation_controller_impl_unittest.cc
@@ -4439,4 +4439,59 @@
BrowserURLHandlerImpl::GetInstance()->RemoveHandlerForTesting(&URLRewriter);
}
+TEST_F(NavigationControllerTest, NavigationApiHistoryEntries_OpaqueOrigin) {
+ NavigationControllerImpl& controller = controller_impl();
+
+ // 1. Navigate main frame to a.com.
+ const GURL url_a("http://a.com");
+ NavigationSimulator::NavigateAndCommitFromDocument(url_a, main_test_rfh());
+ EXPECT_EQ(1U, navigation_entry_committed_counter_);
+ navigation_entry_committed_counter_ = 0;
+
+ // 2. Append a child frame and navigate it to a.com/subframe1.
+ // This updates the current entry (Entry 1).
+ const GURL subframe_url1("http://a.com/subframe1");
+ TestRenderFrameHost* subframe = static_cast<TestRenderFrameHost*>(
+ main_test_rfh()->AppendChild("subframe"));
+ subframe = static_cast<TestRenderFrameHost*>(
+ NavigationSimulator::NavigateAndCommitFromDocument(subframe_url1,
+ subframe));
+ EXPECT_EQ(1U, navigation_entry_changed_counter_);
+ navigation_entry_changed_counter_ = 0;
+
+ // 3. Navigate the child frame to a.com/subframe2.
+ // This creates a new entry (Entry 2).
+ const GURL subframe_url2("http://a.com/subframe2");
+ subframe = static_cast<TestRenderFrameHost*>(
+ NavigationSimulator::NavigateAndCommitFromDocument(subframe_url2,
+ subframe));
+ EXPECT_EQ(1U, navigation_entry_committed_counter_);
+ navigation_entry_committed_counter_ = 0;
+ EXPECT_EQ(2, controller.GetEntryCount());
+
+ // 4. Navigate the child frame to the same URL but with an opaque origin
+ // (sandboxed).
+ blink::FramePolicy sandbox_policy;
+ sandbox_policy.sandbox_flags = network::mojom::WebSandboxFlags::kOrigin;
+ subframe->frame_tree_node()->SetPendingFramePolicy(sandbox_policy);
+
+ subframe = static_cast<TestRenderFrameHost*>(
+ NavigationSimulator::NavigateAndCommitFromDocument(subframe_url2,
+ subframe));
+ EXPECT_EQ(1U, navigation_entry_changed_counter_);
+ navigation_entry_changed_counter_ = 0;
+ navigation_entry_committed_counter_ = 0;
+
+ // 5. Call GetNavigationApiHistoryEntryVectors for the child frame.
+ blink::mojom::NavigationApiHistoryEntryArraysPtr arrays =
+ controller.GetNavigationApiHistoryEntryVectors(
+ subframe->frame_tree_node(), nullptr);
+
+ // The returned arrays should be empty because the current origin is opaque,
+ // preventing it from matching any same-origin entries (even though the URL
+ // looks same-origin).
+ EXPECT_TRUE(arrays->back_entries.empty());
+ EXPECT_TRUE(arrays->forward_entries.empty());
+}
+
} // namespace content
Original Bug Report
Site Isolation bypass leaking Navigation API state to sandboxed frames
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: During BFCache restore or Prerender activation, the browser incorrectly uses url::Origin::Create to determine a subframe’s origin, ignoring sandbox flags. This causes sensitive Navigation API state from previous non-sandboxed history entries to be sent to isolated sandboxed renderer processes. A compromised sandboxed process can intercept this IPC to steal cross-origin data, bypassing Site Isolation.
Affected files:
content/browser/renderer_host/navigation_controller_impl.cccontent/browser/renderer_host/navigation_request.cc
Estimated timestamp from git blame: 2022-11-09
Summary
A potential vulnerability in Chrome’s session history management could allow sensitive navigation_api_state from a non-sandboxed origin to be leaked to an isolated renderer process hosting a sandboxed version of the same origin. This leak occurs during Back-Forward Cache (BFCache) restore or Prerender activation and constitutes a Site Isolation bypass.
Note: The following steps and analysis are generated by an AI agent (Fortify LLM) and represent a potential vulnerability path derived from static code analysis. A working proof-of-concept has not yet been executed to verify this end-to-end.
Technical Details
When a page is restored from the BFCache or activated from a prerender, the browser calculates which history entries should be available to the window.navigation API in the renderer. This occurs in NavigationControllerImpl::GetNavigationApiHistoryEntryVectors.
During page activation, the NavigationRequest is only tied to the main frame. For subframes, the request argument passed to this function is nullptr (see navigation_request.cc:7111 and 7223).
When the request is null, the browser falls back to calculating the pending_origin based on the frame’s current URL:
// content/browser/renderer_host/navigation_controller_impl.cc:5451
url::Origin pending_origin = request
? request->GetOriginToCommit().value()
: url::Origin::Create(node->current_url());
Using url::Origin::Create on a URL is a known anti-pattern (documented in url/origin.h) because it simply parses the URL and ignores frame-specific context like sandbox flags or CSP headers. If the subframe is sandboxed (e.g., via Content-Security-Policy: sandbox), its actual origin is opaque. However, pending_origin will incorrectly be set to the non-opaque origin of the URL.
Later in the same function, this incorrect pending_origin is used as a security filter when iterating through previous session history entries:
if (!pending_origin.IsSameOriginWith(frame_entry_origin)) {
break;
}
If the subframe previously committed a non-sandboxed entry at the same URL, its frame_entry_origin will match the incorrectly calculated pending_origin. The browser then serializes this previous entry’s navigation_api_state (which may contain sensitive data) and sends it to the renderer via the SetNavigationApiHistoryEntriesForRestore Mojo IPC.
Because the kIsolateSandboxedIframes feature isolates sandboxed frames in their own dedicated processes, this sends cross-origin data across a security boundary into an untrusted, isolated process.
Potential Reproduction Steps
- Launch Chrome with sandbox process isolation enabled (
kIsolateSandboxedIframes). - An attacker page embeds an iframe to
https://victim.com/app. - The subframe loads normally (no sandbox) and sets sensitive state via the Navigation API:
navigation.updateCurrentEntry({state: {token: 'SECRET'}}). - The attacker page navigates the subframe to
https://victim.com/ugc, which is served with aContent-Security-Policy: sandboxheader. - The subframe is now an opaque origin and is placed in a new, isolated renderer process.
- The attacker page navigates away, putting the previous page (including the sandboxed subframe) into the BFCache.
- The attacker triggers a back navigation, restoring the page from the BFCache.
- During activation, the browser computes the pending origin for the subframe as
https://victim.cominstead of its true opaque origin. - The browser matches the previous
/apphistory entry and sends its state (containing'SECRET') to the isolated sandboxed process via theSetNavigationApiHistoryEntriesForRestoreIPC. - A compromised renderer in the sandboxed process can read the IPC payload before Blink discards it, successfully stealing the cross-origin data.
Suggested Fix
In NavigationControllerImpl::GetNavigationApiHistoryEntryVectors, use node->current_origin() instead of url::Origin::Create(node->current_url()) when the request is null.
url::Origin pending_origin = request
? request->GetOriginToCommit().value()
: node->current_origin();
node->current_origin() retrieves the replicated origin from the frame’s state, which correctly reflects whether the origin is opaque due to sandboxing.
Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.