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

Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactIncorrect authorization in Isolated
DescriptionIncorrect authorization in Isolated
ComponentIsolated
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.
Tracker501700023
Fix commitf407661caed4 (chromium/src) +102/-25
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-04-11 (per vendor advisory)
Fix landed2026-07-29 (public git)
Disclosed2026-09-08
Patch gap41 days — fix public in source before the advisory

Background

Isolated Web App (IWA)
a highly privileged web app served from a dedicated origin whose frames must be navigationally isolated so that only the app itself can create or navigate into app-origin documents.
`IsolatedWebAppThrottle`
a NavigationThrottle (in content/browser/renderer_host/isolated_web_app_throttle.cc) that inspects navigations touching an IWA and returns ThrottleAction::PROCEED or a block action to enforce the isolation invariant.
Opaque origin / precursor tuple
a data: (or error/web-bundle) document commits with an opaque url::Origin that records the origin that created it as its precursor; url::Origin::GetTupleOrPrecursorTupleIfOpaque() collapses such an opaque origin down to that precursor SchemeHostPort.
Initiator / previous / parent origin
the throttle distinguishes the origin that started a navigation, the frame’s prior origin, and the embedding parent frame’s origin to decide whether a navigation into the app truly comes from the app.

Root Cause Analysis

The vulnerable path is the origin-comparison logic in IsolatedWebAppThrottle::MaybeThrottleNavigationTransition (function named in the commit subject; the @@ hunk headers do not name it). Before the patch the throttle computed a single web_contents_isolation_tuple via GetTupleOrPrecursorTupleIfOpaque() and then compared the previous, initiator, and parent origins against it also after collapsing them to their precursor tuples. A data: iframe that the app creates commits with an opaque origin whose precursor is the app origin, so its collapsed tuple equals the app’s tuple — meaning the throttle treated an opaque, non-app frame as if it were the app itself and allowed it to initiate and host navigations into the privileged app origin, violating the invariant that only genuine app-origin frames may navigate into the app.

The fix introduces app_origin (the un-collapsed web_contents_isolation_info->origin()) and switches the previous (*prev_origin_ != app_origin), initiator (GetInitiatorOrigin() == app_origin), and parent (GetLastCommittedOrigin() != app_origin) checks to full-origin comparisons, so an opaque data: origin can never compare equal to the app origin. The destination check intentionally keeps precursor-tuple collapsing, which remains correct because being permissive about where an app-initiated navigation goes does not grant a non-app frame authority.

Key insight
The single mistake was applying GetTupleOrPrecursorTupleIfOpaque() to the initiator/previous/parent origins, which collapsed an opaque data: frame down to its app precursor and thereby authorized it as the app; the fix compares those origins as full origins so an opaque frame is never mistaken for the app itself.

Attack Path

  1. Load the IWA and spawn a data: subframe With an Isolated Web App loaded, the app (or content within it) creates a data: URL iframe, which commits with an opaque origin whose precursor tuple is the app origin.
  2. Attempt a renderer-initiated navigation into the app from the opaque frame The data: iframe issues a renderer-initiated navigation of itself (or a child) into the app URL, which is not a genuine app-origin frame.
  3. Precursor collapse bypasses the initiator/parent checks Pre-patch, prev_origin_->GetTupleOrPrecursorTupleIfOpaque(), the initiator tuple, and the parent tuple all collapse to the app precursor, so they equal web_contents_isolation_tuple; the throttle returns ThrottleAction::PROCEED instead of block_action.
  4. Non-app frame gains app-origin navigation authority The opaque frame is treated as the app, letting it initiate navigations into, or host frames of, the privileged app origin (inferred: this yields access to app-origin document context that isolation is meant to withhold).

Impact Assessment

An attacker controlling an opaque data: (or similarly opaque error/web-bundle) frame inside an Isolated Web App could bypass the IWA navigation-isolation check and initiate or host navigations into the app’s privileged origin, an incorrect-authorization flaw in the browser process’s navigation-throttle logic. The precondition is an IWA context in which an opaque-origin subframe with the app as its precursor exists. The advisory classifies this as incorrect authorization in Isolated Web Apps; any further escalation to app-origin data access or privileged-API abuse is (inferred) and there is no indication in the patch or advisory of exploitation in the wild.

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_unittest.cc
  • content/browser/renderer_host/isolated_web_app_throttle_browsertest.cc

Audit Directions

  • Precursor-tuple collapsing on trust decisions
    Audit any authorization or isolation check that calls GetTupleOrPrecursorTupleIfOpaque() on an initiator, previous, or parent origin, since collapsing opaque origins to their precursor grants opaque frames the trust of their creator.
  • Opaque-origin frames as security principals
    Review code that compares origins for equality where one side may be an opaque data:/error/web-bundle origin; ensure the security-sensitive side uses full-origin comparison rather than tuple/precursor comparison.
  • Asymmetry between source and destination checks
    Look for throttles or policy gates that intentionally relax destination checks but must keep source/initiator checks strict, and verify a single shared “tuple” value is not reused across both permissive and strict comparisons.
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