Medium watch CVSS 4.3 chrome Info Disclosure 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactObservable discrepancy in Navigation
DescriptionObservable discrepancy in Navigation
ComponentNavigation
Bug ClassInfo Disclosure
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.
Tracker513608513
Fix commit62029e959eeb (chromium/src) +141/-15
CWECWE-203
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-15 (per vendor advisory)
Fix landed2026-07-31 (public git)
Disclosed2026-09-08
Patch gap39 days — fix public in source before the advisory

Background

Same-URL replacement
When a navigation targets the URL already shown in a frame, Chrome may convert it into a replacement of the current history entry instead of appending a new one, so session history does not grow.
Error document / opaque origin
A frame whose load fails (e.g. blocked by X-Frame-Options) commits an error page whose committed origin is opaque, so IsSameOriginWith against any real initiator origin always returns false.
`InitiatorMayObserveSameUrlReplacement`
The helper added by this patch that decides whether a navigation’s initiator is same-origin enough to the target frame to be permitted to cause (and thus observe) a same-URL replacement.
Session-history-length side channel
Whether a navigation replaces the current entry or adds a new one changes the observable session history length, which an initiator can read.

Root Cause Analysis

Before the patch, the same-URL replacement path (enclosing function NavigationRequest::ShouldReplaceCurrentEntryForSameUrlNavigation (inferred), not named in the @@ hunk header) gated replacement on the initiator being same-origin to the target frame via common_params_->initiator_origin->IsSameOriginWith(frame_tree_node_->current_origin()), but bypassed that gate entirely whenever the target was an error document (!frame_tree_node_->current_frame_host()->IsErrorDocument() && ...). Because an error page’s committed origin is opaque and never matches any initiator, the exemption was intended to let an embedder retry its own failed load, but as written it applied to any initiator, including cross-origin ones. As a result a cross-origin initiator could cause replacement on an error page conditioned solely on whether the new URL equaled the frame’s failed URL, and could then observe the resulting difference in session history length — violating the invariant that the replacement decision must not depend on a cross-origin frame’s URL. Separately, the same-URL branch of NavigationRequest::ShouldReplaceCurrentEntryForFailedNavigation (inferred enclosing name) had no initiator-origin check at all.

The fix routes both branches through the new InitiatorMayObserveSameUrlReplacement, which returns true only when there is no initiator origin, when the initiator is same-origin to current_rfh->GetLastCommittedOrigin(), or — for an error document — when the initiator is same-origin to url::Origin::Create(current_rfh->GetLastCommittedURL()) (the origin of the URL that failed to load). This tightens the error-page path from a blanket exemption to a same-origin comparison against the failed URL, so the embedder’s own retry still replaces while a cross-origin initiator does not.

Key insight
The single mistake was exempting error documents from the initiator/target same-origin check outright, which let a cross-origin initiator’s replacement outcome vary with a cross-origin frame’s failed URL; the fix keeps the retry use case working by comparing the initiator against the failed URL’s origin instead of skipping the check.

Attack Path

  1. Create an error-document target An attacker page navigates a cross-origin subframe it controls to a victim URL that fails to load (e.g. an X-Frame-Options: DENY resource), committing an error page whose committed origin is opaque while GetLastCommittedURL() retains the failed URL.
  2. Trigger a same-URL navigation of the error page The attacker (cross-origin initiator) navigates the frame again to a guessed URL; pre-patch, the IsErrorDocument() exemption skipped the cross-origin check, so replacement occurred if and only if the guessed URL equaled the frame’s failed URL, whereas a non-matching URL added a new entry.
  3. Observe the history-length difference The attacker reads the session history length; per the shipped test, a replacement (no growth) versus a new entry (growth) reveals whether the guessed URL matched the cross-origin frame’s failed URL, giving a per-guess yes/no oracle.
  4. Infer the frame's URL By repeating the guess-and-observe step the attacker can confirm or recover the exact current URL of the cross-origin frame (inferred; the removed comment cites this exact concern via crbug/1208614).

Impact Assessment

An attacker who can initiate navigations on a frame showing an error page gains a cross-origin information-disclosure oracle: the observable difference between replacement and a new history entry (i.e. session history length) reveals whether a supplied URL matches the frame’s failed/current URL, and thereby leaks the cross-origin frame’s URL. The vendor advisory rates this medium severity and does not report in-the-wild exploitation; NVD scores it CVSS 4.3. The advisory classes this as InfoDisclosure / “Observable discrepancy in Navigation” and states no exploited-in-the-wild status or severity here. Any escalation beyond inferring the cross-origin frame’s URL is not demonstrated by the patch (inferred).

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/navigation_request.cc
modified

Files Changed

  • content/browser/renderer_host/navigation_controller_impl_browsertest.cc
  • content/browser/renderer_host/navigation_request.cc
  • content/browser/renderer_host/navigation_request.h
  • content/browser/site_per_process_browsertest.cc

Audit Directions

  • Origin-check exemptions for opaque/error origins
    Audit every place that skips a same-origin comparison because a document is an error page or otherwise has an opaque origin; substituting the origin of the failed/intended URL, rather than skipping the check, is the correct pattern.
  • Replacement / history-length decisions gated on cross-origin state
    Review other ShouldReplace* and history-entry decisions in navigation_request.cc for branches that vary observable session history based on a cross-origin frame’s URL without an initiator-origin guard, as ShouldReplaceCurrentEntryForFailedNavigation did.
  • Navigation observable side channels
    Look for any navigation behavior (entry count, history.length, replacement, timing) whose outcome depends on a cross-origin frame’s URL or state, which can form a per-guess disclosure oracle even without a memory-safety bug.
From 62029e959eeb925cc6f70f436fd639553ae03aa0 Mon Sep 17 00:00:00 2001
From: Zainab Rizvi <[email protected]>
Date: Thu, 30 Jul 2026 18:18:20 -0700
Subject: [PATCH] Restrict same-URL replacement of error pages to same-origin

ShouldReplaceCurrentEntryForSameUrlNavigation() compares the initiator
origin against the target frame's origin before allowing same-URL
replacement, but skipped that comparison entirely when the target frame
was showing an error page, because an error page commits with an opaque
origin and would otherwise always appear cross-origin to its initiator.

Replace the blanket exemption with a comparison against the origin
derived from the URL that failed to load, so that an initiator that
would have been same-origin to the document had it loaded successfully
(typically the embedder retrying its own failed load) still gets
replacement, while a cross-origin initiator does not.

Apply the same comparison to the same-URL branch of
ShouldReplaceCurrentEntryForFailedNavigation(), which previously had no
initiator-origin check at all, and factor the shared logic into
InitiatorMayObserveSameUrlReplacement().

Also update ProcessTransferAfterError in site_per_process_browsertest.cc
to account for same-URL replacement denial for cross-origin initiators
on error pages.

TAG=agy
CONV=a9d68ca9-86bc-485c-8a5c-4c724d101d89

Bug: 513608513
Change-Id: Iee083799b09f3fb3e5a1c65d47379ac102a92cb4
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8162252
Reviewed-by: Arthur Sonzogni <[email protected]>
Commit-Queue: Zainab Rizvi <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1671572}
---
diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index 35f7207..6a62ef7 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -11619,15 +11619,9 @@
     return false;
 
   // If the initiating frame is cross-origin to the target frame, do not
-  // replace. Replacing in this case can be used to guess the exact current url
-  // of a cross-origin frame, see https://crbug.com/1208614. Exempt error pages
-  // from this rule so that we don't leave an error page in the back/forward
-  // list if a cross-origin iframe happens to successfully re-naivgate a frame
-  // that had previously failed.
-  if (!frame_tree_node_->current_frame_host()->IsErrorDocument() &&
-      common_params_->initiator_origin &&
-      !common_params_->initiator_origin->IsSameOriginWith(
-          frame_tree_node_->current_origin())) {
+  // replace the history entry for same-URL navigations to prevent observable
+  // differences in session history length.
+  if (!InitiatorMayObserveSameUrlReplacement()) {
     return false;
   }
 
@@ -11705,13 +11699,42 @@
   //   navigations to reload or replacement), those compare against the initial
   //   URL instead of the final URL, which is what we're using here. Also, this
   //   is using the "loading URL", since that is the URL that was used in the
-  //   renderer before we moved the replacement conversion here.
+  //   renderer before we moved the replacement conversion here. As in
+  //   ShouldReplaceCurrentEntryForSameUrlNavigation(), only replace for the
+  //   same-URL case when the initiator is same-origin to the target frame.
   // TODO(crbug.com/40755155): Reconsider whether these two cases should
   // do replacement or not, since we're just preserving old behavior here.
   return is_reload_or_history ||
          (common_params_->url ==
-          GetLastLoadingURLInRendererForNavigationReplacement(
-              frame_tree_node_->current_frame_host()));
+              GetLastLoadingURLInRendererForNavigationReplacement(
+                  frame_tree_node_->current_frame_host()) &&
+          InitiatorMayObserveSameUrlReplacement());
+}
+
+bool NavigationRequest::InitiatorMayObserveSameUrlReplacement() const {
+  if (!common_params_->initiator_origin) {
+    return true;
+  }
+
+  RenderFrameHostImpl* current_rfh = frame_tree_node_->current_frame_host();
+  if (common_params_->initiator_origin->IsSameOriginWith(
+          current_rfh->GetLastCommittedOrigin())) {
+    return true;
+  }
+
+  // If the current document is an error page, its origin is opaque, so the
+  // comparison above will fail even when the initiator would have been
+  // same-origin to the document had it loaded successfully. To allow such an
+  // initiator to retry the failed load without leaving the error page in the
+  // back/forward list, also compare against the origin derived from the URL
+  // that failed to load.
+  if (current_rfh->IsErrorDocument() &&
+      common_params_->initiator_origin->IsSameOriginWith(
+          url::Origin::Create(current_rfh->GetLastCommittedURL()))) {
+    return true;
+  }
+
+  return false;
 }
 
 const std::optional<FencedFrameProperties>&
diff --git a/content/browser/renderer_host/navigation_request.h b/content/browser/renderer_host/navigation_request.h
index 1696ecd20..192469b 100644
--- a/content/browser/renderer_host/navigation_request.h
+++ b/content/browser/renderer_host/navigation_request.h
@@ -2562,6 +2562,14 @@
   // when an error page is about to be committed.
   bool ShouldReplaceCurrentEntryForFailedNavigation() const;
 
+  // Whether the initiator of this navigation should be allowed to observe that
+  // a same-URL navigation of the target frame was converted to a replacement.
+  // Used by ShouldReplaceCurrentEntryForSameUrlNavigation() and
+  // ShouldReplaceCurrentEntryForFailedNavigation() to ensure the replacement
+  // decision does not depend on the target frame's URL when the initiator is
+  // cross-origin to it.
+  bool InitiatorMayObserveSameUrlReplacement() const;
+
   // Calculates the origin that this NavigationRequest may commit. See also the
   // comment of GetOriginToCommit(). Performs calculation without information
   // from RenderFrameHostImpl (e.g. CSPs are ignored). Should be used only in
diff --git a/content/browser/renderer_host/navigation_controller_impl_browsertest.cc b/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
index 9cd59fd..f0fdc66 100644
--- a/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
+++ b/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
@@ -2903,6 +2903,94 @@
   }
 }
 
+// Same-URL navigation of a subframe whose current document is an error page
+// should not do replacement when the initiator is cross-origin to the URL that
+// failed to load. The replacement decision must not depend on whether the new
+// URL matches the failed URL, otherwise the initiator could observe a
+// difference in history.length between matching and non-matching navigations.
+// However, an initiator that is same-origin to the failed URL should still get
+// replacement, so that retrying a failed load doesn't leave the error page in
+// the back/forward list.
+IN_PROC_BROWSER_TEST_P(NavigationControllerBrowserTest,
+                       ErrorPageReplacementSubframeCrossOriginInitiator) {
+  NavigationController& controller = shell()->web_contents()->GetController();
+  // Navigate to a page on a.com with a same-origin iframe.
+  GURL main_url = embedded_test_server()->GetURL(
+      "a.com", "/navigation_controller/page_with_iframe_simple.html");
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  EXPECT_EQ(1, controller.GetEntryCount());
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  ASSERT_EQ(1U, root->child_count());
+  FrameTreeNode* subframe = root->child_at(0);
+
+  // 1) The parent navigates the subframe to a cross-origin (b.com) URL that is
+  // blocked by X-Frame-Options. This commits an error page and adds a new
+  // entry.
+  GURL blocked_url_b =
+      embedded_test_server()->GetURL("b.com", "/x-frame-options-deny.html");
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_b));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(2, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+    EXPECT_EQ(blocked_url_b,
+              subframe->current_frame_host()->GetLastCommittedURL());
+  }
+
+  // 2) The parent navigates the subframe to the same b.com URL again. The
+  // initiator (a.com) is cross-origin to the failed URL (b.com), so this must
+  // not replace the current entry even though the URLs match. The result must
+  // be the same as if the parent had navigated to a different b.com URL.
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_b));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(3, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+  }
+
+  // 3) The parent navigates the subframe to a same-origin (a.com) URL that is
+  // also blocked by X-Frame-Options. This adds a new entry.
+  GURL blocked_url_a =
+      embedded_test_server()->GetURL("a.com", "/x-frame-options-deny.html");
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_a));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(4, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+    EXPECT_EQ(blocked_url_a,
+              subframe->current_frame_host()->GetLastCommittedURL());
+  }
+
+  // 4) The parent navigates the subframe to the same a.com URL again. The
+  // initiator (a.com) is same-origin to the failed URL, so this still does
+  // replacement.
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_a));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_AUTO_SUBFRAME, capturer.navigation_type());
+    EXPECT_TRUE(capturer.did_replace_entry());
+    EXPECT_EQ(4, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+  }
+}
+
 // Various tests for navigation type classifications. TODO(avi): It's rather
 // bogus that the same info is in two different enums; http://crbug.com/453555.
 
diff --git a/content/browser/site_per_process_browsertest.cc b/content/browser/site_per_process_browsertest.cc
index ea9f3fff..487cedc 100644
--- a/content/browser/site_per_process_browsertest.cc
+++ b/content/browser/site_per_process_browsertest.cc
@@ -1784,9 +1784,16 @@
   EXPECT_NE(shell()->web_contents()->GetSiteInstance(),
             child->current_frame_host()->GetSiteInstance());
 
-  // Make sure that the navigation replaced the error page and that going back
-  // ends up on the original site.
-  EXPECT_EQ(2, shell()->web_contents()->GetController().GetEntryCount());
+  // Navigating a subframe displaying an error page for b.com from cross-origin
+  // initiator (a.com) does not replace the entry, preventing cross-origin
+  // history length leaks. Thus, entry count is 3.
+  EXPECT_EQ(3, shell()->web_contents()->GetController().GetEntryCount());
+  {
+    TestNavigationObserver back_load_observer(shell()->web_contents());
+    shell()->web_contents()->GetController().GoBack();
+    back_load_observer.Wait();
+  }
+  // Going back a second time ends up on the original site.
   {
     RenderFrameDeletedObserver deleted_observer(child->current_frame_host());
     TestNavigationObserver back_load_observer(shell()->web_contents());
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/navigation_controller_impl_browsertest.cc b/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
index 9cd59fd..f0fdc66 100644
--- a/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
+++ b/content/browser/renderer_host/navigation_controller_impl_browsertest.cc
@@ -2903,6 +2903,94 @@
   }
 }
 
+// Same-URL navigation of a subframe whose current document is an error page
+// should not do replacement when the initiator is cross-origin to the URL that
+// failed to load. The replacement decision must not depend on whether the new
+// URL matches the failed URL, otherwise the initiator could observe a
+// difference in history.length between matching and non-matching navigations.
+// However, an initiator that is same-origin to the failed URL should still get
+// replacement, so that retrying a failed load doesn't leave the error page in
+// the back/forward list.
+IN_PROC_BROWSER_TEST_P(NavigationControllerBrowserTest,
+                       ErrorPageReplacementSubframeCrossOriginInitiator) {
+  NavigationController& controller = shell()->web_contents()->GetController();
+  // Navigate to a page on a.com with a same-origin iframe.
+  GURL main_url = embedded_test_server()->GetURL(
+      "a.com", "/navigation_controller/page_with_iframe_simple.html");
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  EXPECT_EQ(1, controller.GetEntryCount());
+
+  FrameTreeNode* root = static_cast<WebContentsImpl*>(shell()->web_contents())
+                            ->GetPrimaryFrameTree()
+                            .root();
+  ASSERT_EQ(1U, root->child_count());
+  FrameTreeNode* subframe = root->child_at(0);
+
+  // 1) The parent navigates the subframe to a cross-origin (b.com) URL that is
+  // blocked by X-Frame-Options. This commits an error page and adds a new
+  // entry.
+  GURL blocked_url_b =
+      embedded_test_server()->GetURL("b.com", "/x-frame-options-deny.html");
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_b));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(2, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+    EXPECT_EQ(blocked_url_b,
+              subframe->current_frame_host()->GetLastCommittedURL());
+  }
+
+  // 2) The parent navigates the subframe to the same b.com URL again. The
+  // initiator (a.com) is cross-origin to the failed URL (b.com), so this must
+  // not replace the current entry even though the URLs match. The result must
+  // be the same as if the parent had navigated to a different b.com URL.
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_b));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(3, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+  }
+
+  // 3) The parent navigates the subframe to a same-origin (a.com) URL that is
+  // also blocked by X-Frame-Options. This adds a new entry.
+  GURL blocked_url_a =
+      embedded_test_server()->GetURL("a.com", "/x-frame-options-deny.html");
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_a));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_NEW_SUBFRAME, capturer.navigation_type());
+    EXPECT_FALSE(capturer.did_replace_entry());
+    EXPECT_EQ(4, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+    EXPECT_EQ(blocked_url_a,
+              subframe->current_frame_host()->GetLastCommittedURL());
+  }
+
+  // 4) The parent navigates the subframe to the same a.com URL again. The
+  // initiator (a.com) is same-origin to the failed URL, so this still does
+  // replacement.
+  {
+    FrameNavigateParamsCapturer capturer(subframe);
+    EXPECT_TRUE(
+        NavigateIframeToURL(shell()->web_contents(), "frame", blocked_url_a));
+    capturer.Wait();
+    EXPECT_EQ(NAVIGATION_TYPE_AUTO_SUBFRAME, capturer.navigation_type());
+    EXPECT_TRUE(capturer.did_replace_entry());
+    EXPECT_EQ(4, controller.GetEntryCount());
+    EXPECT_TRUE(subframe->current_frame_host()->IsErrorDocument());
+  }
+}
+
 // Various tests for navigation type classifications. TODO(avi): It's rather
 // bogus that the same info is in two different enums; http://crbug.com/453555.
diff --git a/content/browser/site_per_process_browsertest.cc b/content/browser/site_per_process_browsertest.cc
index ea9f3fff..487cedc 100644
--- a/content/browser/site_per_process_browsertest.cc
+++ b/content/browser/site_per_process_browsertest.cc
@@ -1784,9 +1784,16 @@
   EXPECT_NE(shell()->web_contents()->GetSiteInstance(),
             child->current_frame_host()->GetSiteInstance());
 
-  // Make sure that the navigation replaced the error page and that going back
-  // ends up on the original site.
-  EXPECT_EQ(2, shell()->web_contents()->GetController().GetEntryCount());
+  // Navigating a subframe displaying an error page for b.com from cross-origin
+  // initiator (a.com) does not replace the entry, preventing cross-origin
+  // history length leaks. Thus, entry count is 3.
+  EXPECT_EQ(3, shell()->web_contents()->GetController().GetEntryCount());
+  {
+    TestNavigationObserver back_load_observer(shell()->web_contents());
+    shell()->web_contents()->GetController().GoBack();
+    back_load_observer.Wait();
+  }
+  // Going back a second time ends up on the original site.
   {
     RenderFrameDeletedObserver deleted_observer(child->current_frame_host());
     TestNavigationObserver back_load_observer(shell()->web_contents());
Loading diff…

Original Bug Report

reported by [email protected]

XS-Leak: URL-detection oracle for error documents via history replacement

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A security bypass in the history replacement logic for error documents allows cross-origin sites to use history.length as a URL-equality oracle. This enables attackers to detect sensitive post-redirect URLs of iframes blocked by security headers like X-Frame-Options or CSP.

Affected files:

  • content/browser/renderer_host/navigation_request.cc
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/renderer/render_frame_impl.cc
  • content/browser/renderer_host/navigation_controller_impl.cc
  • content/browser/renderer_host/ancestor_throttle.cc

Estimated timestamp from git blame: 2021-06-30

Summary

Chromium’s logic for determining whether a navigation should replace the current history entry contains a potential security exemption for error documents that can be exploited for information disclosure. This bypass allows a cross-origin attacker to use window.history.length as an oracle to confirm the URL of a frame that failed to load (e.g., due to X-Frame-Options or Content-Security-Policy).

Potential Mechanism

In content/browser/renderer_host/navigation_request.cc, the method ShouldReplaceCurrentEntryForSameUrlNavigation determines if a navigation to the same URL as the current document should replace the existing history entry. A security check was previously added to this method to prevent cross-origin sites from guessing a frame’s URL by observing history length changes (crbug.com/1208614). However, this check is currently bypassed if the current document is an error page:

// content/browser/renderer_host/navigation_request.cc:11052
if (!frame_tree_node_->current_frame_host()->IsErrorDocument() &&
    common_params_->initiator_origin &&
    !common_params_->initiator_origin->IsSameOriginWith(
        frame_tree_node_->current_origin())) {
  return false;
}

When IsErrorDocument() is true, a cross-origin initiator (like attacker.com) can potentially trigger a same-URL navigation that replaces the history entry instead of pushing a new one. For error documents, the browser compares the navigation URL against the URL that failed to load (stored as the ‘unreachable URL’).

Potential Impact

An attacker can potentially leak sensitive, user-specific URLs that result in error pages. For example, a redirect from https://victim.com/dashboard to https://victim.com/u/secret-id which is then blocked by X-Frame-Options: DENY will commit an error document at the secret URL. The attacker can then:

  1. Measure the initial window.history.length of the tab.
  2. Navigate the iframe to a guessed URL (e.g., https://victim.com/u/guess).
  3. If history.length does not increase, the guess is confirmed as the secret URL (history entry was replaced).
  4. If history.length increases, the guess was likely incorrect.

Suggested Potential Steps to Reproduce

  1. Identify a victim site (e.g., victim.com) that redirects a generic URL to a user-specific one and blocks framing via X-Frame-Options: DENY or CSP: frame-ancestors 'none'.
  2. On an attacker-controlled site, embed the victim’s generic URL in an <iframe>.
  3. The browser process blocks the response and commits an error page in the iframe. The internal last_committed_url_ of the frame is set to the sensitive post-redirect URL.
  4. The attacker script records the current window.history.length of the tab.
  5. The attacker script programmatically sets iframe.src to a candidate guess URL.
  6. The attacker checks if window.history.length has remained constant, which indicates a successful guess via the replacement oracle.

Note: These are potential steps based on code analysis; our current tooling has not yet executed this proof-of-concept.

Proposed Fix

Remove the !frame_tree_node_->current_frame_host()->IsErrorDocument() exemption in NavigationRequest::ShouldReplaceCurrentEntryForSameUrlNavigation. Cross-origin initiators should not be permitted to trigger history entry replacement, regardless of whether the current document is an error page, to prevent this side-channel side-channel oracle.

Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker