CVE-2026-87570
Overview
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
SiteInstanceand process. - Opaque origin precursor
- The “precursor tuple” (
scheme/host/port) recorded inside an opaque origin, obtained viaurl::Origin::GetTupleOrPrecursorTupleIfOpaque(), that remembers which real origin the opaque origin was derived from. - `CanCommitOriginAndUrl()`
- The browser-side check in
RenderFrameHostImplthat 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.
Attack Path
- Stage a malicious archive
The attacker gets a victim to load an MHTML archive containing a subframe (e.g. an
about:blankiframe) that commits inside the main frame’sSiteInstanceand process. - Compromise the renderer
Using a separate renderer exploit, the attacker controls the
DidCommitProvisionalLoadParamssent for a commit that arrives without a browser-sideNavigationRequest, such as the synchronousabout:blankcommit or a same-document navigation. - 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. - Bypass the policy check
CanCommitOriginAndUrl()takes theIsMhtmlSubframe()early return, accepting the origin without theChildProcessSecurityPolicyvalidation and recording it viaAddCommittedOrigin(). - 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
NavigationRequest (synchronous about:blank or renderer-initiated same-document navigation), consistent with the medium severity rating.Changed Functions
| Function | Change | Notes |
|---|---|---|
SubframeSyncCommitOriginReplacercontent/browser/navigation_mhtml_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Fcontent/browser/navigation_mhtml_browsertest.cc |
modified |
Files Changed
content/browser/navigation_mhtml_browsertest.cccontent/browser/renderer_host/render_frame_host_impl.cc
Audit Directions
- Early-return authorization bypassesAudit every branch that returns
CAN_COMMIT_ORIGIN_AND_URL(or otherwise short-circuits) before reachingChildProcessSecurityPolicy::CanCommitOriginAndUrl(), since each such path must independently re-enforce the invariants the skipped check would have provided. - Opaque origin trust assumptionsWherever code treats an origin as safe merely because
origin.opaque()is true, verify the precursor tuple fromGetTupleOrPrecursorTupleIfOpaque()is also validated, because opaque origins still carry renderer-influenced precursor data. - `NavigationRequest`-less commit pathsScrutinize commits lacking a browser-side
NavigationRequest(synchronousabout:blank, renderer-initiated same-document navigations), as these bypass normal navigation validation and are where forgedDidCommitProvisionalLoadParamscan slip attacker-chosen origins intoChildProcessSecurityPolicystate.
Patch
From a7c6783bdfd6bb99111eb98da491550b3f5970a0 Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
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 <lukasza@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
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);
Regression Test / PoC
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) {
Original Bug Report
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.
- A victim loads an MHTML archive in a tab, setting
is_mhtml_document_on the main frame. - The renderer process hosting the MHTML page is assumed to be compromised.
- The compromised renderer requests the creation of a child frame by calling the
FrameHost.CreateChildFrameMojo interface on the main frame’s host. - The compromised renderer sends a
FrameHost.DidCommitProvisionalLoadMojo IPC for the newly spawned child frame with:params->url = "about:blank"params->origin = opaque{precursor=https://victim.com, nonce=N}(wherehttps://victim.comis the targeted victim site andNis an arbitrary nonce)is_same_document_navigation = false
- The browser process validates the commit. Because the frame is an MHTML subframe and the origin is opaque,
RenderFrameHostImpl::CanCommitOriginAndUrlreturnsCAN_COMMIT_ORIGIN_AND_URLearly, bypassing downstream validations inChildProcessSecurityPolicyImpl. - Since the origin is opaque,
ValidateDidCommitParamsskips the synchronousabout:blankorigin mismatch check (!params->origin.opaque()). - The browser registers
opaque{https://victim.com}in the process’s allowed committed origins list viaChildProcessSecurityPolicyImpl::AddCommittedOriginat line 16093 ofcontent/browser/renderer_host/render_frame_host_impl.cc. - The compromised renderer can now successfully pass
HostsOriginchecks foropaque{https://victim.com}(with any nonce), enabling it to spoofpostMessagesource origins or bypass other initiator origin validation checks in the browser.
Suggested Fix
To remediate this issue, consider the following mitigations:
- Refactor
RenderFrameHostImpl::CanCommitOriginAndUrlso that MHTML subframes with opaque origins still undergo necessary precursor validation against the process lock inChildProcessSecurityPolicyImpl::CanCommitOriginAndUrlrather than returningCAN_COMMIT_ORIGIN_AND_URLearly. - Strengthen the check in
ValidateDidCommitParamsfor 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.