Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Navigation
DescriptionInappropriate implementation in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker496645205
Fix commit4747f87461ba (chromium/src) +58/-3
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

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
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