Medium watch CVSS 3.1 chrome Authorization Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
3.1
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in SiteIsolation
DescriptionIncorrect authorization in SiteIsolation
ComponentSiteIsolation
Bug ClassAuthorization Error
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker516398679
Fix commit3ac9952760d9 (chromium/src) +82/-35
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-05-25 (per vendor advisory)
Fix landed2026-07-20 (public git)
Disclosed2026-08-25
Patch gap36 days — fix public in source before the advisory

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, using ChildProcessSecurityPolicyImpl.
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.

Key insight
The single mistake was an unconditional 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

  1. 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 a frame-src 'none' CSP) so the subframe commits an error page that stays in the a.com process.
  2. Compromise/abuse the embedding renderer Because the subframe error page is not process-isolated, the a.com renderer can send BeginNavigation/OpenURL IPCs on behalf of the error frame.
  3. 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 with DeriveNewOpaqueOrigin().
  4. Bypass verification via the exemption Pre-patch, VerifyInitiatorOrigin sees current_rfh->IsErrorDocument() is true and returns true, accepting the forged opaque initiator without checking the precursor against the process lock.
  5. 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

A compromised or malicious renderer could spoof the initiator origin of a navigation started from a non-isolated subframe error page, supplying an opaque origin with an attacker-chosen precursor that downstream browser logic trusts — an incorrect-authorization bug in Site Isolation’s origin enforcement. It requires the ability to commit a subframe error page and send navigation IPCs from the embedding renderer (the fix’s new browser test drives exactly this and expects a 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.cc
  • content/browser/security_exploit_browsertest.cc

Audit Directions

  • Opaque-origin verification skips
    Search 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 types
    Audit 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.
From 3ac9952760d91169308e05d855a87fc5a2c4733a Mon Sep 17 00:00:00 2001
From: Alex Moshchuk <[email protected]>
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 <[email protected]>
Commit-Queue: Alex Moshchuk <[email protected]>
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
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • extensions/browser/extension_navigation_throttle.cc
  • extensions/common/manifest_handlers/web_accessible_resources_info.cc
  • content/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:

  1. ExtensionNavigationThrottle (Manifest V3 WAR Bypass)

    • In extensions/browser/extension_navigation_throttle.cc, when checking if a resource is web-accessible via IsResourceWebAccessibleRedirect, the code retrieves the initiator URL by calling GetInitiatorUrl (in extensions/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_resources restrictions and frame restricted extension pages.
  2. 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.

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.

  1. Set up a subframe inside a compromised renderer process.
  2. Navigate the subframe to a URL guaranteed to fail (e.g., a blocked unsafe port like http://example.com:79 or a URL violating CSP) so that the subframe commits an error page, setting current_rfh->IsErrorDocument() to true.
  3. Send a FrameHost::BeginNavigation IPC request from the subframe pointing to a restricted extension resource or an IWA resource.
  4. In the Mojo payload, set initiator_origin to an opaque origin containing a spoofed precursor tuple corresponding to an allowed partner site or the target origin itself.
  5. Verify that VerifyInitiatorOrigin bypasses 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.

View on issue tracker
Links in the report