CVE-2026-9903
Overview
Background
- `RenderFrameHostImpl::CanCommitOriginAndUrl`
- the browser-process gate that decides whether a renderer may commit a given origin/URL in a frame (
render_frame_host_impl.cc:11725). - MHTML subframe
- a subframe loaded from an MHTML archive;
IsMhtmlSubframe()is true when the frame is not the main frame and the main frame is an MHTML document (render_frame_host_impl.cc:11721). - Opaque origin
- an origin with no tuple (scheme/host/port) that is distinct from every other origin; MHTML-derived documents are sandboxed and expected to be opaque.
- `kEnforceSameDocumentOriginInvariants`
- a feature flag; when disabled, the stronger same-document origin-invariance check is skipped in favor of a weaker fallback.
Root Cause Analysis
CanCommitOriginAndUrl runs a series of checks, but the strong same-document origin check only runs when features::IsEnforceSameDocumentOriginInvariantsEnabled() is true (render_frame_host_impl.cc:11762-11766). For MHTML subframes, the function then entered a dedicated block (render_frame_host_impl.cc:11773) that, when the subframe is in the same SiteInstance as the main frame, returned CanCommitStatus::CAN_COMMIT_ORIGIN_AND_URL unconditionally (render_frame_host_impl.cc:11775-11776) — accepting the renderer-supplied origin without validating it. Critically, the weaker fallback same-document origin check sits after this block (render_frame_host_impl.cc:11798-11803) and a comment at render_frame_host_impl.cc:11793-11794 notes it “must be after the MHTML check,” so a same-document commit for an MHTML subframe skipped origin validation entirely and never reached ChildProcessSecurityPolicyImpl::CanCommitOriginAndUrl (render_frame_host_impl.cc:11814). The invariant that an MHTML subframe commits only with its expected opaque origin was thus unenforced.
The fix adds, at the top of the IsMhtmlSubframe() block (added diff at @@ -11771,6), if (!origin.opaque()) { LogCanCommitOriginAndUrlFailureReason("mhtml_subframe_non_opaque_origin"); return CanCommitStatus::CANNOT_COMMIT_ORIGIN; }, so a non-opaque origin on an MHTML subframe is rejected before the permissive early return — tightening the check. The regression test confirms the misbehaving renderer is killed with bad_message::RFH_INVALID_ORIGIN_ON_COMMIT.
CanCommitOriginAndUrl accepted the renderer’s origin without checking it, bypassing the same-document origin-invariance check that follows; the fix requires MHTML subframe origins to be opaque() at that early return and rejects anything else.Attack Path
- Load an MHTML archive
The victim opens a local MHTML file so a main frame and subframe load in the same
SiteInstanceas MHTML documents. (trigger per reporter) - Compromise the renderer The attacker gains code execution in that renderer process. (inferred; not shown in code)
- Forge a same-document commit
The renderer sends a
DidCommitSameDocumentNavigationIPC for the subframe carrying a spoofed non-opaque origin (e.g.https://victim.example), as the regression test demonstrates. - Origin accepted pre-fix
CanCommitOriginAndUrlhits the MHTML block and returnsCAN_COMMIT_ORIGIN_AND_URLwithout validating the origin (render_frame_host_impl.cc:11775-11776), so the browser adopts the spoofed origin. - Abuse of spoofed origin
The browser’s authoritative committed origin for the frame is now the attacker-chosen origin, enabling abuse of origin-keyed browser APIs. (inferred;
SetLastCommittedOriginpath not in shown material)
Impact Assessment
(inferred) beyond the origin acceptance shown.Changed Functions
| Function | Change | Notes |
|---|---|---|
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
- Permissive early returns in commit validationAny branch of
CanCommitOriginAndUrl(or similar commit gates) that returns “can commit” early must itself validate origin/URL, since later checks are skipped. - Checks ordered after a short-circuitWhen a security check is deliberately placed after a special-case block (here the MHTML block preceding the fallback origin check), confirm the special case cannot be the exact path that needs that check.
- Expected-opaque originsFor documents required to be sandboxed/opaque (MHTML, error pages), enforce the opaque invariant explicitly at commit time rather than assuming the renderer honors it.
Patch
From e4b92a699c36c550e02209491591b664293f20f1 Mon Sep 17 00:00:00 2001
From: Arthur Sonzogni <arthursonzogni@chromium.org>
Date: Wed, 13 May 2026 20:01:26 -0700
Subject: [PATCH] Fix Site Isolation bypass via MHTML subframe origin spoofing
RenderFrameHostImpl::CanCommitOriginAndUrl contained an early-return
short-circuit for MHTML subframes that unconditionally accepted the
renderer-provided origin without validating it. This could allow a
compromised renderer to spoof its origin via a fabricated same-document
navigation IPC.
This CL fixes the issue by explicitly enforcing that MHTML subframes
must commit with opaque origins at the early-return block, mirroring
the error-page enforcement at ValidateDidCommitParams().
It also adds a regression test that simulates a compromised renderer
sending a malicious DidCommitSameDocumentNavigation IPC with a
non-opaque origin.
Fixed: 498783665
Change-Id: I8b09fdfa418d4d9846d94ab18a7e5f904d94d983
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7837898
Auto-Submit: Arthur Sonzogni <arthursonzogni@chromium.org>
Commit-Queue: Rakina Zata Amni <rakina@chromium.org>
Reviewed-by: Rakina Zata Amni <rakina@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1630370}
---
diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index 7a5e12c..682db42 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -15,10 +15,12 @@
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
#include "base/threading/thread_restrictions.h"
+#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/web_contents/web_contents_impl.h"
#include "content/common/content_navigation_policy.h"
+#include "content/common/frame.mojom.h"
#include "content/public/common/url_constants.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -37,6 +39,7 @@
#include "net/dns/mock_host_resolver.h"
#include "services/network/public/cpp/web_sandbox_flags.h"
#include "third_party/blink/public/common/features.h"
+#include "third_party/blink/public/common/page_state/page_state.h"
#include "url/gurl.h"
#include "url/url_constants.h"
@@ -465,6 +468,52 @@
EXPECT_EQ(0u, sub_document->child_count());
}
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest,
+ MhtmlSubframeSameDocumentOriginSpoof) {
+ MhtmlArchive mhtml_archive;
+ mhtml_archive.AddHtmlDocument(
+ GURL("http://example.com"),
+ "<iframe src=\"http://example.com/subframe.html\"></iframe>");
+ mhtml_archive.AddHtmlDocument(GURL("http://example.com/subframe.html"),
+ "subframe content");
+ GURL mhtml_url = mhtml_archive.Write("index.mhtml");
+
+ EXPECT_TRUE(NavigateToURL(shell(), mhtml_url));
+
+ RenderFrameHostImpl* main_document = main_frame_host();
+ ASSERT_EQ(1u, main_document->child_count());
+ RenderFrameHostImpl* sub_document =
+ main_document->child_at(0)->current_frame_host();
+
+ EXPECT_TRUE(main_document->is_mhtml_document());
+ EXPECT_TRUE(sub_document->is_mhtml_document());
+ EXPECT_TRUE(sub_document->GetLastCommittedOrigin().opaque());
+
+ // Simulate a compromised renderer sending a malicious
+ // DidCommitSameDocumentNavigation IPC with a non-opaque origin.
+ auto params = mojom::DidCommitProvisionalLoadParams::New();
+ params->url = GURL("https://victim.example/#poc");
+ params->origin = url::Origin::Create(GURL("https://victim.example"));
+ params->navigation_token = base::UnguessableToken::Create();
+ // Fill in other required params to avoid other validation failures.
+ params->did_create_new_entry = false;
+ params->method = "GET";
+ params->page_state = blink::PageState::CreateFromURL(params->url);
+ params->transition = ui::PAGE_TRANSITION_AUTO_SUBFRAME;
+ params->referrer = blink::mojom::Referrer::New();
+
+ auto same_doc_params = mojom::DidCommitSameDocumentNavigationParams::New();
+
+ // We expect the renderer to be killed.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess());
+
+ static_cast<mojom::FrameHost*>(sub_document)
+ ->DidCommitSameDocumentNavigation(std::move(params),
+ std::move(same_doc_params));
+
+ EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
+}
+
// 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 bdf5447..171bee2 100644
--- a/content/browser/renderer_host/render_frame_host_impl.cc
+++ b/content/browser/renderer_host/render_frame_host_impl.cc
@@ -11771,6 +11771,15 @@
// (e.g. "http://localhost"). In such cases, don't verify the URL, but require
// 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.
+ if (!origin.opaque()) {
+ LogCanCommitOriginAndUrlFailureReason("mhtml_subframe_non_opaque_origin");
+ return CanCommitStatus::CANNOT_COMMIT_ORIGIN;
+ }
RenderFrameHostImpl* main_frame = GetMainFrame();
if (IsSameSiteInstance(main_frame)) {
return CanCommitStatus::CAN_COMMIT_ORIGIN_AND_URL;
Regression Test / PoC
diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index 7a5e12c..682db42 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -15,10 +15,12 @@
#include "base/test/bind.h"
#include "base/test/scoped_feature_list.h"
#include "base/threading/thread_restrictions.h"
+#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/web_contents/web_contents_impl.h"
#include "content/common/content_navigation_policy.h"
+#include "content/common/frame.mojom.h"
#include "content/public/common/url_constants.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
@@ -37,6 +39,7 @@
#include "net/dns/mock_host_resolver.h"
#include "services/network/public/cpp/web_sandbox_flags.h"
#include "third_party/blink/public/common/features.h"
+#include "third_party/blink/public/common/page_state/page_state.h"
#include "url/gurl.h"
#include "url/url_constants.h"
@@ -465,6 +468,52 @@
EXPECT_EQ(0u, sub_document->child_count());
}
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlBrowserTest,
+ MhtmlSubframeSameDocumentOriginSpoof) {
+ MhtmlArchive mhtml_archive;
+ mhtml_archive.AddHtmlDocument(
+ GURL("http://example.com"),
+ "<iframe src=\"http://example.com/subframe.html\"></iframe>");
+ mhtml_archive.AddHtmlDocument(GURL("http://example.com/subframe.html"),
+ "subframe content");
+ GURL mhtml_url = mhtml_archive.Write("index.mhtml");
+
+ EXPECT_TRUE(NavigateToURL(shell(), mhtml_url));
+
+ RenderFrameHostImpl* main_document = main_frame_host();
+ ASSERT_EQ(1u, main_document->child_count());
+ RenderFrameHostImpl* sub_document =
+ main_document->child_at(0)->current_frame_host();
+
+ EXPECT_TRUE(main_document->is_mhtml_document());
+ EXPECT_TRUE(sub_document->is_mhtml_document());
+ EXPECT_TRUE(sub_document->GetLastCommittedOrigin().opaque());
+
+ // Simulate a compromised renderer sending a malicious
+ // DidCommitSameDocumentNavigation IPC with a non-opaque origin.
+ auto params = mojom::DidCommitProvisionalLoadParams::New();
+ params->url = GURL("https://victim.example/#poc");
+ params->origin = url::Origin::Create(GURL("https://victim.example"));
+ params->navigation_token = base::UnguessableToken::Create();
+ // Fill in other required params to avoid other validation failures.
+ params->did_create_new_entry = false;
+ params->method = "GET";
+ params->page_state = blink::PageState::CreateFromURL(params->url);
+ params->transition = ui::PAGE_TRANSITION_AUTO_SUBFRAME;
+ params->referrer = blink::mojom::Referrer::New();
+
+ auto same_doc_params = mojom::DidCommitSameDocumentNavigationParams::New();
+
+ // We expect the renderer to be killed.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess());
+
+ static_cast<mojom::FrameHost*>(sub_document)
+ ->DidCommitSameDocumentNavigation(std::move(params),
+ std::move(same_doc_params));
+
+ EXPECT_EQ(bad_message::RFH_INVALID_ORIGIN_ON_COMMIT, kill_waiter.Wait());
+}
+
// 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
Site Isolation bypass via MHTML subframe origin spoofing 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 without the security team.
Overview: A vulnerability in RenderFrameHostImpl::CanCommitOriginAndUrl allows a compromised renderer hosting an MHTML subframe to spoof its origin. By sending a fabricated same-document navigation IPC, the renderer can bypass origin validation and arbitrarily overwrite the browser’s last_committed_origin_. This can lead to privilege escalation, allowing the attacker to abuse permissions or WebAuthn credentials associated with the spoofed origin.
Affected files:
content/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2024-06-13
Description
A potential Site Isolation bypass exists in the browser’s origin validation logic for MHTML subframes. RenderFrameHostImpl::CanCommitOriginAndUrl contains an early-return short-circuit for MHTML subframes that unconditionally accepts the renderer-provided origin without validating it.
When combined with the fact that features::kEnforceSameDocumentOriginInvariants is currently disabled by default, a compromised renderer can exploit this short-circuit to forge a same-document navigation IPC containing an arbitrary cross-origin origin. The browser process will accept this IPC and update its authoritative last_committed_origin_ for the frame to the attacker’s spoofed origin.
Technical Details
In content/browser/renderer_host/render_frame_host_impl.cc, the CanCommitOriginAndUrl function short-circuits validation for MHTML subframes:
if (IsMhtmlSubframe()) {
RenderFrameHostImpl* main_frame = GetMainFrame();
if (IsSameSiteInstance(main_frame)) {
return CanCommitStatus::CAN_COMMIT_ORIGIN_AND_URL;
}
// ...
}
This bypasses several critical security checks, including the ChildProcessSecurityPolicyImpl enforcement. Furthermore, the fallback check for same-document origin changes (which operates when kEnforceSameDocumentOriginInvariants is disabled) is positioned after this MHTML block, meaning the origin invariance check is completely skipped.
When a malicious same-document navigation IPC passes this validation, execution eventually reaches RenderFrameHostImpl::DidNavigate. Because kEnforceSameDocumentOriginInvariants is disabled, the browser unconditionally executes SetLastCommittedOrigin(params.origin) for same-document navigations.
Once last_committed_origin_ is corrupted, the attacker can silently abuse any browser-side APIs that rely on it, such as permission requests (camera/mic/location) or WebAuthn assertions for the victim origin.
Potential Attack Steps
Note: Our tooling agent cannot run code, so these are potential steps to trigger the vulnerability based on static analysis.
- The victim opens a local MHTML file, causing the browser to load a main frame and subframe in the same
SiteInstancewithis_mhtml_document_set to true. - The attacker gains code execution in the renderer process (e.g., via a memory corruption bug in an image or font decoder, as JS is disabled in MHTML by default).
- The compromised renderer constructs a malicious
FrameHost::DidCommitSameDocumentNavigationIPC targeted at the subframe. - The IPC parameters include a spoofed origin (e.g.,
https://victim.example) and a random, unrecognizednavigation_token. - The browser processes the IPC. The unknown token causes it to synthesize a
NavigationRequest, but validation relies onCanCommitOriginAndUrl. - The MHTML short-circuit in
CanCommitOriginAndUrlunconditionally accepts the spoofed origin. RenderFrameHostImpl::DidNavigatecallsSetLastCommittedOrigin(params.origin), successfully spoofing the browser process’s state.
Suggested Fix
Move the fallback same-document origin check (which runs when kEnforceSameDocumentOriginInvariants is disabled) to occur before the IsMhtmlSubframe() early return in CanCommitOriginAndUrl. Alternatively, explicitly enforce that MHTML subframes must commit with opaque origins in RenderFrameHostImpl::ValidateDidCommitParams(), similar to how error pages are currently validated.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
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.