Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Navigation
DescriptionImproper input validation in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker511736672
Fix commite2b48fec7f31 (chromium/src) +108/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/render_frame_host_impl.cc
modified
ErrorPageKillWaiter
content/browser/security_exploit_browsertest.cc
modified
if
content/browser/security_exploit_browsertest.cc
modified

Files Changed

  • content/browser/bad_message.h
  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/security_exploit_browsertest.cc
  • tools/metrics/histograms/metadata/stability/enums.xml
From e2b48fec7f31f474704b4b610db64dc65df4c620 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Fri, 24 Jul 2026 12:17:31 -0700
Subject: [PATCH] Validate DidCommit URL for error pages that bypass commit checks

When ShouldBypassSecurityChecksForErrorPage() returns true for an
error-page commit (e.g. a CSP-blocked subframe navigation that stays in
the parent's process, or a main-frame error page in the dedicated
error-page process), ValidateDidCommitParams() skips
ValidateURLAndOrigin() because the committed URL legitimately won't
match the process lock. However, the renderer-supplied URL must still
match the URL the browser asked the renderer to commit, since it is
stored in last_committed_url_ and the FrameNavigationEntry and later
used for reload / session history.

Add a check that params->url matches NavigationRequest::GetURL() in
this case, and terminate the renderer with a new
RFH_ERROR_PAGE_URL_MISMATCH bad-message reason on mismatch.

Bug: 511736672
Change-Id: Ib8f27d8ec22c1ccab606318c68c50a871074d688
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8139503
Reviewed-by: Charlie Reis <creis@chromium.org>
Auto-Submit: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Mark Pearson <mpearson@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1668062}
---

diff --git a/content/browser/bad_message.h b/content/browser/bad_message.h
index 4de389b6..78a3a648 100644
--- a/content/browser/bad_message.h
+++ b/content/browser/bad_message.h
@@ -386,6 +386,7 @@
   RFH_CREATE_NEW_WINDOW_INVALID_PIP_OPTIONS = 358,
   RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY = 359,
   RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN = 360,
+  RFH_ERROR_PAGE_URL_MISMATCH = 361,
 
   // Please add new elements here. The naming convention is abbreviated class
   // name (e.g. RenderFrameHost becomes RFH) plus a unique description of the
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index afb90f7f..16bff683 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -15599,18 +15599,30 @@
     return false;
   }
 
-  // Error pages must commit in a opaque origin. Terminate the renderer
-  // process if this is violated.
-  if (bypass_checks_for_error_page && !params->origin.opaque()) {
-    DEBUG_ALIAS_FOR_ORIGIN(origin_debug_alias, params->origin);
-    bad_message::ReceivedBadMessage(
-        process, bad_message::RFH_ERROR_PROCESS_NON_UNIQUE_ORIGIN_COMMIT);
-    return false;
-  }
+  if (bypass_checks_for_error_page) {
+    // Error pages must commit in a opaque origin. Terminate the renderer
+    // process if this is violated.
+    if (!params->origin.opaque()) {
+      DEBUG_ALIAS_FOR_ORIGIN(origin_debug_alias, params->origin);
+      bad_message::ReceivedBadMessage(
+          process, bad_message::RFH_ERROR_PROCESS_NON_UNIQUE_ORIGIN_COMMIT);
+      return false;
+    }
 
-  if (!bypass_checks_for_error_page &&
-      !ValidateURLAndOrigin(params->url, params->origin,
-                            is_same_document_navigation, navigation_request)) {
+    // Error pages may legitimately commit a URL that doesn't match the process
+    // lock, so the CanCommitOriginAndUrl checks below are skipped. However, the
+    // committed URL must still match the URL that the browser asked the
+    // renderer to commit, since otherwise the renderer could place an arbitrary
+    // URL into session history and `last_committed_url_`.
+    if (navigation_request && !is_same_document_navigation &&
+        params->url != navigation_request->GetURL()) {
+      bad_message::ReceivedBadMessage(process,
+                                      bad_message::RFH_ERROR_PAGE_URL_MISMATCH);
+      return false;
+    }
+  } else if (!ValidateURLAndOrigin(params->url, params->origin,
+                                   is_same_document_navigation,
+                                   navigation_request)) {
     return false;
   }
 
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 36f23d8..0738c98 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -35,6 +35,7 @@
 #include "content/browser/dom_storage/session_storage_namespace_impl.h"
 #include "content/browser/fenced_frame/fenced_frame.h"
 #include "content/browser/renderer_host/file_utilities_host_impl.h"
+#include "content/browser/renderer_host/navigation_request.h"
 #include "content/browser/renderer_host/navigator.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/renderer_host/render_frame_proxy_host.h"
@@ -4926,4 +4927,86 @@
             kill_waiter.Wait());
 }
 
+namespace {
+
+// Helper class to wait for ReadyToCommitNavigation for the next navigation that
+// results in an error page, and then to wait for a renderer kill for the
+// process that will commit that error page.
+class ErrorPageKillWaiter : public WebContentsObserver {
+ public:
+  explicit ErrorPageKillWaiter(WebContents* web_contents)
+      : WebContentsObserver(web_contents) {}
+
+  // WebContentsObserver:
+  void ReadyToCommitNavigation(NavigationHandle* navigation_handle) override {
+    if (NavigationRequest::From(navigation_handle)->DidEncounterError()) {
+      kill_waiter_ = std::make_unique<RenderProcessHostBadIpcMessageWaiter>(
+          navigation_handle->GetRenderFrameHost()->GetProcess());
+      run_loop_.Quit();
+    }
+  }
+
+  void DidFinishNavigation(NavigationHandle* navigation_handle) override {
+    if (!kill_waiter_) {
+      run_loop_.Quit();
+    }
+  }
+
+  std::optional<bad_message::BadMessageReason> Wait() {
+    run_loop_.Run();
+    if (!kill_waiter_) {
+      return std::nullopt;
+    }
+    return kill_waiter_->Wait();
+  }
+
+ private:
+  std::unique_ptr<RenderProcessHostBadIpcMessageWaiter> kill_waiter_;
+  base::RunLoop run_loop_;
+};
+
+}  // namespace
+
+// Tests that a compromised renderer cannot lie about the URL when committing a
+// CSP-blocked subframe error page. Such error pages are allowed to commit a URL
+// that doesn't match the process lock, but the URL must still match the URL
+// that the browser asked the renderer to commit.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       BlockedSubframeErrorPageDidCommitInvalidURL) {
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL blocked_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  GURL spoofed_url(embedded_test_server()->GetURL("c.com", "/title2.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+      shell()->web_contents()->GetPrimaryMainFrame());
+
+  // Add a CSP that blocks all subframe navigations, so the navigation below
+  // commits an error page.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "var meta = document.createElement('meta');"
+                     "meta.httpEquiv = 'Content-Security-Policy';"
+                     "meta.content = \"frame-src 'none'\";"
+                     "document.head.appendChild(meta);"));
+
+  // Simulate a compromised renderer that lies about the committed URL.
+  DidCommitUrlReplacer url_replacer(shell()->web_contents(), spoofed_url);
+
+  // Set up an observer to capture the error page process right before it
+  // commits. Note that we can't assume that it will be the same as the main
+  // frame's process due to subframe error page isolation.
+  ErrorPageKillWaiter kill_waiter(shell()->web_contents());
+
+  // Create an iframe that will be blocked by CSP and commit an error page. The
+  // browser process should detect the URL mismatch and terminate the renderer.
+  // We use ExecuteScriptAsync because the renderer might be terminated before
+  // ExecJs returns.
+  ExecuteScriptAsync(main_frame,
+                     JsReplace("var f = document.createElement('iframe');"
+                               "f.src = $1;"
+                               "document.body.appendChild(f);",
+                               blocked_url));
+  EXPECT_EQ(bad_message::RFH_ERROR_PAGE_URL_MISMATCH, kill_waiter.Wait());
+}
+
 }  // namespace content
diff --git a/tools/metrics/histograms/metadata/stability/enums.xml b/tools/metrics/histograms/metadata/stability/enums.xml
index 89212410..81f29cf 100644
--- a/tools/metrics/histograms/metadata/stability/enums.xml
+++ b/tools/metrics/histograms/metadata/stability/enums.xml
@@ -535,6 +535,7 @@
   <int value="358" label="RFH_CREATE_NEW_WINDOW_INVALID_PIP_OPTIONS"/>
   <int value="359" label="RFPH_POST_MESSAGE_INVALID_DELEGATED_CAPABILITY"/>
   <int value="360" label="RFHI_WEBMCP_OPAQUE_TARGET_ORIGIN"/>
+  <int value="361" label="RFH_ERROR_PAGE_URL_MISMATCH"/>
 </enum>
 
 <enum name="BadMessageReasonExtensions">
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 36f23d8..0738c98 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -35,6 +35,7 @@
 #include "content/browser/dom_storage/session_storage_namespace_impl.h"
 #include "content/browser/fenced_frame/fenced_frame.h"
 #include "content/browser/renderer_host/file_utilities_host_impl.h"
+#include "content/browser/renderer_host/navigation_request.h"
 #include "content/browser/renderer_host/navigator.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
 #include "content/browser/renderer_host/render_frame_proxy_host.h"
@@ -4926,4 +4927,86 @@
             kill_waiter.Wait());
 }
 
+namespace {
+
+// Helper class to wait for ReadyToCommitNavigation for the next navigation that
+// results in an error page, and then to wait for a renderer kill for the
+// process that will commit that error page.
+class ErrorPageKillWaiter : public WebContentsObserver {
+ public:
+  explicit ErrorPageKillWaiter(WebContents* web_contents)
+      : WebContentsObserver(web_contents) {}
+
+  // WebContentsObserver:
+  void ReadyToCommitNavigation(NavigationHandle* navigation_handle) override {
+    if (NavigationRequest::From(navigation_handle)->DidEncounterError()) {
+      kill_waiter_ = std::make_unique<RenderProcessHostBadIpcMessageWaiter>(
+          navigation_handle->GetRenderFrameHost()->GetProcess());
+      run_loop_.Quit();
+    }
+  }
+
+  void DidFinishNavigation(NavigationHandle* navigation_handle) override {
+    if (!kill_waiter_) {
+      run_loop_.Quit();
+    }
+  }
+
+  std::optional<bad_message::BadMessageReason> Wait() {
+    run_loop_.Run();
+    if (!kill_waiter_) {
+      return std::nullopt;
+    }
+    return kill_waiter_->Wait();
+  }
+
+ private:
+  std::unique_ptr<RenderProcessHostBadIpcMessageWaiter> kill_waiter_;
+  base::RunLoop run_loop_;
+};
+
+}  // namespace
+
+// Tests that a compromised renderer cannot lie about the URL when committing a
+// CSP-blocked subframe error page. Such error pages are allowed to commit a URL
+// that doesn't match the process lock, but the URL must still match the URL
+// that the browser asked the renderer to commit.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       BlockedSubframeErrorPageDidCommitInvalidURL) {
+  GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+  GURL blocked_url(embedded_test_server()->GetURL("b.com", "/title1.html"));
+  GURL spoofed_url(embedded_test_server()->GetURL("c.com", "/title2.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), main_url));
+  RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+      shell()->web_contents()->GetPrimaryMainFrame());
+
+  // Add a CSP that blocks all subframe navigations, so the navigation below
+  // commits an error page.
+  EXPECT_TRUE(ExecJs(main_frame,
+                     "var meta = document.createElement('meta');"
+                     "meta.httpEquiv = 'Content-Security-Policy';"
+                     "meta.content = \"frame-src 'none'\";"
+                     "document.head.appendChild(meta);"));
+
+  // Simulate a compromised renderer that lies about the committed URL.
+  DidCommitUrlReplacer url_replacer(shell()->web_contents(), spoofed_url);
+
+  // Set up an observer to capture the error page process right before it
+  // commits. Note that we can't assume that it will be the same as the main
+  // frame's process due to subframe error page isolation.
+  ErrorPageKillWaiter kill_waiter(shell()->web_contents());
+
+  // Create an iframe that will be blocked by CSP and commit an error page. The
+  // browser process should detect the URL mismatch and terminate the renderer.
+  // We use ExecuteScriptAsync because the renderer might be terminated before
+  // ExecJs returns.
+  ExecuteScriptAsync(main_frame,
+                     JsReplace("var f = document.createElement('iframe');"
+                               "f.src = $1;"
+                               "document.body.appendChild(f);",
+                               blocked_url));
+  EXPECT_EQ(bad_message::RFH_ERROR_PAGE_URL_MISMATCH, kill_waiter.Wait());
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by vm...@google.com

Subframe blocked navigation error-page bypass allows renderer-controlled URL

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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: A logic flaw in RenderFrameHostImpl::ValidateDidCommitParams allows a compromised renderer to spoof the committed URL of a subframe during a blocked navigation (e.g., ERR_BLOCKED_BY_CSP). Because error-page isolation is disabled for subframes, critical Site Isolation checks are bypassed, enabling the renderer to inject an arbitrary URL into the browser’s navigation state and gain unauthorized extension privileges.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc
  • content/browser/renderer_host/navigation_controller_impl.cc
  • content/browser/renderer_host/navigation_request.cc
  • content/public/browser/content_browser_client.cc

Estimated timestamp from git blame: 2025-05-08

Vulnerability Mechanism

When a renderer-initiated subframe navigation is blocked (e.g., by a CSP), the browser attempts to commit an error page. In NavigationRequest::ComputeErrorPageProcess(), the browser determines which process should host the error page. Because error-page isolation is typically disabled for subframes (SiteIsolationPolicy::IsErrorPageIsolationEnabled returns false), the browser decides to commit the error page in the current (potentially compromised) renderer process.

The browser sends a CommitFailedNavigation IPC to the renderer. A compromised renderer can respond with a malicious DidCommitProvisionalLoad IPC, setting params.url to an arbitrary cross-origin URL (e.g., https://victim.com/secret) while setting params.origin to an opaque origin ("null").

During validation in RenderFrameHostImpl::ValidateDidCommitParams, the browser calls ShouldBypassSecurityChecksForErrorPage. This returns true because error page isolation is disabled for the subframe and the error is a blocked request type. Crucially, when bypass_checks_for_error_page is true, the browser skips the call to ValidateURLAndOrigin entirely. This skips the critical ChildProcessSecurityPolicyImpl::CanCommitOriginAndUrl check, which would normally verify if the renderer’s ProcessLock is compatible with https://victim.com.

The browser only performs an opaque origin check and calls RenderProcessHostImpl::FilterURL. FilterURL only checks if the URL scheme is web-safe, so https://victim.com/secret passes. Consequently, the spoofed parameters are accepted, and the browser updates its internal last_committed_url_ and FrameNavigationEntry with the attacker-controlled URL.

Security Impact

This bypasses Site Isolation and allows a compromised renderer to spoof the committed URL of a subframe. When the navigation finishes, ScriptInjectionTracker::DidFinishNavigation reads this spoofed URL and updates the RenderProcessHostUserData state. It incorrectly records that the compromised renderer is permitted to execute extension content scripts for victim.com. The attacker can then use IPC messages to impersonate these extension content scripts, achieving Universal XSS (UXSS) and credential theft.

Potential Reproduction Steps

Note: These are suggested steps; our tooling does not yet have the ability to run code to confirm them.

  1. Compromise a renderer process hosting https://a.com.
  2. In the a.com document, set a CSP such as frame-src 'none'.
  3. Create an iframe and attempt to navigate it to https://victim.com/secret. The browser’s CSP throttle blocks this with ERR_BLOCKED_BY_CSP.
  4. The browser sends a CommitFailedNavigation IPC to the compromised renderer.
  5. The renderer responds with a DidCommitProvisionalLoad IPC where params.url is https://victim.com/secret and params.origin is an opaque origin.
  6. ValidateDidCommitParams skips ValidateURLAndOrigin due to the error status and subframe type.
  7. The browser accepts https://victim.com/secret as the last committed URL, granting the compromised renderer access to extension content scripts for that origin.

Suggested Fix

ValidateDidCommitParams should not blindly trust the URL provided by the renderer during error page commits simply because bypass_checks_for_error_page is true. The browser already calculates the expected origin_to_commit for error pages and enforces it. It should also enforce that the committed URL matches the expected URL for the error page, or restrict error page commits to safe URLs like chrome-error://... or about:blank, preventing arbitrary cross-origin http/https URLs from being injected into the browser’s navigation state.

Evaluated with Chrome root at commit: eca8648a4e1cdfdda68c495a6003059fed641955


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker