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

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Navigation
DescriptionInappropriate implementation in Navigation
ComponentNavigation
Bug ClassLogic Error
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
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.
Tracker496645205
Fix commit4747f87461ba (chromium/src) +58/-3
CWECWE-284, CWE-693
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-04-03 (public git)
Disclosed2026-05-05
Patch gap32 days — fix public in source before the advisory

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_origin against each entry’s origin.
Opaque origin
the unique, non-matching origin assigned to sandboxed (e.g. sandbox flag) 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.

Key insight
The function derived the filtering origin from the current URL (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

  1. 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.
  2. Query the Navigation API Script (or the browser building entries) causes GetNavigationApiHistoryEntryVectors to run for that frame with no pending request, so the else branch computes the origin from the URL.
  3. Origin mismatch masked Pre-patch, url::Origin::Create(current_url()) yields the non-opaque a.com origin, so the same-origin filter admits entries that belong to the real origin rather than the opaque one.
  4. 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

An attacker could use a sandboxed (opaque-origin) frame to obtain Navigation API history entries belonging to the frame’s URL-apparent origin, leaking cross-document navigation history/state across the opaque-origin boundary in the renderer-exposed API. The precondition is a sandboxed/opaque-origin document whose URL is textually same-origin with existing entries and a path that reaches the no-request branch. The vendor advisory rates this medium (inappropriate implementation in Navigation) and does not report exploitation in the wild.

Changed Functions

FunctionChangeNotes
TEST_F
content/browser/renderer_host/navigation_controller_impl_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/navigation_controller_impl.cc
  • content/browser/renderer_host/navigation_controller_impl_unittest.cc

Audit Directions

  • Origin from URL instead of committed origin
    Audit for url::Origin::Create(...->current_url()) or similar URL-to-origin reconstructions used in security decisions; they ignore sandboxing/opaqueness and should use GetLastCommittedOrigin().
  • Same-origin filters on exposed lists
    Review 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 handling
    Look for comparison paths that would incorrectly treat a sandboxed document as same-origin with its host, including DSE/NTP and fenced-frame contexts.
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/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

  1. Launch Chrome with sandbox process isolation enabled (kIsolateSandboxedIframes).
  2. An attacker page embeds an iframe to https://victim.com/app.
  3. The subframe loads normally (no sandbox) and sets sensitive state via the Navigation API: navigation.updateCurrentEntry({state: {token: 'SECRET'}}).
  4. The attacker page navigates the subframe to https://victim.com/ugc, which is served with a Content-Security-Policy: sandbox header.
  5. The subframe is now an opaque origin and is placed in a new, isolated renderer process.
  6. The attacker page navigates away, putting the previous page (including the sandboxed subframe) into the BFCache.
  7. The attacker triggers a back navigation, restoring the page from the BFCache.
  8. During activation, the browser computes the pending origin for the subframe as https://victim.com instead of its true opaque origin.
  9. The browser matches the previous /app history entry and sends its state (containing 'SECRET') to the isolated sandboxed process via the SetNavigationApiHistoryEntriesForRestore IPC.
  10. 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.

View on issue tracker