Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Isolated
DescriptionIncorrect authorization in Isolated
ComponentIsolated
Bug ClassLogic Error
Tracker501700023
Fix commitf407661caed4 (chromium/src) +102/-25
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
if
content/browser/renderer_host/isolated_web_app_throttle.cc
modified
TEST_F
content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
modified

Files Changed

  • content/browser/renderer_host/isolated_web_app_throttle.cc
  • content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
  • content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
From f407661caed4a60715e559f3a734679ba5fa02bb Mon Sep 17 00:00:00 2001
From: greengrape <greengrape@google.com>
Date: Wed, 29 Jul 2026 01:14:44 -0700
Subject: [PATCH] Fix data: URL iframe origin checks in IsolatedWebAppThrottle

This patch updates IsolatedWebAppThrottle::MaybeThrottleNavigationTransition
to differentiate between the true app origin and opaque origins with an
app precursor. It ensures opaque data: iframes cannot bypass apps isolation
by collapsing their origin to their trusted precursor for the initiator check.

Bug: 501700023
TAG=agy

Change-Id: If8ec88b963c5a282a8aaa31b6d0a376b6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8159004
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Cr-Commit-Position: refs/heads/main@{#1670056}
---

diff --git a/content/browser/renderer_host/isolated_web_app_throttle.cc b/content/browser/renderer_host/isolated_web_app_throttle.cc
index 0cf236b..734bfdd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle.cc
@@ -193,15 +193,18 @@
     return dest_needs_apps_isolation ? block_action : ThrottleAction::PROCEED;
   }
 
-  // We want the following origin checks to be a bit more permissive than
-  // usual. In particular, if the isolation, previous, or destination origins
-  // are opaque, we want to use their precursor tuple for "origin" comparisons.
-  // This lets us allow navigations to/from data, error, or web bundle URLs
-  // that originate from the same precursor URLs. Other rules may block these
-  // navigations, but for the purpose of this throttle, these navigations are
-  // valid.
+  // For the destination origin we want to be a bit more permissive than usual
+  // and use the precursor tuple if the origin is opaque. This lets us allow
+  // navigations to data, error, or web bundle URLs that originate from the
+  // app. Other rules may block these navigations, but for the purpose of this
+  // throttle, these navigations are valid. Note that the previous, initiator,
+  // and parent origins below are intentionally compared as full origins, since
+  // an opaque-origin frame (e.g. a data: URL iframe created by the app) must
+  // not be treated as the app itself when initiating or hosting navigations
+  // into the app.
+  const url::Origin& app_origin = web_contents_isolation_info->origin();
   const url::SchemeHostPort& web_contents_isolation_tuple =
-      web_contents_isolation_info->origin().GetTupleOrPrecursorTupleIfOpaque();
+      app_origin.GetTupleOrPrecursorTupleIfOpaque();
   const url::SchemeHostPort& dest_tuple =
       dest_origin_.GetTupleOrPrecursorTupleIfOpaque();
 
@@ -245,21 +248,13 @@
     // initiated by a non-app frame. This ensures that all iframe navigations
     // into the app come from the app itself.
     if (navigation_handle()->IsRendererInitiated() && prev_origin_ &&
-        prev_origin_->GetTupleOrPrecursorTupleIfOpaque() !=
-            web_contents_isolation_tuple) {
+        *prev_origin_ != app_origin) {
       // Allow the navigation if it was initiated by the app, meaning it has a
       // trusted destination URL. This only applies to the initial request, as
       // redirect locations come from outside the app.
       if (navigation_handle()->GetRedirectChain().size() == 1 &&
-          navigation_handle()->GetInitiatorOrigin().has_value()) {
-        const url::SchemeHostPort& initiator_tuple =
-            navigation_handle()
-                ->GetInitiatorOrigin()
-                .value()
-                .GetTupleOrPrecursorTupleIfOpaque();
-        if (initiator_tuple == web_contents_isolation_tuple) {
-          return ThrottleAction::PROCEED;
-        }
+          navigation_handle()->GetInitiatorOrigin() == app_origin) {
+        return ThrottleAction::PROCEED;
       }
       return block_action;
     }
@@ -267,12 +262,8 @@
     // Block iframe navigations to the app's origin if the parent frame
     // doesn't belong to the app. This prevents non-app frames from having
     // access to an app frame.
-    const url::SchemeHostPort& parent_tuple =
-        navigation_handle()
-            ->GetParentFrame()
-            ->GetLastCommittedOrigin()
-            .GetTupleOrPrecursorTupleIfOpaque();
-    if (parent_tuple != web_contents_isolation_tuple) {
+    if (navigation_handle()->GetParentFrame()->GetLastCommittedOrigin() !=
+        app_origin) {
       return block_action;
     }
 
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
index 68ac43c..05efacd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
@@ -233,6 +233,45 @@
 }
 
 IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+                       DataIframeInitiatedNavigationIntoAppBlocked) {
+  GURL app_url = GetAppURL();
+  EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+  EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+  // A data: URL iframe created by the app commits with an opaque origin whose
+  // precursor is the app origin.
+  RenderFrameHost* iframe =
+      CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+  EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+  // The data: iframe must not be allowed to navigate itself back into the app.
+  std::unique_ptr<TestNavigationObserver> navigation_observer =
+      SelfNavigateIframeToURL(iframe, app_url);
+  EXPECT_FALSE(navigation_observer->last_navigation_succeeded());
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+                       AppInitiatedDataIframeNavigationIntoAppAllowed) {
+  GURL app_url = GetAppURL();
+  EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+  EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+  RenderFrameHost* iframe =
+      CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+  EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+  // The app's main frame is allowed to navigate the data: iframe into the app.
+  std::unique_ptr<TestNavigationObserver> navigation_observer =
+      NavigateIframeToUrlFromParent(iframe, app_url);
+  EXPECT_EQ(main_rfh()->GetFrameToken(),
+            navigation_observer->last_initiator_frame_token().value());
+  EXPECT_TRUE(navigation_observer->last_navigation_succeeded());
+  EXPECT_EQ(net::OK, navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
                        AppInitiatedIframeNavigationIntoAppAllowed) {
   GURL app_url = GetAppURL();
   EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
index c50eb0e4..ff8ca65 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -380,6 +380,53 @@
 }
 
 TEST_F(IsolatedWebAppThrottleTest,
+       BlockDataIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
+  CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+  EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+  FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+  // Navigate the iframe to a data: URL, which commits with an opaque origin
+  // whose precursor is the app origin.
+  const char kDataUrl[] = "data:text/html,body";
+  CommitRendererInitiatedNavigation(iframe_id, kDataUrl);
+  url::Origin iframe_origin =
+      FrameTreeNode::GloballyFindByID(iframe_id)->current_origin();
+  EXPECT_TRUE(iframe_origin.opaque());
+  EXPECT_EQ(
+      url::Origin::Create(GURL(kAppUrl)).GetTupleOrPrecursorTupleIfOpaque(),
+      iframe_origin.GetTupleOrPrecursorTupleIfOpaque());
+
+  // The data: iframe must not be allowed to navigate itself back into the app.
+  auto simulator = StartRendererInitiatedNavigation(iframe_id, kAppUrl2);
+
+  auto start_result = simulator->GetLastThrottleCheckResult();
+  EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIsolatedIframeInDataIframe) {
+  CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+  EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+
+  // Create a data: URL iframe, which commits with an opaque origin whose
+  // precursor is the app origin.
+  FrameTreeNodeId child_iframe_id =
+      CreateIframe(main_frame_id(), "test_frame1");
+  CommitRendererInitiatedNavigation(child_iframe_id, "data:text/html,body");
+  EXPECT_TRUE(FrameTreeNode::GloballyFindByID(child_iframe_id)
+                  ->current_origin()
+                  .opaque());
+
+  // Try to create an app iframe within the data: iframe.
+  FrameTreeNodeId grandchild_iframe_id =
+      CreateIframe(child_iframe_id, "test_frame2");
+  auto simulator =
+      StartRendererInitiatedNavigation(grandchild_iframe_id, kAppUrl);
+
+  auto start_result = simulator->GetLastThrottleCheckResult();
+  EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest,
        AllowIframeBrowserInitiatedNavigationIntoIsolatedWebApp) {
   CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
   EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
index 68ac43c..05efacd 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc
@@ -233,6 +233,45 @@
 }
 
 IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+                       DataIframeInitiatedNavigationIntoAppBlocked) {
+  GURL app_url = GetAppURL();
+  EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+  EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+  // A data: URL iframe created by the app commits with an opaque origin whose
+  // precursor is the app origin.
+  RenderFrameHost* iframe =
+      CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+  EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+  // The data: iframe must not be allowed to navigate itself back into the app.
+  std::unique_ptr<TestNavigationObserver> navigation_observer =
+      SelfNavigateIframeToURL(iframe, app_url);
+  EXPECT_FALSE(navigation_observer->last_navigation_succeeded());
+  EXPECT_EQ(net::ERR_BLOCKED_BY_CLIENT,
+            navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
+                       AppInitiatedDataIframeNavigationIntoAppAllowed) {
+  GURL app_url = GetAppURL();
+  EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
+  EXPECT_EQ(kIsolatedApplication, main_rfh()->GetWebExposedIsolationLevel());
+
+  RenderFrameHost* iframe =
+      CreateChildIframe(main_rfh(), GURL("data:text/html,body"));
+  EXPECT_TRUE(iframe->GetLastCommittedOrigin().opaque());
+
+  // The app's main frame is allowed to navigate the data: iframe into the app.
+  std::unique_ptr<TestNavigationObserver> navigation_observer =
+      NavigateIframeToUrlFromParent(iframe, app_url);
+  EXPECT_EQ(main_rfh()->GetFrameToken(),
+            navigation_observer->last_initiator_frame_token().value());
+  EXPECT_TRUE(navigation_observer->last_navigation_succeeded());
+  EXPECT_EQ(net::OK, navigation_observer->last_net_error_code());
+}
+
+IN_PROC_BROWSER_TEST_F(IsolatedWebAppThrottleBrowserTest,
                        AppInitiatedIframeNavigationIntoAppAllowed) {
   GURL app_url = GetAppURL();
   EXPECT_TRUE(NavigateToURL(web_contents(), app_url));
diff --git a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
index c50eb0e4..ff8ca65 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -380,6 +380,53 @@
 }
 
 TEST_F(IsolatedWebAppThrottleTest,
+       BlockDataIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
+  CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+  EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+  FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+  // Navigate the iframe to a data: URL, which commits with an opaque origin
+  // whose precursor is the app origin.
+  const char kDataUrl[] = "data:text/html,body";
+  CommitRendererInitiatedNavigation(iframe_id, kDataUrl);
+  url::Origin iframe_origin =
+      FrameTreeNode::GloballyFindByID(iframe_id)->current_origin();
+  EXPECT_TRUE(iframe_origin.opaque());
+  EXPECT_EQ(
+      url::Origin::Create(GURL(kAppUrl)).GetTupleOrPrecursorTupleIfOpaque(),
+      iframe_origin.GetTupleOrPrecursorTupleIfOpaque());
+
+  // The data: iframe must not be allowed to navigate itself back into the app.
+  auto simulator = StartRendererInitiatedNavigation(iframe_id, kAppUrl2);
+
+  auto start_result = simulator->GetLastThrottleCheckResult();
+  EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIsolatedIframeInDataIframe) {
+  CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+  EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+
+  // Create a data: URL iframe, which commits with an opaque origin whose
+  // precursor is the app origin.
+  FrameTreeNodeId child_iframe_id =
+      CreateIframe(main_frame_id(), "test_frame1");
+  CommitRendererInitiatedNavigation(child_iframe_id, "data:text/html,body");
+  EXPECT_TRUE(FrameTreeNode::GloballyFindByID(child_iframe_id)
+                  ->current_origin()
+                  .opaque());
+
+  // Try to create an app iframe within the data: iframe.
+  FrameTreeNodeId grandchild_iframe_id =
+      CreateIframe(child_iframe_id, "test_frame2");
+  auto simulator =
+      StartRendererInitiatedNavigation(grandchild_iframe_id, kAppUrl);
+
+  auto start_result = simulator->GetLastThrottleCheckResult();
+  EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest,
        AllowIframeBrowserInitiatedNavigationIntoIsolatedWebApp) {
   CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
   EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
Loading diff…

Original Bug Report

reported by vm...@google.com

IWA sandbox bypass via data: URL iframe precursor-collapse

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 Chrome Security team.

Overview: A logic error in IsolatedWebAppThrottle allows navigations from data: URL iframes into protected Isolated Web App (IWA) routes. The throttle incorrectly treats data: iframes as trusted app origins by collapsing their opaque origin to the IWA precursor.

Affected files:

  • content/browser/renderer_host/isolated_web_app_throttle.cc

Estimated timestamp from git blame: 2025-05-15

Description

IsolatedWebAppThrottle is responsible for enforcing security boundaries for Isolated Web Apps (IWAs). It ensures that renderer-initiated navigations into an IWA only originate from trusted app frames.

A logic error exists in MaybeThrottleNavigationTransition. The throttle uses GetTupleOrPrecursorTupleIfOpaque() to compare origins for security checks. If an IWA creates a data: URL iframe, the browser assigns the iframe an opaque origin, but sets its precursor to the IWA’s origin.

When a navigation is initiated from this data: iframe to an internal IWA route (isolated-app://...), the throttle evaluates the iframe’s origin (prev_origin_) using GetTupleOrPrecursorTupleIfOpaque(). This collapses the untrusted opaque origin down to the trusted IWA precursor. Consequently, the throttle believes the navigation originated from a trusted same-origin app frame and allows it to proceed.

Impact

If an IWA renders user-influenced content within a data: URL iframe, an attacker can use this bypass to navigate the iframe to internal IWA routes. This circumvents the intended security boundary separating the IWA from untrusted subframes.

Because the data: iframe is not restricted by the parent’s script-src CSP (it is an opaque origin), an attacker can execute script within the iframe and trigger a navigation. This enables GET-based CSRF against internal IWA routes, and potentially exposes DOM-XSS sinks within the protected IWA environment, bypassing the IWA’s isolation guarantees.

Potential Reproduction Steps

  1. Install an IWA that renders user-influenced HTML in an unsandboxed data: URL iframe (e.g., a file preview feature).
  2. Provide content to the iframe that executes: window.location.href = "isolated-app://<app-id>/sensitive-route".
  3. The navigation request is evaluated by IsolatedWebAppThrottle.
  4. The prev_origin_ check collapses the data: URL’s opaque origin to the <app-id>, matching the IWA origin.
  5. The navigation is incorrectly permitted, committing the protected internal route within the previously untrusted iframe.

Suggested Fix

Modify IsolatedWebAppThrottle::MaybeThrottleNavigationTransition to differentiate between the true app origin and opaque origins with an app precursor when evaluating the initiator of a navigation into the app.

For prev_origin_ and initiator_origin, the throttle should likely require a strict origin match (or explicitly handle opaque origins) rather than unconditionally collapsing them via GetTupleOrPrecursorTupleIfOpaque(), ensuring that only legitimate same-origin app frames can navigate to isolated-app:// URLs.

Evaluated with Chrome root at commit: 096fc8fdbfacf2546485756d03f160a3d04fcc9b


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.

View on issue tracker