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
Tracker532931962
Fix commit3a40d9388412 (chromium/src) +89/-27
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

Changed Functions

FunctionChangeNotes
BindLambdaForTesting
chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
modified
IN_PROC_BROWSER_TEST_P
chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
modified

Files Changed

  • chrome/browser/ui/web_applications/navigation_capturing_process.cc
  • chrome/browser/web_applications/isolated_web_apps/isolated_web_app_browsertest.cc
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(
Loading diff…

Regression Test / PoC

shipped with the fix
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(
Loading diff…

Original Bug Report

reported by [email protected]

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.cc
  • chrome/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.

  1. 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.
  2. Action: The victim clicks a button in IWA-A, which triggers: window.open('isolated-app://<IWA_B_ID>/admin?action=wipe', '_blank', 'popup')
  3. Blink Processing: The renderer client allows the scheme request. The browser process maps the "popup" request to WindowOpenDisposition::NEW_POPUP and delegates to the navigation engine.
  4. Guard Bypass: HandleIsolatedWebAppNavigation() returns std::nullopt early due to the NEW_POPUP disposition, bypassing the cross-IWA navigation cancels at lines 936–956.
  5. UI Spoofing & Deep-linking: The browser loads the target URL isolated-app://<IWA_B_ID>/admin?action=wipe inside a new popup window. The window’s container and platform app metadata are set to IWA_A_ID (the initiator’s ID), but it renders IWA_B_ID content.

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.

View on issue tracker