CVE-2026-87580
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifcontent/browser/renderer_host/isolated_web_app_throttle.cc |
modified | |
TEST_Fcontent/browser/renderer_host/isolated_web_app_throttle_unittest.cc |
modified |
Files Changed
content/browser/renderer_host/isolated_web_app_throttle.cccontent/browser/renderer_host/isolated_web_app_throttle_unittest.cc
Patch
From fff02dfcceccf3d62c6e31b8841176a926317aa4 Mon Sep 17 00:00:00 2001
From: greengrape <greengrape@google.com>
Date: Tue, 28 Jul 2026 12:27:32 -0700
Subject: [PATCH] Fix isolated web app throttle for iframes
A compromised Isolated Web App (IWA) renderer could drive iframe navigation requests into any other installed IWA.
This updates content::IsolatedWebAppThrottle::MaybeThrottleNavigationTransition to correctly handle iframe navigations to other IWAs, preventing them from bypassing apps isolation.
Bug: 502986244
TAG=agy
Change-Id: I1499c81dc446ddf5b76249ec25e57a406a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8158080
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Reviewed-by: Alex Moshchuk <alexmos@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1669655}
---
diff --git a/content/browser/renderer_host/isolated_web_app_throttle.cc b/content/browser/renderer_host/isolated_web_app_throttle.cc
index 96c9f35d..0cf236b 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle.cc
@@ -233,9 +233,12 @@
// Handle iframe navigations.
CHECK(!navigation_handle()->IsInMainFrame());
- // Iframes are allowed to leave the app's origin.
+ // Iframes are allowed to leave the app's origin, but not to navigate
+ // into a different Isolated Web App.
if (dest_tuple != web_contents_isolation_tuple) {
- return ThrottleAction::PROCEED;
+ return IsNavigatingToIsolatedApplication(navigation_handle())
+ ? block_action
+ : ThrottleAction::PROCEED;
}
// Block renderer-initiated iframe navigations into the app that were
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 fe16716..c50eb0e4 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -36,6 +36,7 @@
const char kAppUrl[] = "https://isolated.app";
const char kAppUrl2[] = "https://isolated.app/page";
+const char kOtherAppUrl[] = "https://other-isolated.app";
const char kNonAppUrl[] = "https://example.com";
const char kNonAppUrl2[] = "https://example.com/page";
static constexpr WebExposedIsolationLevel kNotIsolated =
@@ -47,7 +48,8 @@
public:
bool ShouldUrlUseApplicationIsolationLevel(BrowserContext* browser_context,
const GURL& url) override {
- return url.GetHost() == GURL(kAppUrl).GetHost();
+ return url.GetHost() == GURL(kAppUrl).GetHost() ||
+ url.GetHost() == GURL(kOtherAppUrl).GetHost();
}
bool HandleExternalProtocol(
@@ -331,6 +333,36 @@
CommitRendererInitiatedNavigation(iframe_id, kNonAppUrl, corp_coep_headers());
}
+TEST_F(IsolatedWebAppThrottleTest, BlockIframeNavigationToOtherIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ // Navigating an iframe to a different Isolated Web App should be blocked.
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kOtherAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIframeRedirectToOtherIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kNonAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::PROCEED, start_result.action());
+
+ // Redirect to a different Isolated Web App.
+ simulator->SetRedirectHeaders(corp_coep_headers());
+ simulator->Redirect(GURL(kOtherAppUrl));
+
+ auto redirect_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, redirect_result.action());
+}
+
TEST_F(IsolatedWebAppThrottleTest,
BlockIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
Regression Test / PoC
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 fe16716..c50eb0e4 100644
--- a/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
+++ b/content/browser/renderer_host/isolated_web_app_throttle_unittest.cc
@@ -36,6 +36,7 @@
const char kAppUrl[] = "https://isolated.app";
const char kAppUrl2[] = "https://isolated.app/page";
+const char kOtherAppUrl[] = "https://other-isolated.app";
const char kNonAppUrl[] = "https://example.com";
const char kNonAppUrl2[] = "https://example.com/page";
static constexpr WebExposedIsolationLevel kNotIsolated =
@@ -47,7 +48,8 @@
public:
bool ShouldUrlUseApplicationIsolationLevel(BrowserContext* browser_context,
const GURL& url) override {
- return url.GetHost() == GURL(kAppUrl).GetHost();
+ return url.GetHost() == GURL(kAppUrl).GetHost() ||
+ url.GetHost() == GURL(kOtherAppUrl).GetHost();
}
bool HandleExternalProtocol(
@@ -331,6 +333,36 @@
CommitRendererInitiatedNavigation(iframe_id, kNonAppUrl, corp_coep_headers());
}
+TEST_F(IsolatedWebAppThrottleTest, BlockIframeNavigationToOtherIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ // Navigating an iframe to a different Isolated Web App should be blocked.
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kOtherAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, start_result.action());
+}
+
+TEST_F(IsolatedWebAppThrottleTest, BlockIframeRedirectToOtherIsolatedWebApp) {
+ CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
+ EXPECT_EQ(kIsolatedApplication, GetWebExposedIsolationLevel(main_frame_id()));
+ FrameTreeNodeId iframe_id = CreateIframe(main_frame_id(), "test_frame");
+
+ auto simulator = StartRendererInitiatedNavigation(iframe_id, kNonAppUrl);
+
+ auto start_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::PROCEED, start_result.action());
+
+ // Redirect to a different Isolated Web App.
+ simulator->SetRedirectHeaders(corp_coep_headers());
+ simulator->Redirect(GURL(kOtherAppUrl));
+
+ auto redirect_result = simulator->GetLastThrottleCheckResult();
+ EXPECT_EQ(NavigationThrottle::BLOCK_REQUEST, redirect_result.action());
+}
+
TEST_F(IsolatedWebAppThrottleTest,
BlockIframeRendererInitiatedNavigationIntoIsolatedWebApp) {
CommitBrowserInitiatedNavigation(kAppUrl, coop_coep_headers());
Original Bug Report
Cross-IWA iframe navigation enables Service Worker CSRF and installation oracle
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. Please see go/chrome-ai-generated-security-bugs-faq for more information.
Overview: A logic flaw in IsolatedWebAppThrottle fails to restrict cross-origin iframe navigations between Isolated Web Apps (IWAs). A compromised IWA renderer can exploit this, bypassing CSP checks, to dispatch arbitrary FetchEvents to other IWAs’ Service Workers (CSRF) or probe their installation status.
Affected files:
content/browser/renderer_host/isolated_web_app_throttle.ccchrome/browser/web_applications/isolated_web_apps/isolated_web_app_throttle.cccontent/browser/renderer_host/navigation_request.cccomponents/webapps/isolated_web_apps/url_loading/url_loader_factory.cccontent/browser/child_process_security_policy_impl.ccchrome/browser/web_applications/isolated_web_apps/chrome_iwa_client.cccomponents/webapps/isolated_web_apps/url_loading/utils.cc
Estimated timestamp from git blame: 2025-05-15
Summary
A compromised Isolated Web App (IWA) renderer can drive iframe navigation requests into any other installed IWA. This occurs because the IsolatedWebAppThrottle implementation for iframes incorrectly allows navigations to any isolated-app:// destination if it differs from the current app’s origin, without verifying if the destination is another IWA that should be isolated. By bypassing browser-side CSP checks via IPC manipulation, an attacker can trigger a cross-IWA Service Worker FetchEvent (blind CSRF) and utilize error message leakage as an installation oracle.
Vulnerability Details
- Broad Scheme Access: When an IWA is launched,
ChildProcessSecurityPolicyImpl::GrantCommitURLgrants the renderer process request access to the entire non-web-safeisolated-appscheme (content/browser/child_process_security_policy_impl.cc:1378). Thus,CanRequestURLpermits the compromised renderer to request URLs belonging to other IWAs. - CSP Bypass: The attacker initiates an iframe navigation to a target IWA (
isolated-app://<B-id>/) and setsshould_check_main_world_csptoDO_NOT_CHECKin themojom::LocalFrameHost::BeginNavigationIPC. This causesNavigationRequest::CheckContentSecurityPolicyto immediately returnnet::OK(content/browser/renderer_host/navigation_request.cc:7698), bypassing theframe-src 'self'policy that normally prevents cross-IWA embedding. - Throttle Logic Flaw: The navigation reaches
content::IsolatedWebAppThrottle::MaybeThrottleNavigationTransition. For iframe navigations, the throttle checksif (dest_tuple != web_contents_isolation_tuple)(content/browser/renderer_host/isolated_web_app_throttle.cc:237). If true, it returnsThrottleAction::PROCEED. It mistakenly assumes the navigation is leaving the IWA for the open web, failing to check if the destination is another IWA (lacking the!dest_needs_apps_isolationcheck present in the main-frame branch).
Impact
Although the navigation response is eventually blocked by AncestorThrottle (due to headers injected later in the pipeline), two critical side effects occur first:
- Service Worker CSRF: The browser resolves the navigation to the target IWA’s dedicated
StoragePartition. If the target is installed,ServiceWorkerMainResourceLoaderInterceptordispatches aFetchEventto its Service Worker with an attacker-controlled URL. This enables blind CSRF against any target IWA. - Installation Oracle: If the target IWA is not installed, the
IsolatedWebAppURLLoaderFactoryfails to find the app bundle. It attempts to log “There’s no matching Isolated Web App installed.” to the console. Because the iframe navigation is still pending,LogErrorMessageToConsolesends this error via IPC to theRenderFrameHostcurrently hosting the frame, which is still controlled by the attacker. The attacker can observe this IPC to deterministically leak the installation status of any IWA bundle ID.
Potential Steps to Reproduce
Note: These are suggested steps based on codebase analysis; we do not currently have the tooling to execute a working proof of concept.
- Install two IWAs (App A and App B). Ensure App B registers a Service Worker.
- Gain arbitrary code execution in App A’s renderer process.
- From the compromised renderer, construct a
mojom::LocalFrameHost::BeginNavigationIPC for a child frame targetingisolated-app://<App-B-ID>/victim?data=attacker. - Set
should_check_main_world_cspin theCommonNavigationParamstonetwork::mojom::CSPDisposition::DO_NOT_CHECK. - Send the IPC. Observe that App B’s Service Worker receives the
FetchEvent. - Uninstall App B and repeat the process. Observe the “no matching Isolated Web App” error message IPC arriving at the attacker’s renderer.
Suggested Fix
Update content::IsolatedWebAppThrottle::MaybeThrottleNavigationTransition to correctly handle iframe navigations to other IWAs. Specifically, at line 237:
// Iframes are allowed to leave the app's origin, but NOT to enter another IWA.
if (dest_tuple != web_contents_isolation_tuple) {
return dest_needs_apps_isolation ? ThrottleAction::CANCEL : ThrottleAction::PROCEED;
}
This mirrors the protection applied to main-frame navigations and will block the transition before it reaches the StoragePartition or Service Worker layers.
Evaluated with Chrome root at commit: 661452647ddb2827305122ff3273bd5dea403f09
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.