CVE-2026-87468
Overview
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
Patch
From 3a40d93884129f66a868ce157eaa75f55a3d876f Mon Sep 17 00:00:00 2001 From: greengrape <[email protected]> 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 <[email protected]> Reviewed-by: Vlad Krot <[email protected]> Commit-Queue: Andrew Rayskiy <[email protected]> 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.