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

Overview

Medium
Severity
6.5
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.
Tracker532931962
Fix commit3a40d9388412 (chromium/src) +89/-27
CWECWE-863
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-07-09 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-09-08
Patch gap42 days — fix public in source before the advisory

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.cc that 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.

Key insight
The core mistake was performing the cross-IWA authorization check only on the capture path, so any disposition that short-circuited capture (notably NEW_POPUP) bypassed it entirely; the fix runs IsCrossIwaNavigation() unconditionally and early, independent of the capture logic.

Attack Path

  1. 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_).
  2. Open a cross-IWA popup The source app calls window.open(target_url, '_blank', 'popup') pointing at a different installed IWA’s URL, producing a WindowOpenDisposition::NEW_POPUP navigation into iwa_id.
  3. Bypass the restriction Pre-patch, HandleIsolatedWebAppNavigation() reached the capture logic, found capturing disabled for the popup disposition, and returned CapturingDisabled() before the cross-IWA source-app check ran, so the navigation was not canceled.
  4. 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.
  5. Downstream impact Any further consequence beyond opening/spoofing the target app window is not demonstrated by the diff (inferred).

Impact Assessment

An attacker controlling one installed IWA could bypass cross-IWA navigation restrictions and open a popup window for a different installed IWA via 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

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

Audit Directions

  • Security checks gated behind feature/capture logic
    Look 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 bypasses
    Audit 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 parity
    Ensure alternative navigation initiators (clients.openWindow(), meta-refresh, anchor links) are all subjected to the same cross-IWA source-identity checks now centralized in IsCrossIwaNavigation().
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(
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 vm...@google.com

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