CVE-2026-87468
Overview
Background
- Isolated Web App (IWA)
- a packaged web app served from an
isolated-app://origin that runs with strong origin isolation, so navigations that cross from one IWA to another are meant to be tightly restricted. - `NavigationCapturingProcess` / `HandleIsolatedWebAppNavigation()`
- the browser-process logic in
chrome/browser/ui/web_applications/navigation_capturing_process.ccthat decides whether a navigation into an IWA is captured into an app window, redirected, or canceled. - `WindowOpenDisposition::NEW_POPUP`
- the disposition produced by
window.open(url, target, 'popup'), which requests a popup window rather than a normal tab or capture. - `source_browser_app_id_` / `first_navigation_parent_app_id_`
- the app identity of the browser initiating the navigation and, optionally, its parent app; a navigation is cross-IWA when neither matches the target
iwa_id.
Root Cause Analysis
HandleIsolatedWebAppNavigation() enforced the cross-IWA restriction only in the block that ran after the navigation-capture decision. When capturing was disabled — for example because the disposition was WindowOpenDisposition::NEW_POPUP — the function took an early return CapturingDisabled() before ever reaching the link-transition and service-worker cross-IWA checks, so a popup navigating from one IWA to a different IWA was never canceled. This violated the invariant that a browser belonging to one IWA (source_browser_app_id_) must not open or navigate into a different IWA (iwa_id) unless it is that app or its parent.
The fix hoists the cross-IWA determination into a new standalone helper IsCrossIwaNavigation() and calls it near the top of HandleIsolatedWebAppNavigation() — before the capture/disposition handling and its CapturingDisabled() early return — so a cross-IWA navigation is canceled via CancelInitialNavigation(NavigationCapturingInitialResult::kNavigationCanceled) regardless of disposition. The removed inline blocks (the service-worker initiator_origin check and the PAGE_TRANSITION_LINK source-app check) are now consolidated in the helper, preserving the auxiliary-context handling that remains after the check.
NEW_POPUP) bypassed it entirely; the fix runs IsCrossIwaNavigation() unconditionally and early, independent of the capture logic.Attack Path
- Install and launch a source IWA
An installed IWA is opened, giving attacker-controlled script running in the source app’s origin (
source_browser_app_id_). - Open a cross-IWA popup
The source app calls
window.open(target_url, '_blank', 'popup')pointing at a different installed IWA’s URL, producing aWindowOpenDisposition::NEW_POPUPnavigation intoiwa_id. - Bypass the restriction
Pre-patch,
HandleIsolatedWebAppNavigation()reached the capture logic, found capturing disabled for the popup disposition, and returnedCapturingDisabled()before the cross-IWA source-app check ran, so the navigation was not canceled. - Spoof the target app The popup opens a window rendering the target IWA’s content initiated by a different app, enabling cross-IWA navigation and UI spoofing of another installed app, as the advisory describes.
- Downstream impact
Any further consequence beyond opening/spoofing the target app window is not demonstrated by the diff
(inferred).
Impact Assessment
window.open(..., 'popup'), allowing spoofing of that other app in the browser process’s window management. The advisory classifies this as incorrect authorization in the Isolated component and does not report exploitation in the wild; the precondition is that both the source and target IWAs are installed and the attacker can run script in the source IWA. No memory-safety or code-execution escalation is shown by the patch, so any such outcome would be (inferred).Changed Functions
| Function | Change | Notes |
|---|---|---|
BindLambdaForTestingchrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc |
modified | |
IN_PROC_BROWSER_TEST_Pchrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
Audit Directions
- Security checks gated behind feature/capture logicLook for authorization or origin-isolation checks placed after an early return for a disabled/opt-out path (e.g.
CapturingDisabled()), where certain dispositions or modes skip the check entirely. - Disposition-specific bypassesAudit navigation and window-open handling for behaviors that differ by
WindowOpenDisposition(popups, new windows, background tabs) and verify cross-origin/cross-app restrictions apply to every disposition. - Service-worker and link-transition parityEnsure alternative navigation initiators (
clients.openWindow(), meta-refresh, anchor links) are all subjected to the same cross-IWA source-identity checks now centralized inIsCrossIwaNavigation().
Patch
From 3a40d93884129f66a868ce157eaa75f55a3d876f Mon Sep 17 00:00:00 2001
From: greengrape <greengrape@google.com>
Date: Tue, 28 Jul 2026 09:13:56 -0700
Subject: [PATCH] Fix cross-IWA navigation restrictions for popups
The NavigationCapturingProcess previously returned early if capturing was
disabled due to dispositions like WindowOpenDisposition::NEW_POPUP,
bypassing cross-Isolated Web App limits. This allowed an installed IWA
to bypass cross-IWA navigation restrictions and spoof another app via
window.open() with the 'popup' feature.
This patch updates HandleIsolatedWebAppNavigation() to execute the
restriction checks for cross-IWA window target popups independently of
the capture logic.
Bug: 532931962
TAG=agy
Change-Id: I1eea864f1008f517e1f58217f02f2e1c6a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8158641
Reviewed-by: Dibyajyoti Pal <dibyapal@chromium.org>
Reviewed-by: Vlad Krot <vkrot@google.com>
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Cr-Commit-Position: refs/heads/main@{#1669557}
---
diff --git a/chrome/browser/ui/web_applications/navigation_capturing_process.cc b/chrome/browser/ui/web_applications/navigation_capturing_process.cc
index 3a198d88..1d19fab 100644
--- a/chrome/browser/ui/web_applications/navigation_capturing_process.cc
+++ b/chrome/browser/ui/web_applications/navigation_capturing_process.cc
@@ -280,6 +280,35 @@
base::DoNothing());
}
+bool IsCrossIwaNavigation(
+ const NavigateParams& params,
+ const webapps::AppId& iwa_id,
+ const std::optional<webapps::AppId>& source_browser_app_id,
+ const std::optional<webapps::AppId>& parent_app_id) {
+ // Service worker `clients.openWindow()` arrives with no source browser and a
+ // non-link transition, so the link-based source check below does not apply.
+ // Use the initiator origin to enforce the same cross-IWA restriction.
+ if (params.is_service_worker_open_window && params.initiator_origin &&
+ !params.initiator_origin->IsSameOriginWith(params.url)) {
+ return true;
+ }
+
+ // Any links: same-IWA or cross-IWA window.open(), same-IWA or cross-IWA
+ // anchor link, cross-IWA meta tag redirect. Cancel navigations that do not
+ // originate from a browser for the target app (or its parent app), regardless
+ // of disposition, before falling through to the disposition-specific handling
+ // below.
+ if (ui::PageTransitionCoreTypeIs(params.transition,
+ ui::PAGE_TRANSITION_LINK) &&
+ source_browser_app_id != iwa_id &&
+ (!parent_app_id.has_value() ||
+ source_browser_app_id != parent_app_id.value())) {
+ return true;
+ }
+
+ return false;
+}
+
} // namespace
NavigationCapturingOverride::~NavigationCapturingOverride() = default;
@@ -893,6 +922,13 @@
const webapps::AppId& iwa_id = *first_navigation_app_id_;
const DisplayMode& app_display_mode = *first_navigation_app_display_mode_;
+ if (IsCrossIwaNavigation(params, iwa_id, source_browser_app_id_,
+ first_navigation_parent_app_id_)) {
+ // TODO(crbug.com/424422466): Support cross-IWA navigations to start_url.
+ return CancelInitialNavigation(
+ NavigationCapturingInitialResult::kNavigationCanceled);
+ }
+
// Prefer `params.browser` if it's a compatible IWA browser.
bool iwa_browser =
params.browser &&
@@ -934,34 +970,13 @@
return CapturingDisabled();
}
- // Service worker `clients.openWindow()` arrives with no source browser and a
- // non-link transition, so the link-based source check below does not apply.
- // Use the initiator origin to enforce the same cross-IWA restriction.
- if (params.is_service_worker_open_window && params.initiator_origin &&
- !params.initiator_origin->IsSameOriginWith(params.url)) {
- // TODO(crbug.com/424422466): Support cross-IWA navigations to start_url.
- return CancelInitialNavigation(
- NavigationCapturingInitialResult::kNavigationCanceled);
- }
-
if (ui::PageTransitionCoreTypeIs(params.transition,
- ui::PAGE_TRANSITION_LINK)) {
- // Any links: same-IWA or cross-IWA window.open(), same-IWA or cross-IWA
- // anchor link, cross-IWA meta tag redirect.
- if (source_browser_app_id_ != iwa_id &&
- (!first_navigation_parent_app_id_.has_value() ||
- source_browser_app_id_ != first_navigation_parent_app_id_.value())) {
- // TODO(crbug.com/424422466): Support cross-IWA navigations to start_url.
- return CancelInitialNavigation(
- NavigationCapturingInitialResult::kNavigationCanceled);
- }
-
- if (IsAuxiliaryBrowsingContext(params)) {
- debug_data_.Set("is_auxiliary_browsing_context", true);
- Browser* aux_window =
- CreateWebAppWindowFromNavigationParams(iwa_id, params);
- return AuxiliaryContextInAppWindow(aux_window);
- }
+ ui::PAGE_TRANSITION_LINK) &&
+ IsAuxiliaryBrowsingContext(params)) {
+ debug_data_.Set("is_auxiliary_browsing_context", true);
+ Browser* aux_window =
+ CreateWebAppWindowFromNavigationParams(iwa_id, params);
+ return AuxiliaryContextInAppWindow(aux_window);
}
// Auxiliary browsing contexts should only be openable via link transitions.
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
index 88c2180..64b8ff2 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
@@ -13,6 +13,7 @@
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
+#include "base/test/bind.h"
#include "base/test/gmock_expected_support.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/run_until.h"
@@ -72,6 +73,7 @@
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/service_worker_test_helpers.h"
#include "content/public/test/test_navigation_observer.h"
+#include "content/public/test/test_utils.h"
#include "extensions/test/result_catcher.h"
#include "net/base/net_errors.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
@@ -1838,6 +1840,51 @@
EXPECT_EQ(browsers_before, GlobalBrowserCollection::GetInstance()->GetSize());
}
+IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest,
+ CrossOriginWindowOpenPopup) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> source_app =
+ IsolatedWebAppBuilder(ManifestBuilder()).BuildBundle();
+ ASSERT_OK_AND_ASSIGN(IsolatedWebAppUrlInfo source_url_info,
+ source_app->Install(profile()));
+
+ std::unique_ptr<ScopedBundledIsolatedWebApp> target_app =
+ IsolatedWebAppBuilder(
+ ManifestBuilder().SetLaunchHandlerClientMode(GetParam()))
+ .AddHtml("/something/weird.html", "meow")
+ .BuildBundle();
+ ASSERT_OK_AND_ASSIGN(IsolatedWebAppUrlInfo target_url_info,
+ target_app->Install(profile()));
+
+ content::WebContents* web_contents =
+ content::WebContents::FromRenderFrameHost(
+ OpenIsolatedWebApp(profile(), source_url_info.app_id()));
+
+ const size_t browsers_before =
+ GlobalBrowserCollection::GetInstance()->GetSize();
+
+ const GURL target_url =
+ target_url_info.origin().GetURL().Resolve("/something/weird.html");
+
+ std::unique_ptr<content::WebContentsDestroyedWatcher> destroyed_watcher;
+ base::CallbackListSubscription creation_subscription =
+ content::RegisterWebContentsCreationCallback(
+ base::BindLambdaForTesting([&](content::WebContents* wc) {
+ destroyed_watcher =
+ std::make_unique<content::WebContentsDestroyedWatcher>(wc);
+ }));
+ ASSERT_TRUE(content::ExecJs(
+ web_contents,
+ content::JsReplace("window.open($1, '_blank', 'popup')", target_url)));
+ ASSERT_TRUE(destroyed_watcher);
+ destroyed_watcher->Wait();
+
+ // The cross-origin `window.open()` popup must not open a window for the
+ // target app.
+ EXPECT_FALSE(AppBrowserController::FindForWebApp(*profile(),
+ target_url_info.app_id()));
+ EXPECT_EQ(browsers_before, GlobalBrowserCollection::GetInstance()->GetSize());
+}
+
IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest, PlainLaunch) {
std::unique_ptr<ScopedBundledIsolatedWebApp> app =
IsolatedWebAppBuilder(
Regression Test / PoC
diff --git a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
index 88c2180..64b8ff2 100644
--- a/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
@@ -13,6 +13,7 @@
#include "base/memory/raw_ptr.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
+#include "base/test/bind.h"
#include "base/test/gmock_expected_support.h"
#include "base/test/metrics/histogram_tester.h"
#include "base/test/run_until.h"
@@ -72,6 +73,7 @@
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/service_worker_test_helpers.h"
#include "content/public/test/test_navigation_observer.h"
+#include "content/public/test/test_utils.h"
#include "extensions/test/result_catcher.h"
#include "net/base/net_errors.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
@@ -1838,6 +1840,51 @@
EXPECT_EQ(browsers_before, GlobalBrowserCollection::GetInstance()->GetSize());
}
+IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest,
+ CrossOriginWindowOpenPopup) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> source_app =
+ IsolatedWebAppBuilder(ManifestBuilder()).BuildBundle();
+ ASSERT_OK_AND_ASSIGN(IsolatedWebAppUrlInfo source_url_info,
+ source_app->Install(profile()));
+
+ std::unique_ptr<ScopedBundledIsolatedWebApp> target_app =
+ IsolatedWebAppBuilder(
+ ManifestBuilder().SetLaunchHandlerClientMode(GetParam()))
+ .AddHtml("/something/weird.html", "meow")
+ .BuildBundle();
+ ASSERT_OK_AND_ASSIGN(IsolatedWebAppUrlInfo target_url_info,
+ target_app->Install(profile()));
+
+ content::WebContents* web_contents =
+ content::WebContents::FromRenderFrameHost(
+ OpenIsolatedWebApp(profile(), source_url_info.app_id()));
+
+ const size_t browsers_before =
+ GlobalBrowserCollection::GetInstance()->GetSize();
+
+ const GURL target_url =
+ target_url_info.origin().GetURL().Resolve("/something/weird.html");
+
+ std::unique_ptr<content::WebContentsDestroyedWatcher> destroyed_watcher;
+ base::CallbackListSubscription creation_subscription =
+ content::RegisterWebContentsCreationCallback(
+ base::BindLambdaForTesting([&](content::WebContents* wc) {
+ destroyed_watcher =
+ std::make_unique<content::WebContentsDestroyedWatcher>(wc);
+ }));
+ ASSERT_TRUE(content::ExecJs(
+ web_contents,
+ content::JsReplace("window.open($1, '_blank', 'popup')", target_url)));
+ ASSERT_TRUE(destroyed_watcher);
+ destroyed_watcher->Wait();
+
+ // The cross-origin `window.open()` popup must not open a window for the
+ // target app.
+ EXPECT_FALSE(AppBrowserController::FindForWebApp(*profile(),
+ target_url_info.app_id()));
+ EXPECT_EQ(browsers_before, GlobalBrowserCollection::GetInstance()->GetSize());
+}
+
IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest, PlainLaunch) {
std::unique_ptr<ScopedBundledIsolatedWebApp> app =
IsolatedWebAppBuilder(
Original Bug Report
Bypass of cross-IWA navigation restrictions via WindowOpenDisposition::NEW_POPUP
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 logical flaw in Chrome’s Isolated Web App (IWA) navigation capturing process allows an installed IWA to bypass cross-IWA navigation restrictions. By initiating a navigation via a popup window, the source IWA can bypass authorization checks, enabling potential cross-IWA deep-linking/CSRF. Additionally, the target IWA content is displayed inside a popup window carrying the source app’s UI branding.
Affected files:
chrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/ui/navigator/browser_navigator.cc
Estimated timestamp from git blame: 2025-06-11
Root Cause Analysis
In chrome/browser/ui/web_applications/navigation_capturing_process.cc, HandleIsolatedWebAppNavigation() is responsible for enforcing cross-IWA navigation restrictions. However, it computes capturing_disabled based on the disposition and returns early before executing either of the two cross-IWA origin checks:
// chrome/browser/ui/web_applications/navigation_capturing_process.cc:902-934
bool capturing_disabled = [&]() {
switch (disposition_) {
case WindowOpenDisposition::NEW_POPUP:
case WindowOpenDisposition::NEW_PICTURE_IN_PICTURE:
// App popups and picture-in-picture are handled in the switch statement
// in `GetBrowserAndTabForDisposition()`.
return true;
...
}
}();
if (capturing_disabled) {
return CapturingDisabled(); // returns std::nullopt
}
// -- SW clients.openWindow initiator check (lines 936-944) -- UNREACHED
// -- PAGE_TRANSITION_LINK cross-IWA source_browser_app_id_ != iwa_id check (lines 946-956) -- UNREACHED
When a popup window is requested, disposition_ evaluates to WindowOpenDisposition::NEW_POPUP. This triggers the early return of CapturingDisabled(), effectively short-circuiting the function and bypassing the cross-IWA checks.
Furthermore, because navigation capturing returns std::nullopt, browser_navigator.cc falls back to GetBrowserAndTabForDisposition(), which resolves the window’s app_name from the source browser:
// chrome/browser/ui/navigator/browser_navigator.cc:294-324
case WindowOpenDisposition::NEW_POPUP: {
std::string app_name;
if (!params.app_id.empty()) { ... }
else if (params.browser && !params.browser->GetBrowserForMigrationOnly()->app_name().empty()) {
app_name = params.browser->GetBrowserForMigrationOnly()->app_name(); // Inherits IWA-A's app_name
}
...
Browser::CreateParams browser_params =
Browser::CreateParams::CreateForAppPopup(app_name, ...);
return {Browser::Create(browser_params), -1};
}
The browser then loads the destination IWA (IWA-B) inside a TYPE_APP_POPUP window carrying the source app’s (IWA-A) branding (taskbar/shelf grouping and icon).
Potential Exploit Scenario
Note: These are potential steps modeled using static analysis; our tooling does not currently have the capability to run code to verify live behavior.
- Precondition: The victim has both an attacker-controlled IWA-A (
IWA_A_ID) and a target IWA-B (IWA_B_ID) installed on their system. - Action: The victim clicks a button in IWA-A, which triggers:
window.open('isolated-app://<IWA_B_ID>/admin?action=wipe', '_blank', 'popup') - Blink Processing: The renderer client allows the scheme request. The browser process maps the
"popup"request toWindowOpenDisposition::NEW_POPUPand delegates to the navigation engine. - Guard Bypass:
HandleIsolatedWebAppNavigation()returnsstd::nulloptearly due to theNEW_POPUPdisposition, bypassing the cross-IWA navigation cancels at lines 936–956. - UI Spoofing & Deep-linking: The browser loads the target URL
isolated-app://<IWA_B_ID>/admin?action=wipeinside a new popup window. The window’s container and platform app metadata are set toIWA_A_ID(the initiator’s ID), but it rendersIWA_B_IDcontent.
This bypass allows an installed IWA to execute deep-link navigations on other IWAs, which can trigger state-changing actions via routes/query-parameters (CSRF), alongside spoofing the application context.
Suggested Fix
The cross-IWA security checks in HandleIsolatedWebAppNavigation() should not be short-circuited by the disposition logic.
Specifically, the source identity check (lines 946–956) and the Service Worker initiator check (lines 936–944) should be executed before checking if capturing_disabled is true, or the authorization/restriction checks should be decoupled entirely from the navigation capturing configuration logic.
Evaluated with Chrome root at commit: 84065d9121f6e48f67755f0ae963cc09617e5c85
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.