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
Tracker517606780
Fix commitf61dcd7ebcda (chromium/src) +81/-9
CISA KEVNot listed
CreditedGoogle
Disclosed2026-08-25

Changed Functions

FunctionChangeNotes
BeginNavigationInitiatorReplacer
content/browser/navigation_mhtml_browsertest.cc
modified
if
content/browser/navigation_mhtml_browsertest.cc
modified

Files Changed

  • content/browser/navigation_mhtml_browsertest.cc
  • content/browser/renderer_host/ipc_utils.cc
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();
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 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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • content/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.

  1. A user loads a local or downloaded MHTML archive containing an <iframe> subframe S.
  2. The compromised renderer process hosting the MHTML document sends a FrameHost::BeginNavigation IPC targeting subframe S with:
    • common_params.url = "about:blank"
    • common_params.initiator_origin set to an opaque origin with a forged precursor of https://victim.example.
  3. VerifyInitiatorOrigin detects IsMhtmlSubframe() is true and accepts the forged initiator origin without validation.
  4. Since the URL is about:blank, the browser derives the commit origin directly from the initiator origin, preserving the forged precursor.
  5. The navigation completes. ChildProcessSecurityPolicy registers the origin opaque{precursor=https://victim.example} within the process’s committed origins.
  6. The compromised renderer then calls RouteMessageEvent (postMessage) targeting https://victim.example with the spoofed source origin, which successfully passes the HostsOrigin checks in RenderFrameProxyHost.

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.

View on issue tracker