CVE-2026-79191
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
BeginNavigationInitiatorReplacercontent/browser/navigation_mhtml_browsertest.cc |
modified | |
ifcontent/browser/navigation_mhtml_browsertest.cc |
modified |
Files Changed
content/browser/navigation_mhtml_browsertest.cccontent/browser/renderer_host/ipc_utils.cc
Patch
From f61dcd7ebcda685675065f7abf556c4f16f69ed1 Mon Sep 17 00:00:00 2001 From: Alex Moshchuk <[email protected]> Date: Mon, 20 Jul 2026 12:29:52 -0700 Subject: [PATCH] Validate opaque initiator precursors for MHTML subframes VerifyInitiatorOrigin() returned early for any opaque initiator origin when the navigated frame was an MHTML subframe, accepting whatever precursor tuple the renderer supplied. The exception was added before ChildProcessSecurityPolicyImpl tracked committed origins, when HostsOrigin() compared only against the process lock and rejected the legitimate archive-derived precursors seen in NavigationMhtmlBrowserTest.DataIframe. Now that AddCommittedOrigin() records every committed frame's origin and HostsOrigin() consults that set, any frame in an MHTML page that could legitimately initiate a navigation already passes the check. Drop the early return so the precursor is validated like any other initiator origin, and add a browser test that intercepts BeginNavigation on an MHTML subframe and injects an opaque initiator with an unrelated precursor, expecting the renderer to be terminated with INVALID_INITIATOR_ORIGIN. Bug: 517606780 Change-Id: I5d0f9b921d5df73fc4c32db8347baa0c2b7945c4 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8118708 Commit-Queue: Alex Moshchuk <[email protected]> Reviewed-by: Łukasz Anforowicz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1664853} --- diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc index df1989b..7126186 100644 --- a/content/browser/navigation_mhtml_browsertest.cc +++ b/content/browser/navigation_mhtml_browsertest.cc @@ -30,6 +30,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/frame_host_interceptor.h" #include "mojo/public/c/system/trap.h" #include "mojo/public/c/system/types.h" #include "mojo/public/cpp/system/data_pipe.h" @@ -41,6 +42,7 @@ #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/origin.h" #include "url/url_constants.h" namespace content { @@ -1022,4 +1024,71 @@ kill_waiter.Wait()); } +// Verifies that an MHTML subframe cannot start a navigation with an opaque +// initiator origin whose precursor doesn't correspond to anything that has +// committed in the MHTML document's process. +IN_PROC_BROWSER_TEST_F(NavigationMhtmlImprovementsBrowserTest, + MhtmlSubframeBeginNavigationOpaqueInitiatorPrecursor) { + // Intercepts BeginNavigation to overwrite the initiator origin once + // activated. This simulates a renderer that lies about the initiator. + class BeginNavigationInitiatorReplacer : public FrameHostInterceptor { + public: + BeginNavigationInitiatorReplacer(WebContents* web_contents, + url::Origin initiator_to_inject) + : FrameHostInterceptor(web_contents), + initiator_to_inject_(std::move(initiator_to_inject)) {} + + bool WillDispatchBeginNavigation( + RenderFrameHost* render_frame_host, + blink::mojom::CommonNavigationParamsPtr* common_params, + blink::mojom::BeginNavigationParamsPtr* begin_params, + mojo::PendingRemote<blink::mojom::BlobURLToken>* blob_url_token, + mojo::PendingAssociatedRemote<mojom::NavigationClient>* + navigation_client) override { + if (is_activated_) { + (*common_params)->initiator_origin = initiator_to_inject_; + is_activated_ = false; + } + return true; + } + + void Activate() { is_activated_ = true; } + + private: + url::Origin initiator_to_inject_; + bool is_activated_ = false; + }; + + // The interceptor must be created before the frames whose IPCs it will + // intercept. Use an opaque origin whose precursor is unrelated to anything + // the MHTML archive contains. + url::Origin injected_origin = + url::Origin::Create(GURL("https://other-precursor.example")) + .DeriveNewOpaqueOrigin(); + BeginNavigationInitiatorReplacer injector(web_contents(), injected_origin); + + 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(); + ASSERT_TRUE(sub_document->IsMhtmlSubframe()); + + // Start a renderer-initiated navigation in the subframe and overwrite its + // initiator origin. The renderer should be terminated since the precursor + // doesn't match anything the process is allowed to host. + RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess()); + injector.Activate(); + ExecuteScriptAsync(sub_document, "window.location = 'about:blank';"); + EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait()); +} + } // namespace content diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc index 94ee783..9c5c718 100644 --- a/content/browser/renderer_host/ipc_utils.cc +++ b/content/browser/renderer_host/ipc_utils.cc @@ -100,17 +100,20 @@ return true; } - // Certain (e.g., data:) navigations in subframes of MHTML documents may - // have precursor origins that do not match the process lock of the MHTML - // document. This is seen in NavigationMhtmlBrowserTest.DataIframe, where: + // Navigations in subframes of MHTML documents may have precursor origins + // that do not match the process lock of the MHTML document. This is seen + // in NavigationMhtmlBrowserTest.DataIframe, where: // - renderer origin lock = { file:/// sandboxed } // - precursor of initiator origin = http://8.8.8.8/ - // Note that RenderFrameHostImpl::CanCommitOriginAndUrl() similarly allows - // such navigations to commit, and it also ensures that they can only commit - // in the main frame MHTML document's process. - if (current_rfh && current_rfh->IsMhtmlSubframe()) { - return true; - } + // In the past, this case used to be special-cased here, but this is no + // longer needed now that ChildProcessSecurityPolicy's enforcements have + // been switched to use committed origin tracking. Any frame in the MHTML + // page that could legitimately initiate such a navigation has already + // committed in this process, so its (opaque) origin has been recorded by + // ChildProcessSecurityPolicyImpl::AddCommittedOrigin and the HostsOrigin() + // check below will accept it. There is therefore no need to skip the check + // for MHTML subframes, and doing so would allow the renderer to claim an + // opaque initiator with an arbitrary precursor. } auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
Regression Test / PoC
diff --git a/content/browser/navigation_mhtml_browsertest.cc b/content/browser/navigation_mhtml_browsertest.cc
index df1989b..7126186 100644
--- a/content/browser/navigation_mhtml_browsertest.cc
+++ b/content/browser/navigation_mhtml_browsertest.cc
@@ -30,6 +30,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/frame_host_interceptor.h"
#include "mojo/public/c/system/trap.h"
#include "mojo/public/c/system/types.h"
#include "mojo/public/cpp/system/data_pipe.h"
@@ -41,6 +42,7 @@
#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/origin.h"
#include "url/url_constants.h"
namespace content {
@@ -1022,4 +1024,71 @@
kill_waiter.Wait());
}
+// Verifies that an MHTML subframe cannot start a navigation with an opaque
+// initiator origin whose precursor doesn't correspond to anything that has
+// committed in the MHTML document's process.
+IN_PROC_BROWSER_TEST_F(NavigationMhtmlImprovementsBrowserTest,
+ MhtmlSubframeBeginNavigationOpaqueInitiatorPrecursor) {
+ // Intercepts BeginNavigation to overwrite the initiator origin once
+ // activated. This simulates a renderer that lies about the initiator.
+ class BeginNavigationInitiatorReplacer : public FrameHostInterceptor {
+ public:
+ BeginNavigationInitiatorReplacer(WebContents* web_contents,
+ url::Origin initiator_to_inject)
+ : FrameHostInterceptor(web_contents),
+ initiator_to_inject_(std::move(initiator_to_inject)) {}
+
+ bool WillDispatchBeginNavigation(
+ RenderFrameHost* render_frame_host,
+ blink::mojom::CommonNavigationParamsPtr* common_params,
+ blink::mojom::BeginNavigationParamsPtr* begin_params,
+ mojo::PendingRemote<blink::mojom::BlobURLToken>* blob_url_token,
+ mojo::PendingAssociatedRemote<mojom::NavigationClient>*
+ navigation_client) override {
+ if (is_activated_) {
+ (*common_params)->initiator_origin = initiator_to_inject_;
+ is_activated_ = false;
+ }
+ return true;
+ }
+
+ void Activate() { is_activated_ = true; }
+
+ private:
+ url::Origin initiator_to_inject_;
+ bool is_activated_ = false;
+ };
+
+ // The interceptor must be created before the frames whose IPCs it will
+ // intercept. Use an opaque origin whose precursor is unrelated to anything
+ // the MHTML archive contains.
+ url::Origin injected_origin =
+ url::Origin::Create(GURL("https://other-precursor.example"))
+ .DeriveNewOpaqueOrigin();
+ BeginNavigationInitiatorReplacer injector(web_contents(), injected_origin);
+
+ 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();
+ ASSERT_TRUE(sub_document->IsMhtmlSubframe());
+
+ // Start a renderer-initiated navigation in the subframe and overwrite its
+ // initiator origin. The renderer should be terminated since the precursor
+ // doesn't match anything the process is allowed to host.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(sub_document->GetProcess());
+ injector.Activate();
+ ExecuteScriptAsync(sub_document, "window.location = 'about:blank';");
+ EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
+}
+
} // namespace content
Original Bug Report
VerifyInitiatorOrigin bypass via MHTML subframe opaque initiator origin
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 security bypass in VerifyInitiatorOrigin potentially allows a compromised MHTML renderer process to skip process-lock validation checks on opaque initiator origins for MHTML subframes. By supplying an opaque origin containing an arbitrary, attacker-forged precursor origin, the renderer can successfully commit a navigation with the spoofed precursor. This weakness allows the compromised process to register the forged precursor origin within the ChildProcessSecurityPolicy’s committed origins list, bypassing subsequent Site Isolation defenses.
Affected files:
content/browser/renderer_host/ipc_utils.cccontent/browser/renderer_host/render_frame_host_impl.cc
Estimated timestamp from git blame: 2024-06-14
Root Cause Analysis
In content/browser/renderer_host/ipc_utils.cc, VerifyInitiatorOrigin is responsible for ensuring that a renderer process is authorized to host the initiator origin of a navigation. However, the function contains an early return that short-circuits the check if the initiator origin is opaque and the target frame is an MHTML subframe:
// content/browser/renderer_host/ipc_utils.cc
if (initiator_origin.opaque()) {
...
if (current_rfh && current_rfh->IsMhtmlSubframe()) {
return true;
}
}
auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
if (!policy->HostsOrigin(process_id, initiator_origin)) {
...
}
Because the browser returns true early on line 112, it skips the subsequent HostsOrigin check. This allows a compromised MHTML process to supply an opaque origin wrapping an arbitrary, forged precursor origin (such as https://victim.example) without triggering a process termination.
During navigation commit, RenderFrameHostImpl::CanCommitOriginAndUrl similarly allows opaque origins to commit in an MHTML subframe if it resides in the same SiteInstance as the main frame, which also skips standard process-lock checks on the precursor tuple.
Upon commit, RenderFrameHostImpl::UpdatePermissionsForNavigation registers the committed origin via ChildProcessSecurityPolicyImpl::AddCommittedOrigin, adding opaque{precursor=https://victim.example} to the process’s committed_origins_ list. This enables the compromised process to pass subsequent HostsOrigin checks for the spoofed precursor origin, allowing it to bypass cross-origin boundaries (such as spoofing the source origin in postMessage calls).
Potential Steps to Trigger the Vulnerability
Note: These steps are based on static code analysis; our analysis does not currently include a running proof of concept.
- A user loads a local or downloaded MHTML archive containing an
<iframe>subframe S. - The compromised renderer process hosting the MHTML document sends a
FrameHost::BeginNavigationIPC targeting subframe S with:common_params.url = "about:blank"common_params.initiator_originset to an opaque origin with a forged precursor ofhttps://victim.example.
VerifyInitiatorOrigindetectsIsMhtmlSubframe()is true and accepts the forged initiator origin without validation.- Since the URL is
about:blank, the browser derives the commit origin directly from the initiator origin, preserving the forged precursor. - The navigation completes.
ChildProcessSecurityPolicyregisters the originopaque{precursor=https://victim.example}within the process’s committed origins. - The compromised renderer then calls
RouteMessageEvent(postMessage) targetinghttps://victim.examplewith the spoofed source origin, which successfully passes theHostsOriginchecks inRenderFrameProxyHost.
Suggested Remediation
To remediate this issue, the browser should ensure that the precursor of any opaque initiator origin supplied by an MHTML subframe is validated against the process lock of the MHTML renderer process instead of being skipped. VerifyInitiatorOrigin should check that the precursor of the initiator origin is compatible with the process lock, or enforce that the precursor matches the main frame’s origin (or is otherwise authorized), rather than returning true unconditionally.
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.