CVE-2026-79228
Overview
Background
- Initiator origin
- the origin of the frame that started a navigation, sent from the renderer over IPC (e.g.,
BeginNavigation/OpenURL) and verified by the browser to prevent origin spoofing. - Opaque origin and precursor
- an opaque origin carries a “precursor” tuple identifying the origin it was derived from;
GetTupleOrPrecursorTupleIfOpaque()returns that precursor, which downstream consumers may trust for security decisions. - `VerifyInitiatorOrigin`
- the browser-side helper (in
ipc_utils.cc) that checks whether a renderer-claimed initiator origin is legitimate for the sending process, usingChildProcessSecurityPolicyImpl. - Error document / non-isolated subframe error page
- a committed error page; some subframe error pages are not process-isolated, so the embedding site’s renderer runs them and can send IPCs on their behalf.
Root Cause Analysis
The changed function (enclosing name not in the hunk; from the file it is VerifyInitiatorOrigin (inferred)) validated renderer-supplied initiator origins, but for opaque initiators it early-returned true (skipping verification) in two special cases, including if (current_rfh && current_rfh->IsErrorDocument()) return true;. This exemption meant that when the navigating frame was an error document, any opaque initiator origin was accepted regardless of its precursor. Because subframe error pages are not process-isolated, the embedding site’s (compromised) renderer could send BeginNavigation/OpenURL on behalf of such a frame claiming an opaque initiator with an arbitrary precursor, which downstream code then trusts via GetTupleOrPrecursorTupleIfOpaque() — an incorrect-authorization / origin-spoofing flaw.
The fix removes both opaque-origin exemptions (the error-page case and the MHTML-subframe case) so verification always proceeds to the HostsOrigin() / committed-origin check. Per the commit message this is safe because ChildProcessSecurityPolicyImpl::HostsOrigin() now relies on committed-origin tracking: any error page that could legitimately initiate a navigation has already committed in the process and had its opaque origin recorded via ChildProcessSecurityPolicyImpl::AddCommittedOrigin, so legitimate reloads still pass while spoofed precursors are rejected.
return true that skipped initiator-origin verification for any opaque origin whenever the frame was an error document, letting a renderer forge the precursor; the fix deletes that exemption so the committed-origin (HostsOrigin()) check always runs.Attack Path
- Commit a non-isolated subframe error page
In a page locked to
a.com, the attacker causes a subframe navigation to fail (e.g., via aframe-src 'none'CSP) so the subframe commits an error page that stays in thea.comprocess. - Compromise/abuse the embedding renderer
Because the subframe error page is not process-isolated, the
a.comrenderer can sendBeginNavigation/OpenURLIPCs on behalf of the error frame. - Forge the initiator origin
The renderer sends the navigation claiming an opaque initiator origin whose precursor is an arbitrary origin (e.g.,
evil.com), as the regression test does withDeriveNewOpaqueOrigin(). - Bypass verification via the exemption
Pre-patch,
VerifyInitiatorOriginseescurrent_rfh->IsErrorDocument()is true and returnstrue, accepting the forged opaque initiator without checking the precursor against the process lock. - Downstream trust of the fake precursor
Consumers read the precursor via
GetTupleOrPrecursorTupleIfOpaque()and make security decisions based on the spoofed origin (concrete downstream abuse not shown in the diff)(inferred).
Impact Assessment
bad_message::INVALID_INITIATOR_ORIGIN renderer kill). The advisory classifies this as incorrect authorization in Site Isolation and does not report exploitation in the wild; the commit notes the subframe-without-error-page-isolation case has the greater security consequence.Files Changed
content/browser/renderer_host/ipc_utils.cccontent/browser/security_exploit_browsertest.cc
Audit Directions
- Opaque-origin verification skipsSearch origin/initiator verification for early returns that bypass checks when the origin
opaque()or the frame is an error/MHTML document; each such exemption can let a renderer forge a precursor. - Non-process-isolated frame typesAudit navigation IPC handling for frame categories (error pages, MHTML subframes, sandboxed frames) that share a renderer with other origins, since the hosting renderer can act on their behalf.
- Trust in `GetTupleOrPrecursorTupleIfOpaque()`Review downstream consumers that make security decisions from an opaque origin’s precursor and confirm the precursor was validated against committed-origin tracking / the process lock upstream.
Patch
From 3ac9952760d91169308e05d855a87fc5a2c4733a Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <alexmos@chromium.org>
Date: Mon, 20 Jul 2026 14:37:39 -0700
Subject: [PATCH] Remove error page opaque origin exemption from VerifyInitiatorOrigin
VerifyInitiatorOrigin() currently early-returns true for any opaque
initiator_origin when the navigating frame is an error document,
regardless of the precursor. Since subframe error pages are not
process-isolated, this allows the embedding renderer to send
BeginNavigation/OpenURL on behalf of such a frame with an opaque
initiator carrying an arbitrary precursor, which is then trusted by
downstream consumers via GetTupleOrPrecursorTupleIfOpaque().
This CL removes this exemption because it is actually no longer
needed. This is because ChildProcessSecurityPolicyImpl::HostsOrigin()
has now been switched to rely on committed origin tracking. Any error
page that could legitimately initiate 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, even if the precursor
doesn't match the process lock. So, cases like error page reloads
should continue to work normally without this exemption.
This is already covered in tests for both main frames (with
ErrorPageNavigationReload) and subframes (with
ErrorPageNavigationReload_InSubframe_BlockedByClient). It matters both
with and without error page isolation (e.g., in the main frame case,
the initiator origin's precursor corresponds to the unreachable URL's
origin, but the process lock is chrome-error://chromewebdata), though
the subframe case without error page isolation has more security
consequences, since a dedicated error page process is much harder to
compromise. A new test is added to ensure a compromised renderer can't
spoof the initiator origin for the subframe error case.
Note also that subframe error pages that stay in process now commit
with an empty precursor after r1624238, which is another reason why
they no longer need this exemption.
Bug: 516398679
Change-Id: I83c231ab7c57a9e0b10dad74324296db2a06e42e
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8126215
Reviewed-by: Łukasz Anforowicz <lukasza@chromium.org>
Commit-Queue: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1664952}
---
diff --git a/content/browser/renderer_host/ipc_utils.cc b/content/browser/renderer_host/ipc_utils.cc
index 9c5c718..088e6b31 100644
--- a/content/browser/renderer_host/ipc_utils.cc
+++ b/content/browser/renderer_host/ipc_utils.cc
@@ -80,41 +80,22 @@
const RenderFrameHostImpl* current_rfh = nullptr,
GURL* navigation_url = nullptr,
std::optional<blink::LocalFrameToken>* initiator_frame_token = nullptr) {
- // TODO(crbug.com/40109437): Ideally, origin verification should be performed
- // even if `initiator_origin` is opaque, to ensure that the precursor origin
- // matches the process lock. However, there are a couple of cases where this
- // doesn't yet work, which are documented and skipped below.
- if (initiator_origin.opaque()) {
- // Reloads initiated from error pages may currently lead to a precursor
- // mismatch, since the error page loads with an opaque origin with the
- // original URL's origin as its precursor, which may not match the error
- // page's process lock. This is seen in the following
- // RenderFrameHostManagerTest tests:
- // 1. ErrorPageNavigationReload:
- // - renderer origin lock = chrome-error://chromewebdata/
- // - precursor of initiator origin = http://127.0.0.1:.../
- // 2. ErrorPageNavigationReload_InSubframe_BlockedByClient
- // - renderer origin lock = http://b.com:.../
- // - precursor of initiator origin = http://c.com:.../
- if (current_rfh && current_rfh->IsErrorDocument()) {
- return true;
- }
-
- // 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/
- // 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.
- }
+ // Important Note about opaque origins: these checks used to be skipped for
+ // opaque origins in two tricky cases, error pages and MHTML subframes. These
+ // exemptions are no longer needed now that ChildProcessSecurityPolicy's
+ // enforcements have been switched to use committed origin tracking. Any error
+ // page or MHTML subframe that could legitimately initiate 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, even if the precursor doesn't match the process
+ // lock. This is covered in tests such as ErrorPageNavigationReload,
+ // ErrorPageNavigationReload_InSubframe_BlockedByClient, and
+ // NavigationMhtmlBrowserTest.DataIframe.
+ //
+ // Warning: avoid skipping this check for future cases, as doing so carries
+ // security consequences, allowing the renderer to claim an opaque initiator
+ // with an arbitrary precursor. See crbug.com/516398679 and
+ // crbug.com/517606780.
auto* policy = ChildProcessSecurityPolicyImpl::GetInstance();
// TODO(crbug.com/379869738): Remove GetUnsafeValue.
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 1cafdb7..5f9b27f22 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -2376,6 +2376,72 @@
EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
}
+// Similar to the test above, but ensure that opaque initiator origins are
+// validated even when the frame sending BeginNavigation is currently showing an
+// error page. Some subframe error pages are not currently process-isolated, so
+// a renderer for the embedding site can send IPCs on their behalf and must not
+// be allowed to claim an arbitrary precursor.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ InvalidBeginNavigationOpaqueInitiatorFromErrorPage) {
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ // Prepare to intercept BeginNavigation mojo IPC. This has to be done before
+ // the test creates the RenderFrameHostImpl that is the target of the IPC.
+ url::Origin injected_origin =
+ url::Origin::Create(GURL("http://evil.com")).DeriveNewOpaqueOrigin();
+ BeginNavigationInitiatorReplacer injector(web_contents, injected_origin);
+
+ // Explicitly isolating a.com helps ensure that this test is applicable on
+ // platforms without site-per-process.
+ IsolateOrigin("a.com");
+
+ // Navigate to a test page that will be locked to a.com.
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(web_contents, main_url));
+ RenderFrameHostImpl* main_frame =
+ web_contents->GetPrimaryFrameTree().root()->current_frame_host();
+
+ // Set CSP to block iframes, so the subframe navigation below results in an
+ // error page that stays in the current (a.com) process.
+ EXPECT_TRUE(ExecJs(main_frame,
+ "var meta = document.createElement('meta');"
+ "meta.httpEquiv = 'Content-Security-Policy';"
+ "meta.content = \"frame-src 'none'\";"
+ "document.head.appendChild(meta);"));
+
+ // Create a subframe that fails to load and commits an error page.
+ GURL blocked_url(embedded_test_server()->GetURL("a.com", "/title2.html"));
+ TestNavigationObserver error_observer(web_contents);
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("var f = document.createElement('iframe');"
+ "f.src = $1;"
+ "document.body.appendChild(f);",
+ blocked_url)));
+ error_observer.Wait();
+ EXPECT_FALSE(error_observer.last_navigation_succeeded());
+
+ RenderFrameHostImpl* error_frame =
+ main_frame->child_at(0)->current_frame_host();
+ ASSERT_TRUE(error_frame->IsErrorDocument());
+
+ // Start monitoring for renderer kills.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(error_frame->GetProcess());
+
+ // Have the error page subframe navigate and lie that the initiator origin is
+ // an opaque origin with the precursor of evil.com. Note that an absolute URL
+ // is required here, since relative URLs in error documents resolve against
+ // chrome-error://chromewebdata/ and would be blocked in the renderer.
+ injector.Activate();
+ // Don't expect a response for the script, as the process may be killed
+ // before the script sends its completion message.
+ ExecuteScriptAsync(error_frame,
+ JsReplace("window.location = $1;", blocked_url));
+
+ // Verify that the renderer was terminated.
+ EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
+}
+
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
MissingBeginNavigationInitiator) {
// Prepare to intercept BeginNavigation mojo IPC. This has to be done before
Regression Test / PoC
diff --git a/content/browser/security_exploit_browsertest.cc b/content/browser/security_exploit_browsertest.cc
index 1cafdb7..5f9b27f22 100644
--- a/content/browser/security_exploit_browsertest.cc
+++ b/content/browser/security_exploit_browsertest.cc
@@ -2376,6 +2376,72 @@
EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
}
+// Similar to the test above, but ensure that opaque initiator origins are
+// validated even when the frame sending BeginNavigation is currently showing an
+// error page. Some subframe error pages are not currently process-isolated, so
+// a renderer for the embedding site can send IPCs on their behalf and must not
+// be allowed to claim an arbitrary precursor.
+IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
+ InvalidBeginNavigationOpaqueInitiatorFromErrorPage) {
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+
+ // Prepare to intercept BeginNavigation mojo IPC. This has to be done before
+ // the test creates the RenderFrameHostImpl that is the target of the IPC.
+ url::Origin injected_origin =
+ url::Origin::Create(GURL("http://evil.com")).DeriveNewOpaqueOrigin();
+ BeginNavigationInitiatorReplacer injector(web_contents, injected_origin);
+
+ // Explicitly isolating a.com helps ensure that this test is applicable on
+ // platforms without site-per-process.
+ IsolateOrigin("a.com");
+
+ // Navigate to a test page that will be locked to a.com.
+ GURL main_url(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ EXPECT_TRUE(NavigateToURL(web_contents, main_url));
+ RenderFrameHostImpl* main_frame =
+ web_contents->GetPrimaryFrameTree().root()->current_frame_host();
+
+ // Set CSP to block iframes, so the subframe navigation below results in an
+ // error page that stays in the current (a.com) process.
+ EXPECT_TRUE(ExecJs(main_frame,
+ "var meta = document.createElement('meta');"
+ "meta.httpEquiv = 'Content-Security-Policy';"
+ "meta.content = \"frame-src 'none'\";"
+ "document.head.appendChild(meta);"));
+
+ // Create a subframe that fails to load and commits an error page.
+ GURL blocked_url(embedded_test_server()->GetURL("a.com", "/title2.html"));
+ TestNavigationObserver error_observer(web_contents);
+ EXPECT_TRUE(
+ ExecJs(main_frame, JsReplace("var f = document.createElement('iframe');"
+ "f.src = $1;"
+ "document.body.appendChild(f);",
+ blocked_url)));
+ error_observer.Wait();
+ EXPECT_FALSE(error_observer.last_navigation_succeeded());
+
+ RenderFrameHostImpl* error_frame =
+ main_frame->child_at(0)->current_frame_host();
+ ASSERT_TRUE(error_frame->IsErrorDocument());
+
+ // Start monitoring for renderer kills.
+ RenderProcessHostBadIpcMessageWaiter kill_waiter(error_frame->GetProcess());
+
+ // Have the error page subframe navigate and lie that the initiator origin is
+ // an opaque origin with the precursor of evil.com. Note that an absolute URL
+ // is required here, since relative URLs in error documents resolve against
+ // chrome-error://chromewebdata/ and would be blocked in the renderer.
+ injector.Activate();
+ // Don't expect a response for the script, as the process may be killed
+ // before the script sends its completion message.
+ ExecuteScriptAsync(error_frame,
+ JsReplace("window.location = $1;", blocked_url));
+
+ // Verify that the renderer was terminated.
+ EXPECT_EQ(bad_message::INVALID_INITIATOR_ORIGIN, kill_waiter.Wait());
+}
+
IN_PROC_BROWSER_TEST_F(SecurityExploitBrowserTest,
MissingBeginNavigationInitiator) {
// Prepare to intercept BeginNavigation mojo IPC. This has to be done before
Original Bug Report
Bypass of initiator origin verification via error document subframes
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 logic flaw in the browser’s initiator origin verification allows a compromised renderer to spoof the precursor of an opaque initiator origin. By navigating from a subframe currently displaying an error page, the renderer can bypass process-level verification checks. This could potentially allow unauthorized framing of restricted extension resources or frame-isolation bypasses in Isolated Web Apps.
Affected files:
content/browser/renderer_host/ipc_utils.ccextensions/browser/extension_navigation_throttle.ccextensions/common/manifest_handlers/web_accessible_resources_info.cccontent/browser/renderer_host/isolated_web_app_throttle.cc
Estimated timestamp from git blame: 2024-06-14
Root Cause
In content/browser/renderer_host/ipc_utils.cc, the function VerifyInitiatorOrigin is responsible for verifying that a renderer-provided initiator origin is hosted by the calling process. However, when the initiator origin is opaque, VerifyInitiatorOrigin contains a potential security bypass for frames that are currently displaying an error document (lines 99-101):
if (initiator_origin.opaque()) {
// ...
if (current_rfh && current_rfh->IsErrorDocument()) {
return true;
}
// ...
}
If the subframe has committed an error page (e.g., due to a failed navigation), current_rfh->IsErrorDocument() returns true. Consequently, VerifyInitiatorOrigin immediately returns true, entirely skipping process-level verification via ChildProcessSecurityPolicyImpl::HostsOrigin (line 117).
This early-return allows a compromised renderer to submit a custom-serialized opaque url::Origin containing an arbitrary, spoofed precursor origin (via the Mojo deserializer in url/mojom/origin_mojom_traits.cc). Because the precursor origin is not verified against the process lock, the browser accepts the forged initiator origin as valid.
Impact
Several security-critical components rely on the precursor of opaque initiator origins for authorization decisions:
-
ExtensionNavigationThrottle (Manifest V3 WAR Bypass)
- In
extensions/browser/extension_navigation_throttle.cc, when checking if a resource is web-accessible viaIsResourceWebAccessibleRedirect, the code retrieves the initiator URL by callingGetInitiatorUrl(inextensions/common/manifest_handlers/web_accessible_resources_info.cc), which falls back to the precursor tuple if the origin is opaque. - By spoofing the precursor tuple to a partner site allowed in the extension’s manifest, a compromised renderer can bypass Manifest V3
web_accessible_resourcesrestrictions and frame restricted extension pages.
- In
-
IsolatedWebAppThrottle (Iframe Isolation Bypass)
- In
content/browser/renderer_host/isolated_web_app_throttle.cc, the throttle blocks non-app contexts from navigating app iframes. However, it permits the navigation if the initiator’s precursor matches the IWA origin (lines 252-259). - A compromised renderer can spoof the precursor to match the target IWA origin, allowing unauthorized cross-origin frames to navigate back into the secure IWA context.
- In
Potential Steps to Reproduce
Note: These are potential steps based on static code analysis; our tooling agent does not currently have the ability to run or execute code to verify them.
- Set up a subframe inside a compromised renderer process.
- Navigate the subframe to a URL guaranteed to fail (e.g., a blocked unsafe port like
http://example.com:79or a URL violating CSP) so that the subframe commits an error page, settingcurrent_rfh->IsErrorDocument()totrue. - Send a
FrameHost::BeginNavigationIPC request from the subframe pointing to a restricted extension resource or an IWA resource. - In the Mojo payload, set
initiator_originto an opaque origin containing a spoofed precursor tuple corresponding to an allowed partner site or the target origin itself. - Verify that
VerifyInitiatorOriginbypasses verification, allowing the restricted navigation to proceed.
Suggested Fix
Instead of unconditionally returning true when IsErrorDocument() is true for opaque origins, the browser should perform strict validation of the precursor origin. If the exception is strictly required to support reloads of failed error page navigations, the browser should explicitly verify that the spoofed precursor origin matches either the original failed navigation’s origin or the expected lock of the error document process, rather than accepting any arbitrary precursor provided by the renderer.
Evaluated with Chrome root at commit: a2bea94528f4bd6cc57739c43fa3bb890b8367d3
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.