Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in SiteIsolation
DescriptionIncorrect authorization in SiteIsolation
ComponentSiteIsolation
Bug ClassLogic Error
Tracker517215407
Fix commita7c6783bdfd6 (chromium/src) +101/-5
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Background

MHTML subframe
A frame whose content is loaded from a saved MHTML web archive rather than a live network fetch, and whose subframes commit within the archive’s main-frame SiteInstance and process.
Opaque origin precursor
The “precursor tuple” (scheme/host/port) recorded inside an opaque origin, obtained via url::Origin::GetTupleOrPrecursorTupleIfOpaque(), that remembers which real origin the opaque origin was derived from.
`CanCommitOriginAndUrl()`
The browser-side check in RenderFrameHostImpl that decides whether a renderer is authorized to commit a given origin and URL, guarding against a compromised renderer claiming unauthorized origins.
`AddCommittedOrigin()` / `ChildProcessSecurityPolicy`
The Site Isolation bookkeeping that records which origins a given renderer process is permitted to host, later consulted when authorizing sensitive actions.

Root Cause Analysis

In RenderFrameHostImpl::CanCommitOriginAndUrl(), the IsMhtmlSubframe() branch returned CAN_COMMIT_ORIGIN_AND_URL early as long as the reported origin was opaque and the frame shared the main frame’s SiteInstance, without ever inspecting the opaque origin’s precursor tuple. Because this early return deliberately skips the downstream ChildProcessSecurityPolicy::CanCommitOriginAndUrl() validation, the invariant that a committed origin’s precursor must trace back to the archive or the frame’s inheritance chain was never enforced on this path. For commits that arrive without a browser-side NavigationRequest — the synchronous about:blank commit or a renderer-initiated same-document navigation — a compromised renderer could report an opaque origin whose precursor is an arbitrary, unrelated tuple and have it accepted and recorded via AddCommittedOrigin().

The fix computes origin.GetTupleOrPrecursorTupleIfOpaque() and, when the precursor is valid, requires it to either equal the SchemeHostPort of the URL being committed or already be hosted by the process (policy->HostsOrigin(...)), rejecting the commit with CANNOT_COMMIT_ORIGIN otherwise. This works because it re-imposes the origin-inheritance invariant precisely on the path that bypasses the full policy check, terminating the offending renderer instead of laundering an attacker-chosen origin.

Key insight
The single mistake was trusting that “opaque” alone made an MHTML subframe origin safe, ignoring that an opaque origin still carries an attacker-controllable precursor tuple that gets recorded in Site Isolation state; the fix validates that precursor against the committing URL or already-admitted process origins before allowing the early return.

Attack Path

  1. Stage a malicious archive The attacker gets a victim to load an MHTML archive containing a subframe (e.g. an about:blank iframe) that commits inside the main frame’s SiteInstance and process.
  2. Compromise the renderer Using a separate renderer exploit, the attacker controls the DidCommitProvisionalLoadParams sent for a commit that arrives without a browser-side NavigationRequest, such as the synchronous about:blank commit or a same-document navigation.
  3. Forge the precursor The renderer reports an opaque origin whose precursor tuple points at an unrelated origin (e.g. https://unrelated.example) rather than the archive or inheritance chain.
  4. Bypass the policy check CanCommitOriginAndUrl() takes the IsMhtmlSubframe() early return, accepting the origin without the ChildProcessSecurityPolicy validation and recording it via AddCommittedOrigin().
  5. Leverage the recorded origin The process is now recorded as authorized to host the attacker-chosen origin, which downstream Site Isolation authorization decisions may consult.

Impact Assessment

An attacker with a compromised renderer hosting an MHTML archive gains the ability to have the browser record an opaque origin with an arbitrary, unrelated precursor as legitimately hosted by that process, defeating the Site Isolation invariant on which-origins-a-process-may-host. This is incorrect authorization occurring in the browser process’s security bookkeeping, driven by renderer-controlled commit parameters. Preconditions are a renderer already compromised enough to forge commit params and a commit path lacking a browser-side NavigationRequest (synchronous about:blank or renderer-initiated same-document navigation), consistent with the medium severity rating.

Changed Functions

FunctionChangeNotes
SubframeSyncCommitOriginReplacer
content/browser/navigation_mhtml_browsertest.cc
modified
IN_PROC_BROWSER_TEST_F
content/browser/navigation_mhtml_browsertest.cc
modified

Files Changed

  • content/browser/navigation_mhtml_browsertest.cc
  • content/browser/renderer_host/render_frame_host_impl.cc

Audit Directions

  • Early-return authorization bypasses
    Audit every branch that returns CAN_COMMIT_ORIGIN_AND_URL (or otherwise short-circuits) before reaching ChildProcessSecurityPolicy::CanCommitOriginAndUrl(), since each such path must independently re-enforce the invariants the skipped check would have provided.
  • Opaque origin trust assumptions
    Wherever code treats an origin as safe merely because origin.opaque() is true, verify the precursor tuple from GetTupleOrPrecursorTupleIfOpaque() is also validated, because opaque origins still carry renderer-influenced precursor data.
  • `NavigationRequest`-less commit paths
    Scrutinize commits lacking a browser-side NavigationRequest (synchronous about:blank, renderer-initiated same-document navigations), as these bypass normal navigation validation and are where forged DidCommitProvisionalLoadParams can slip attacker-chosen origins into ChildProcessSecurityPolicy state.
From a7c6783bdfd6bb99111eb98da491550b3f5970a0 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <[email protected]>
Date: Thu, 30 Jul 2026 09:54:58 -0700
Subject: [PATCH] Validate opaque-origin precursors for MHTML subframe commits

CanCommitOriginAndUrl() returns CAN_COMMIT_ORIGIN_AND_URL early for any
opaque origin reported by an MHTML subframe in the main frame's
SiteInstance, without checking the origin's precursor tuple. For commits
that arrive without a browser-side NavigationRequest (the synchronous
about:blank commit, or a renderer-initiated same-document navigation)
this allows the renderer to commit an opaque origin whose precursor is
unrelated to the archive or the frame's inheritance chain, and have it
recorded in ChildProcessSecurityPolicy via AddCommittedOrigin().

Require the precursor (when present) to either match the tuple of the
URL being committed, or be inherited from an origin of another MHTML
frame that had already been admitted into the MHTML process (e.g.,
inherited from the parent frame when creating a new iframe).

Add a regression test that intercepts an MHTML subframe's synchronous
about:blank commit, rewrites its origin to an opaque origin with an
unrelated precursor, and verifies the renderer is terminated and the
precursor is not recorded as hosted by the process.

Bug: 517215407
Change-Id: I323064a68de0b3d86ddfc320fa56c0a357f59917
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8168022
Reviewed-by: Łukasz Anforowicz <[email protected]>
Commit-Queue: Alex Moshchuk <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1671169}
---

diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index 7126186..1684fa6e 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -18,6 +18,7 @@
 #include "content/browser/bad_message.h"
 #include "content/browser/renderer_host/navigation_request.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/common/frame.mojom.h"
@@ -30,6 +31,7 @@
 #include "content/public/test/test_utils.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
+#include "content/test/did_commit_navigation_interceptor.h"
 #include "content/test/frame_host_interceptor.h"
 #include "mojo/public/c/system/trap.h"
 #include "mojo/public/c/system/types.h"
@@ -516,6 +518,85 @@
   EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
 }
 
+namespace {
+
+// Intercepts the synchronous about:blank commit of a child frame and replaces
+// the reported origin to test error handling for unexpected precursor origins.
+class SubframeSyncCommitOriginReplacer : public DidCommitNavigationInterceptor {
+ public:
+  SubframeSyncCommitOriginReplacer(WebContents* web_contents,
+                                   const url::Origin& origin)
+      : DidCommitNavigationInterceptor(web_contents), origin_(origin) {}
+
+  SubframeSyncCommitOriginReplacer(const SubframeSyncCommitOriginReplacer&) =
+      delete;
+  SubframeSyncCommitOriginReplacer& operator=(
+      const SubframeSyncCommitOriginReplacer&) = delete;
+
+  std::optional<bad_message::BadMessageReason> WaitForKill() {
+    return kill_waiter_->Wait();
+  }
+
+  bool did_intercept() const { return did_intercept_; }
+  int process_id() const { return process_id_; }
+
+ private:
+  bool WillProcessDidCommitNavigation(
+      RenderFrameHost* render_frame_host,
+      NavigationRequest* navigation_request,
+      mojom::DidCommitProvisionalLoadParamsPtr* params,
+      mojom::DidCommitProvisionalLoadInterfaceParamsPtr* interface_params)
+      override {
+    // Only target the synchronous about:blank commit of a child frame, which
+    // arrives without a browser-side NavigationRequest.
+    if (navigation_request || !render_frame_host->GetParent() ||
+        did_intercept_) {
+      return true;
+    }
+    did_intercept_ = true;
+    (*params)->origin = origin_;
+    process_id_ = render_frame_host->GetProcess()->GetDeprecatedID();
+    kill_waiter_ = std::make_unique<RenderProcessHostBadIpcMessageWaiter>(
+        render_frame_host->GetProcess());
+    return true;
+  }
+
+  const url::Origin origin_;
+  bool did_intercept_ = false;
+  int process_id_ = -1;
+  std::unique_ptr<RenderProcessHostBadIpcMessageWaiter> kill_waiter_;
+};
+
+}  // namespace
+
+// An MHTML subframe's synchronous about:blank commit inherits an opaque origin
+// derived from the main document. Ensure the browser rejects an opaque origin
+// whose precursor tuple does not match what the subframe could have inherited.
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest,
+                       MhtmlSubframeOpaquePrecursorValidation) {
+  MhtmlArchive mhtml_archive;
+  mhtml_archive.AddHtmlDocument(GURL("http://example.com"),
+                                "<iframe src=\"about:blank\"></iframe>");
+  GURL mhtml_url = mhtml_archive.Write("index.mhtml");
+
+  url::Origin mismatched_origin =
+      url::Origin::Create(GURL("https://unrelated.example"))
+          .DeriveNewOpaqueOrigin();
+  SubframeSyncCommitOriginReplacer replacer(web_contents(), mismatched_origin);
+
+  // The result of NavigateToURL is not checked, since the main frame commits
+  // before the subframe's invalid commit terminates the renderer.
+  std::ignore = NavigateToURL(shell(), mhtml_url);
+  EXPECT_TRUE(replacer.did_intercept());
+
+  // The process must not be recorded as hosting the opaque origin with the
+  // mismatched precursor.
+  EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->HostsOrigin(
+      replacer.process_id(), mismatched_origin));
+
+  EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, replacer.WaitForKill());
+}
+
 // Load iframe with the content-ID scheme. The resource is found in the MHTML
 // archive.
 IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest, IframeContentIdFound) {
diff --git a/content/browser/renderer_host/render_frame_host_impl.cc b/content/browser/renderer_host/render_frame_host_impl.cc
index d5f8e0b..38e2a7ee 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -12031,6 +12031,8 @@
     return CanCommitStatus::CANNOT_COMMIT_ORIGIN;
   }
 
+  auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
+
   // MHTML subframes can supply URLs at commit time that do not match the
   // process lock. For example, it can be either "cid:..." or arbitrary URL at
   // which the frame was at the time of generating the MHTML
@@ -12038,15 +12040,29 @@
   // the URL to commit in the process of the main frame.
   if (IsMhtmlSubframe()) {
     // Documents derived from an MHTML archive are behind sandbox flags, so
-    // their origin is opaque. The early-return below validates neither URL
-    // nor origin, so a compromised renderer could otherwise launder an
-    // arbitrary non-opaque origin past this point via
-    // DidCommitSameDocumentNavigation.
+    // their origin must be opaque.
     if (!origin.opaque()) {
       LogCanCommitOriginAndUrlFailureReason("mhtml_subframe_non_opaque_origin");
       return CanCommitStatus::CANNOT_COMMIT_ORIGIN;
     }
+
+    // Additionally, ensure the opaque origin's precursor is something this
+    // subframe could have legitimately produced: either derived from the URL
+    // being committed, or inherited from an existing frame's origin which had
+    // already been allowed into this process. This is important because this
+    // path skips the ChildProcessSecurityPolicy validation further down
+    // below.
     RenderFrameHostImpl* main_frame = GetMainFrame();
+    const url::SchemeHostPort precursor =
+        origin.GetTupleOrPrecursorTupleIfOpaque();
+    if (precursor.IsValid() && precursor != url::SchemeHostPort(url) &&
+        !policy->HostsOrigin(GetProcess()->GetDeprecatedID(), origin)) {
+      LogCanCommitOriginAndUrlFailureReason(
+          "mhtml_subframe_invalid_precursor_origin");
+      return CanCommitStatus::CANNOT_COMMIT_ORIGIN;
+    }
+
+    // Require the URL to commit in the process of the main frame.
     if (IsSameSiteInstance(main_frame)) {
       return CanCommitStatus::CAN_COMMIT_ORIGIN_AND_URL;
     }
@@ -12085,7 +12101,6 @@
   }
 
   // Check with ChildProcessSecurityPolicy, which enforces Site Isolation, etc.
-  auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
   const CanCommitStatus can_commit_status = policy->CanCommitOriginAndUrl(
       GetProcess()->GetDeprecatedID(), GetSiteInstance()->GetIsolationContext(),
       url_info);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index 7126186..1684fa6e 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -18,6 +18,7 @@
 #include "content/browser/bad_message.h"
 #include "content/browser/renderer_host/navigation_request.h"
 #include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/security/cpsp/child_process_security_policy_impl.h"
 #include "content/browser/web_contents/web_contents_impl.h"
 #include "content/common/content_navigation_policy.h"
 #include "content/common/frame.mojom.h"
@@ -30,6 +31,7 @@
 #include "content/public/test/test_utils.h"
 #include "content/shell/browser/shell.h"
 #include "content/test/content_browser_test_utils_internal.h"
+#include "content/test/did_commit_navigation_interceptor.h"
 #include "content/test/frame_host_interceptor.h"
 #include "mojo/public/c/system/trap.h"
 #include "mojo/public/c/system/types.h"
@@ -516,6 +518,85 @@
   EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
 }
 
+namespace {
+
+// Intercepts the synchronous about:blank commit of a child frame and replaces
+// the reported origin to test error handling for unexpected precursor origins.
+class SubframeSyncCommitOriginReplacer : public DidCommitNavigationInterceptor {
+ public:
+  SubframeSyncCommitOriginReplacer(WebContents* web_contents,
+                                   const url::Origin& origin)
+      : DidCommitNavigationInterceptor(web_contents), origin_(origin) {}
+
+  SubframeSyncCommitOriginReplacer(const SubframeSyncCommitOriginReplacer&) =
+      delete;
+  SubframeSyncCommitOriginReplacer& operator=(
+      const SubframeSyncCommitOriginReplacer&) = delete;
+
+  std::optional<bad_message::BadMessageReason> WaitForKill() {
+    return kill_waiter_->Wait();
+  }
+
+  bool did_intercept() const { return did_intercept_; }
+  int process_id() const { return process_id_; }
+
+ private:
+  bool WillProcessDidCommitNavigation(
+      RenderFrameHost* render_frame_host,
+      NavigationRequest* navigation_request,
+      mojom::DidCommitProvisionalLoadParamsPtr* params,
+      mojom::DidCommitProvisionalLoadInterfaceParamsPtr* interface_params)
+      override {
+    // Only target the synchronous about:blank commit of a child frame, which
+    // arrives without a browser-side NavigationRequest.
+    if (navigation_request || !render_frame_host->GetParent() ||
+        did_intercept_) {
+      return true;
+    }
+    did_intercept_ = true;
+    (*params)->origin = origin_;
+    process_id_ = render_frame_host->GetProcess()->GetDeprecatedID();
+    kill_waiter_ = std::make_unique<RenderProcessHostBadIpcMessageWaiter>(
+        render_frame_host->GetProcess());
+    return true;
+  }
+
+  const url::Origin origin_;
+  bool did_intercept_ = false;
+  int process_id_ = -1;
+  std::unique_ptr<RenderProcessHostBadIpcMessageWaiter> kill_waiter_;
+};
+
+}  // namespace
+
+// An MHTML subframe's synchronous about:blank commit inherits an opaque origin
+// derived from the main document. Ensure the browser rejects an opaque origin
+// whose precursor tuple does not match what the subframe could have inherited.
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest,
+                       MhtmlSubframeOpaquePrecursorValidation) {
+  MhtmlArchive mhtml_archive;
+  mhtml_archive.AddHtmlDocument(GURL("http://example.com"),
+                                "<iframe src=\"about:blank\"></iframe>");
+  GURL mhtml_url = mhtml_archive.Write("index.mhtml");
+
+  url::Origin mismatched_origin =
+      url::Origin::Create(GURL("https://unrelated.example"))
+          .DeriveNewOpaqueOrigin();
+  SubframeSyncCommitOriginReplacer replacer(web_contents(), mismatched_origin);
+
+  // The result of NavigateToURL is not checked, since the main frame commits
+  // before the subframe's invalid commit terminates the renderer.
+  std::ignore = NavigateToURL(shell(), mhtml_url);
+  EXPECT_TRUE(replacer.did_intercept());
+
+  // The process must not be recorded as hosting the opaque origin with the
+  // mismatched precursor.
+  EXPECT_FALSE(ChildProcessSecurityPolicyImpl::GetInstance()->HostsOrigin(
+      replacer.process_id(), mismatched_origin));
+
+  EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, replacer.WaitForKill());
+}
+
 // Load iframe with the content-ID scheme. The resource is found in the MHTML
 // archive.
 IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest, IframeContentIdFound) {
Loading diff…

Original Bug Report

reported by [email protected]

Precursor origin spoofing in MHTML subframes via early-return in CanCommitOriginAndUrl

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 potential vulnerability exists in RenderFrameHostImpl::CanCommitOriginAndUrl where MHTML subframe validation returns early for opaque origins, bypassing process lock checks. During a synchronous about:blank commit, a compromised renderer can register a spoofed opaque origin with a victim’s precursor tuple in the browser’s committed origins list. Since ChildProcessSecurityPolicyImpl matches opaque origins solely by their precursor tuple, this allows the renderer to bypass Site Isolation boundaries.

Affected files:

  • content/browser/renderer_host/render_frame_host_impl.cc

Estimated timestamp from git blame: 2026-05-13

Potential Precursor Origin Spoofing in MHTML Subframes

Description

A potential security boundary bypass exists in the validation of MHTML subframes within the browser process. Specifically, RenderFrameHostImpl::CanCommitOriginAndUrl allows opaque origins in MHTML subframes to return CanCommitStatus::CAN_COMMIT_ORIGIN_AND_URL early, completely bypassing standard process lock checks performed by ChildProcessSecurityPolicyImpl.

By utilizing a synchronous about:blank commit for a newly spawned child frame, a compromised renderer can avoid the browser’s mismatch checks and successfully register an arbitrary opaque origin (e.g., opaque{https://victim.com}) in the process’s committed origins list. Because ChildProcessSecurityPolicyImpl::MatchesCommittedOrigin verifies opaque origins solely by their precursor tuple (discarding/ignoring the nonce), this enables the compromised process to bypass Site Isolation boundaries and spoof actions or communications on behalf of the victim’s precursor origin.

Potential Steps to Reproduce

Note: These are potential steps based on static code analysis, as our current tooling does not have the ability to execute code or verify via a working proof of concept.

  1. A victim loads an MHTML archive in a tab, setting is_mhtml_document_ on the main frame.
  2. The renderer process hosting the MHTML page is assumed to be compromised.
  3. The compromised renderer requests the creation of a child frame by calling the FrameHost.CreateChildFrame Mojo interface on the main frame’s host.
  4. The compromised renderer sends a FrameHost.DidCommitProvisionalLoad Mojo IPC for the newly spawned child frame with:
    • params->url = "about:blank"
    • params->origin = opaque{precursor=https://victim.com, nonce=N} (where https://victim.com is the targeted victim site and N is an arbitrary nonce)
    • is_same_document_navigation = false
  5. The browser process validates the commit. Because the frame is an MHTML subframe and the origin is opaque, RenderFrameHostImpl::CanCommitOriginAndUrl returns CAN_COMMIT_ORIGIN_AND_URL early, bypassing downstream validations in ChildProcessSecurityPolicyImpl.
  6. Since the origin is opaque, ValidateDidCommitParams skips the synchronous about:blank origin mismatch check (!params->origin.opaque()).
  7. The browser registers opaque{https://victim.com} in the process’s allowed committed origins list via ChildProcessSecurityPolicyImpl::AddCommittedOrigin at line 16093 of content/browser/renderer_host/render_frame_host_impl.cc.
  8. The compromised renderer can now successfully pass HostsOrigin checks for opaque{https://victim.com} (with any nonce), enabling it to spoof postMessage source origins or bypass other initiator origin validation checks in the browser.

Suggested Fix

To remediate this issue, consider the following mitigations:

  1. Refactor RenderFrameHostImpl::CanCommitOriginAndUrl so that MHTML subframes with opaque origins still undergo necessary precursor validation against the process lock in ChildProcessSecurityPolicyImpl::CanCommitOriginAndUrl rather than returning CAN_COMMIT_ORIGIN_AND_URL early.
  2. Strengthen the check in ValidateDidCommitParams for synchronous initial document commits to ensure that the precursor origin of an opaque origin matches the expected initiator or parent origin, preventing the registration of arbitrary precursors.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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
Links in the report