Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Navigation
DescriptionInsufficient validation of untrusted input in Navigation
ComponentNavigation
Bug ClassLogic Error
Tracker487300831
Fix commit470a5614ecfb (chromium/src) +105/-12
CISA KEVNot listed
CreditedTianyi Hu
Disclosed2026-06-02

Changed Functions

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

Files Changed

  • content/browser/renderer_host/navigation_request.cc
  • content/browser/renderer_host/render_frame_host_manager_browsertest.cc
  • content/browser/security_exploit_browsertest.cc
From 470a5614ecfbdd85e5b0bb97719ffafa85410872 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Fri, 01 May 2026 20:31:33 -0700
Subject: [PATCH] Don't use precursors for error page origins for kCurrentProcess.

Currently, when a subframe navigation fails due to deterministic
failures (e.g., CSP or BLOCKED_BY_CLIENT), the resulting error page is
committed in the current process (`ErrorPageProcess::kCurrentProcess`)
to avoid spawning a new process for a potentially privileged
destination.

Previously, this error page was given an opaque origin derived from
the destination URL. If a compromised renderer intentionally triggered
a CSP failure against a cross-site victim URL, it could force an error
page with the victim's precursor to commit within the attacker's
process. This is risky, and among other problems, it allowed the
compromised renderer to inject a sandboxed srcdoc iframe into the
error page, which would inherit the victim precursor and could be
incorrectly granted a dedicated SiteInstance/process belonging to the
victim.

This CL fixes this by forcing error pages that stay in the current
process to use opaque unique origins with no precursor. Note that
this only affects subframe error pages, since main frame error pages
have error page isolation which avoids these problems.

Change-Id: Ib43c88233b36cd0ba84dff993134e2fcaa52ba13
Bug: 502348223, 487300831
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7794327
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Reviewed-by: Charlie Reis <creis@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1624238}
---

diff --git a/content/browser/renderer_host/navigation_request.cc b/content/browser/renderer_host/navigation_request.cc
index 6cdaa2bd..9ec42c6d 100644
--- a/content/browser/renderer_host/navigation_request.cc
+++ b/content/browser/renderer_host/navigation_request.cc
@@ -5527,9 +5527,6 @@
   }
 
   if (state_ < NavigationRequest::CANCELING) {
-    CHECK(browser_initiated_error_navigation_type_ !=
-          BrowserInitiatedErrorNavigationType::kNone);
-
     if (browser_initiated_error_navigation_type_ ==
         BrowserInitiatedErrorNavigationType::kPostCommit) {
       // Post-commit error page normally goes through the "non-error page"
@@ -5537,9 +5534,8 @@
       return ErrorPageProcess::kPostCommitErrorPage;
     }
 
-    // Otherwise, this is a normal browser-initiated error navigation, which
-    // should fall out of this block and use existing process selection
-    // behavior.
+    // Otherwise, this is a normal error navigation, which should fall out of
+    // this block and use existing process selection behavior.
   }
 
   // By policy we can isolate all error pages from both the current and
@@ -6525,6 +6521,13 @@
           previous_origin.GetTupleOrPrecursorTupleIfOpaque();
   if (!is_error_page_with_same_precursor) {
     commit_params_->force_new_document_sequence_number = true;
+  } else {
+    // We only preserve the document sequence number for temporary errors that
+    // could later be reloaded and succeed, which don't stay in the current
+    // process. Fatal errors routed to kCurrentProcess have a pure opaque origin
+    // and will not share the precursor, so they will always force a new
+    // document sequence number.
+    CHECK_NE(ComputeErrorPageProcess(), ErrorPageProcess::kCurrentProcess);
   }
 
   PopulateDocumentTokenForCrossDocumentNavigation();
@@ -11739,10 +11742,16 @@
 url::Origin NavigationRequest::GetOriginForURLLoaderFactoryUnchecked() {
   if (DidEncounterError()) {
     // Error pages commit in an opaque origin in the renderer process. If this
-    // NavigationRequest resulted in committing an error page, return an
-    // opaque origin that has precursor information consistent with the URL
-    // being requested.  Note: this is intentionally done first; cases like
-    // errors in srcdoc frames need not inherit the parent's origin for errors.
+    // NavigationRequest resulted in committing an error page, return an opaque
+    // origin. We usually derive the precursor for that opaque origin from the
+    // destination URL, with one exception: if the error page commits in the
+    // current process (e.g., for unrecoverable errors in subframes), we leave
+    // the precursor empty. This prevents compromised renderers from gaining
+    // access to opaque origins with precursors that aren't normally allowed in
+    // the process (crbug.com/502348223).
+    if (ComputeErrorPageProcess() == ErrorPageProcess::kCurrentProcess) {
+      return url::Origin();
+    }
     return url::Origin::Create(common_params().url).DeriveNewOpaqueOrigin();
   }
 
diff --git a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
index 7fbe6debe..672b606 100644
--- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
@@ -4834,8 +4834,16 @@
     EXPECT_EQ(4, nav_controller.GetEntryCount());
     EXPECT_EQ(test_url, child1->current_frame_host()->GetLastCommittedURL());
 
-    // Error pages should commit in an opaque origin.
-    EXPECT_TRUE(IsOriginOpaqueAndCompatibleWithURL(child1, test_url));
+    // Error pages should commit in an opaque origin. This particular error
+    // stays in the current process, so when B2 navigates B1 to C, it stays in
+    // B's process. The origin's precursor should be empty, and more
+    // specifically, it should not be C, to guard against compromised renderers
+    // gaining access to cross-site precursors (crbug.com/502348223).
+    const url::Origin& child1_origin =
+        child1->current_frame_host()->GetLastCommittedOrigin();
+    EXPECT_TRUE(child1_origin.opaque());
+    EXPECT_TRUE(
+        child1_origin.GetTupleOrPrecursorTupleIfOpaque().GetURL().is_empty());
 
     // net::ERR_BLOCKED_BY_CLIENT errors in subframes should commit in the
     // the correct process based on whether isolation is enabled or not.
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index d4f420a..56b28eb 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4048,4 +4048,80 @@
   EXPECT_FALSE(subframe->IsRenderFrameLive());
 }
 
+// Tests that a compromised renderer cannot exploit a CSP-blocked subframe error
+// page to place a srcdoc frame into a sandboxed SiteInstance for a site that it
+// doesn't have access to. This verifies the fix for
+// https://crbug.com/502348223.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       ErrorPagePrecursorDoesNotLeakToSandboxedSrcdoc) {
+  GURL attacker_url(
+      embedded_test_server()->GetURL("attacker.test", "/title1.html"));
+  GURL victim_url(
+      embedded_test_server()->GetURL("victim.test", "/title1.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), attacker_url));
+  RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+      shell()->web_contents()->GetPrimaryMainFrame());
+
+  // Set CSP to block iframes, so we get 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);"));
+
+  // Create an iframe to victim.test. It will be blocked and commit an error
+  // page.
+  TestNavigationObserver nav_observer(shell()->web_contents());
+  EXPECT_TRUE(
+      ExecJs(main_frame, JsReplace("var f = document.createElement('iframe');"
+                                   "f.src = $1;"
+                                   "document.body.appendChild(f);",
+                                   victim_url)));
+  nav_observer.Wait();
+
+  EXPECT_FALSE(nav_observer.last_navigation_succeeded());
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CSP, nav_observer.last_net_error_code());
+
+  RenderFrameHostImpl* error_frame =
+      main_frame->child_at(0)->current_frame_host();
+  EXPECT_TRUE(error_frame->IsErrorDocument());
+  ASSERT_EQ(error_frame->GetProcess(), main_frame->GetProcess());
+
+  // Simulate a compromised renderer by injecting a sandboxed srcdoc inside the
+  // error page. Once we have error page isolation for subframes, attacker.test
+  // won't be able to do this step.
+  TestNavigationObserver srcdoc_observer(shell()->web_contents());
+  EXPECT_TRUE(ExecJs(error_frame,
+                     "var f = document.createElement('iframe');"
+                     "f.sandbox = 'allow-scripts';"
+                     "f.srcdoc = 'foo';"
+                     "document.body.appendChild(f);"));
+  srcdoc_observer.Wait();
+
+  RenderFrameHostImpl* srcdoc_frame =
+      error_frame->child_at(0)->current_frame_host();
+
+  // With the fix, the error page's opaque origin has no precursor. Check that
+  // the sandboxed srcdoc's SiteInstance was not derived from victim.test.
+  EXPECT_TRUE(srcdoc_frame->GetLastCommittedOrigin().opaque());
+  EXPECT_TRUE(srcdoc_frame->GetLastCommittedOrigin()
+                  .GetTupleOrPrecursorTupleIfOpaque()
+                  .GetURL()
+                  .is_empty());
+  SiteInfo site_info = srcdoc_frame->GetSiteInstance()->GetSiteInfo();
+  EXPECT_FALSE(site_info.site_url().DomainIs("victim.test"));
+
+  // OOPSIFs require site isolation, so the srcdoc frame will be in a new
+  // sandboxed process if site isolation is enabled; otherwise, it will go into
+  // the error page's current unsandboxed process.
+  if (AreAllSitesIsolatedForTesting()) {
+    EXPECT_TRUE(site_info.IsSandboxed());
+    EXPECT_NE(srcdoc_frame->GetProcess(), error_frame->GetProcess());
+  } else {
+    EXPECT_FALSE(site_info.IsSandboxed());
+    EXPECT_EQ(srcdoc_frame->GetProcess(), error_frame->GetProcess());
+  }
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
index 7fbe6debe..672b606 100644
--- a/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
+++ b/content/browser/renderer_host/render_frame_host_manager_browsertest.cc
@@ -4834,8 +4834,16 @@
     EXPECT_EQ(4, nav_controller.GetEntryCount());
     EXPECT_EQ(test_url, child1->current_frame_host()->GetLastCommittedURL());
 
-    // Error pages should commit in an opaque origin.
-    EXPECT_TRUE(IsOriginOpaqueAndCompatibleWithURL(child1, test_url));
+    // Error pages should commit in an opaque origin. This particular error
+    // stays in the current process, so when B2 navigates B1 to C, it stays in
+    // B's process. The origin's precursor should be empty, and more
+    // specifically, it should not be C, to guard against compromised renderers
+    // gaining access to cross-site precursors (crbug.com/502348223).
+    const url::Origin& child1_origin =
+        child1->current_frame_host()->GetLastCommittedOrigin();
+    EXPECT_TRUE(child1_origin.opaque());
+    EXPECT_TRUE(
+        child1_origin.GetTupleOrPrecursorTupleIfOpaque().GetURL().is_empty());
 
     // net::ERR_BLOCKED_BY_CLIENT errors in subframes should commit in the
     // the correct process based on whether isolation is enabled or not.
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index d4f420a..56b28eb 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -4048,4 +4048,80 @@
   EXPECT_FALSE(subframe->IsRenderFrameLive());
 }
 
+// Tests that a compromised renderer cannot exploit a CSP-blocked subframe error
+// page to place a srcdoc frame into a sandboxed SiteInstance for a site that it
+// doesn't have access to. This verifies the fix for
+// https://crbug.com/502348223.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+                       ErrorPagePrecursorDoesNotLeakToSandboxedSrcdoc) {
+  GURL attacker_url(
+      embedded_test_server()->GetURL("attacker.test", "/title1.html"));
+  GURL victim_url(
+      embedded_test_server()->GetURL("victim.test", "/title1.html"));
+
+  EXPECT_TRUE(NavigateToURL(shell(), attacker_url));
+  RenderFrameHostImpl* main_frame = static_cast<RenderFrameHostImpl*>(
+      shell()->web_contents()->GetPrimaryMainFrame());
+
+  // Set CSP to block iframes, so we get 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);"));
+
+  // Create an iframe to victim.test. It will be blocked and commit an error
+  // page.
+  TestNavigationObserver nav_observer(shell()->web_contents());
+  EXPECT_TRUE(
+      ExecJs(main_frame, JsReplace("var f = document.createElement('iframe');"
+                                   "f.src = $1;"
+                                   "document.body.appendChild(f);",
+                                   victim_url)));
+  nav_observer.Wait();
+
+  EXPECT_FALSE(nav_observer.last_navigation_succeeded());
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CSP, nav_observer.last_net_error_code());
+
+  RenderFrameHostImpl* error_frame =
+      main_frame->child_at(0)->current_frame_host();
+  EXPECT_TRUE(error_frame->IsErrorDocument());
+  ASSERT_EQ(error_frame->GetProcess(), main_frame->GetProcess());
+
+  // Simulate a compromised renderer by injecting a sandboxed srcdoc inside the
+  // error page. Once we have error page isolation for subframes, attacker.test
+  // won't be able to do this step.
+  TestNavigationObserver srcdoc_observer(shell()->web_contents());
+  EXPECT_TRUE(ExecJs(error_frame,
+                     "var f = document.createElement('iframe');"
+                     "f.sandbox = 'allow-scripts';"
+                     "f.srcdoc = 'foo';"
+                     "document.body.appendChild(f);"));
+  srcdoc_observer.Wait();
+
+  RenderFrameHostImpl* srcdoc_frame =
+      error_frame->child_at(0)->current_frame_host();
+
+  // With the fix, the error page's opaque origin has no precursor. Check that
+  // the sandboxed srcdoc's SiteInstance was not derived from victim.test.
+  EXPECT_TRUE(srcdoc_frame->GetLastCommittedOrigin().opaque());
+  EXPECT_TRUE(srcdoc_frame->GetLastCommittedOrigin()
+                  .GetTupleOrPrecursorTupleIfOpaque()
+                  .GetURL()
+                  .is_empty());
+  SiteInfo site_info = srcdoc_frame->GetSiteInstance()->GetSiteInfo();
+  EXPECT_FALSE(site_info.site_url().DomainIs("victim.test"));
+
+  // OOPSIFs require site isolation, so the srcdoc frame will be in a new
+  // sandboxed process if site isolation is enabled; otherwise, it will go into
+  // the error page's current unsandboxed process.
+  if (AreAllSitesIsolatedForTesting()) {
+    EXPECT_TRUE(site_info.IsSandboxed());
+    EXPECT_NE(srcdoc_frame->GetProcess(), error_frame->GetProcess());
+  } else {
+    EXPECT_FALSE(site_info.IsSandboxed());
+    EXPECT_EQ(srcdoc_frame->GetProcess(), error_frame->GetProcess());
+  }
+}
+
 }  // namespace content
Loading diff…

Original Bug Report

reported by os...@gmail.com

VerifyInitiatorOrigin() skips HostsOrigin() process lock check for opaque origins in error documents and MHTML subframes


Report description

VerifyInitiatorOrigin() skips HostsOrigin() process lock check for opaque origins in error documents and MHTML subframes


Bug location

Where do you want to report your vulnerability?

Chrome VRP – Report security issues affecting the Chrome browser. See program rules

Which URL (or repository) have you found the vulnerability in?

https://source.chromium.org/chromium/chromium/src/+/main:content/browser/renderer_host/ipc_utils.cc


The problem

Please describe the technical details of the vulnerability

The problem

VerifyInitiatorOrigin() completely skips the HostsOrigin() process lock check when the initiator_origin is opaque and the current frame is an error document or MHTML subframe. A compromised renderer in these contexts can forge any opaque initiator origin with an arbitrary precursor, and the browser accepts it without verification — a site isolation boundary violation.

Vulnerable files:

Transmission chain:

  1. In RenderFrameImpl::OpenURL(), the renderer sets params->initiator_origin from info->url_request.RequestorOrigin() — attacker-controlled in a compromised renderer.
  2. The field is a url.mojom.Origin in the OpenURLParams Mojo struct, sent via mojom::FrameHost::OpenURL().
  3. In VerifyOpenURLParams(), the browser calls VerifyInitiatorOrigin() which checks whether the renderer process hosts the claimed origin.
  4. However, when the origin is opaque AND the frame is an error document, the function returns true immediately without calling HostsOrigin():
if (initiator_origin.opaque()) {
    if (current_rfh && current_rfh->IsErrorDocument()) {
        return true;  // SKIP ALL VERIFICATION
    }
    if (current_rfh && current_rfh->IsMhtmlSubframe()) {
        return true;  // SKIP ALL VERIFICATION
    }
}

Two TODO comments in the code explicitly acknowledge this gap:

> TODO(crbug.com/40109437): Ideally, origin verification should be performed even if initiator_origin is opaque, to ensure that the precursor origin matches the process lock.

Steps to reproduce:

  1. Check out the current stable tag (145.0.7632.110)
  2. git apply h5_patch_renderer_poc.diff
  3. Build: autoninja -C out/Default chrome
  4. Place PoC files in the same directory, start HTTP server:
    • python3 serve.py
  5. Launch patched Chrome and observe:
    • out/Default/Chromium.app/Contents/MacOS/Chromium --user-data-dir=/tmp/chrome-h5-test --enable-logging=stderr http://localhost:8080/index.html
    • The page embeds an iframe to a dead port (localhost:9999). After the error page loads, the patched renderer sends OpenURL with a forged opaque origin (precursor=http://cross-site-forged.example). The iframe navigates to victim.html — proving the forged initiator origin was accepted by the browser.

The renderer patch hooks DidFinishLoad() to detect error page subframes, then after 3 seconds calls GetFrameHost()->OpenURL() with a forged opaque initiator_origin whose precursor (http://cross-site-forged.example) does not match the error page’s process lock (chrome-error://chromewebdata/). No special flags are needed.

Bisect: Introducing commit: 4043b7370f9b845cd06f07296e115c7094662c66

Evidence:

  • M127 (127.0.6533.72): blanket return true for ALL opaque origins (no IsErrorDocument/IsMhtmlSubframe check)
  • M128 (128.0.6613.84): first milestone with the targeted IsErrorDocument()/IsMhtmlSubframe() bypass pattern
  • Parent commit (5c67fc2a6cca): blanket bypass, no current_rfh parameter
  • Commit 4043b737: introduced current_rfh parameter and the error doc/MHTML bypass paths

The commit was a security hardening that narrowed a blanket opaque origin bypass to only error documents and MHTML subframes. However, these two remaining return true paths still skip HostsOrigin() entirely, and the TODOs acknowledge the gap remains unfixed.

Earliest affected release: Chrome M128 (stable August 2024). Latest confirmed: Chrome M145 (145.0.7632.110, current stable).

Suggested fix (attached as h5_fix.diff): Instead of blanket return true for opaque origins in error pages and MHTML subframes, validate that the precursor of the provided opaque origin matches the precursor of the frame’s committed origin:

// Before (vulnerable):
if (current_rfh && current_rfh->IsErrorDocument()) {
    return true;
}

// After (fixed):
if (current_rfh && current_rfh->IsErrorDocument()) {
    const auto& committed = current_rfh->GetLastCommittedOrigin();
    if (committed.opaque() &&
        committed.GetTupleOrPrecursorTupleIfOpaque() ==
            initiator_origin.GetTupleOrPrecursorTupleIfOpaque()) {
        return true;
    }
    bad_message::ReceivedBadMessage(
        process_id, bad_message::INVALID_INITIATOR_ORIGIN);
    return false;
}

This allows legitimate error page reloads (where the precursor naturally matches) while blocking forged cross-site precursors. Verified: with the fix applied, the renderer is killed with bad_message::INVALID_INITIATOR_ORIGIN (reason 213) instead of the navigation succeeding.

A browser test (h5_browsertest.diff) is also attached. It adds SecurityExploitBrowserTest.InvalidOpaqueInitiatorFromErrorPage to content/browser/security_exploit_browsertest.cc, following the same pattern as existing INVALID_INITIATOR_ORIGIN tests. The test creates an error page subframe via URLLoaderInterceptor, then directly injects a forged OpenURL with a mismatched opaque precursor and verifies the renderer is killed. Plan to upload both fix and test as a Gerrit CL.

Impact analysis

A compromised renderer in an error page or MHTML subframe context can forge navigation initiator origins with arbitrary cross-site precursors. This violates site isolation boundaries — the browser accepts navigations attributed to origins that the renderer process does not host.

Error pages are easy to trigger: any iframe pointed at a non-responsive server produces one. A compromised renderer in this context could forge initiator origins to manipulate navigation attribution, potentially affecting security decisions downstream that rely on the initiator origin (e.g., CSP, CORS preflight, permission delegation).


The cause

What version of Chrome have you found the security issue in?

145.0.7632.110 (Stable)

No, it is not related to a crash.

Choose the type of vulnerability

Site Isolation Bypass

How would you like to be publicly acknowledged for your report?

Tianyi Hu

View on issue tracker