CVE-2026-17780
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forchrome/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 4e0a08a74988bdcd9d2aed12002ab176c450bb87 Mon Sep 17 00:00:00 2001
From: greengrape <greengrape@google.com>
Date: Thu, 25 Jun 2026 08:07:46 -0700
Subject: [PATCH] [IWA] Cancel cross-origin SW clients.openWindow() in nav capture
HandleIsolatedWebAppNavigation() only checked the source app for
PAGE_TRANSITION_LINK navigations. clients.openWindow() from an IWA's
service worker arrives with PAGE_TRANSITION_AUTO_TOPLEVEL and no source
Browser, so the check did not apply and the navigation was captured into
the target IWA's app window.
Compare the initiator origin against the target URL's origin for
service-worker-initiated navigations and cancel when they differ,
matching the existing link-based source check.
Add a browser test that exercises clients.openWindow() targeting a
different installed IWA from a notificationclick handler.
Fixed: 513485951
Change-Id: Ic51db577229554358ff31a5644fd72416a6a6964
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7984206
Reviewed-by: Dibyajyoti Pal <dibyapal@chromium.org>
Commit-Queue: Andrew Rayskiy <greengrape@google.com>
Cr-Commit-Position: refs/heads/main@{#1652430}
---
diff --git a/chrome/browser/ui/web_applications/navigation_capturing_process.cc b/chrome/browser/ui/web_applications/navigation_capturing_process.cc
index efaac5d2..bbea98914 100644
--- a/chrome/browser/ui/web_applications/navigation_capturing_process.cc
+++ b/chrome/browser/ui/web_applications/navigation_capturing_process.cc
@@ -934,6 +934,16 @@
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
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 276298b..e7f80cf 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
@@ -1742,6 +1742,92 @@
WaitForLaunchQueueEntryWithURL(target_contents, url.spec());
}
+IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest,
+ CrossOriginServiceWorkerOpenWindow) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> source_app =
+ IsolatedWebAppBuilder(ManifestBuilder())
+ .AddJs("/service_worker.js", R"(
+ self.addEventListener('notificationclick', event => {
+ event.waitUntil((async () => {
+ try {
+ await clients.openWindow(event.notification.body);
+ } catch (e) {}
+ const all =
+ await clients.matchAll({includeUncontrolled: true});
+ for (const c of all) {
+ c.postMessage('open-window-settled');
+ }
+ })());
+ });
+ self.addEventListener('message', event => {
+ event.source.postMessage('ready');
+ });
+ )")
+ .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()));
+
+ static constexpr std::string_view kServiceWorkerRegister = R"(
+ new Promise(async (resolve) => {
+ const policy = trustedTypes.createPolicy("default", {
+ createScriptURL: (url) => url,
+ });
+ await navigator.serviceWorker.register(
+ policy.createScriptURL('/service_worker.js')
+ );
+ navigator.serviceWorker.addEventListener('message', e => {
+ if (e.data == 'ready') resolve();
+ });
+ (await navigator.serviceWorker.ready).active.postMessage('ping');
+ });
+ )";
+ ASSERT_TRUE(content::ExecJs(web_contents, kServiceWorkerRegister));
+
+ const size_t browsers_before =
+ GlobalBrowserCollection::GetInstance()->GetSize();
+
+ static constexpr std::string_view kSetUpOpenWindowWaiter = R"(
+ window.__openWindowSettled = new Promise(resolve => {
+ navigator.serviceWorker.addEventListener('message', e => {
+ if (e.data == 'open-window-settled') resolve(true);
+ });
+ });
+ true;
+ )";
+ ASSERT_TRUE(content::ExecJs(web_contents, kSetUpOpenWindowWaiter));
+
+ const GURL target_url =
+ target_url_info.origin().GetURL().Resolve("/something/weird.html");
+ blink::PlatformNotificationData notification_data;
+ notification_data.body = base::UTF8ToUTF16(target_url.spec());
+ content::DispatchServiceWorkerNotificationClick(
+ profile()
+ ->GetStoragePartition(
+ source_url_info.storage_partition_config(profile()))
+ ->GetServiceWorkerContext(),
+ source_url_info.origin().GetURL(), notification_data);
+
+ EXPECT_EQ(true, content::EvalJs(web_contents, "window.__openWindowSettled"));
+
+ // The cross-origin `clients.openWindow()` must not open or focus 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 276298b..e7f80cf 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
@@ -1742,6 +1742,92 @@
WaitForLaunchQueueEntryWithURL(target_contents, url.spec());
}
+IN_PROC_BROWSER_TEST_P(IsolatedWebAppLaunchHandlingBrowserTest,
+ CrossOriginServiceWorkerOpenWindow) {
+ std::unique_ptr<ScopedBundledIsolatedWebApp> source_app =
+ IsolatedWebAppBuilder(ManifestBuilder())
+ .AddJs("/service_worker.js", R"(
+ self.addEventListener('notificationclick', event => {
+ event.waitUntil((async () => {
+ try {
+ await clients.openWindow(event.notification.body);
+ } catch (e) {}
+ const all =
+ await clients.matchAll({includeUncontrolled: true});
+ for (const c of all) {
+ c.postMessage('open-window-settled');
+ }
+ })());
+ });
+ self.addEventListener('message', event => {
+ event.source.postMessage('ready');
+ });
+ )")
+ .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()));
+
+ static constexpr std::string_view kServiceWorkerRegister = R"(
+ new Promise(async (resolve) => {
+ const policy = trustedTypes.createPolicy("default", {
+ createScriptURL: (url) => url,
+ });
+ await navigator.serviceWorker.register(
+ policy.createScriptURL('/service_worker.js')
+ );
+ navigator.serviceWorker.addEventListener('message', e => {
+ if (e.data == 'ready') resolve();
+ });
+ (await navigator.serviceWorker.ready).active.postMessage('ping');
+ });
+ )";
+ ASSERT_TRUE(content::ExecJs(web_contents, kServiceWorkerRegister));
+
+ const size_t browsers_before =
+ GlobalBrowserCollection::GetInstance()->GetSize();
+
+ static constexpr std::string_view kSetUpOpenWindowWaiter = R"(
+ window.__openWindowSettled = new Promise(resolve => {
+ navigator.serviceWorker.addEventListener('message', e => {
+ if (e.data == 'open-window-settled') resolve(true);
+ });
+ });
+ true;
+ )";
+ ASSERT_TRUE(content::ExecJs(web_contents, kSetUpOpenWindowWaiter));
+
+ const GURL target_url =
+ target_url_info.origin().GetURL().Resolve("/something/weird.html");
+ blink::PlatformNotificationData notification_data;
+ notification_data.body = base::UTF8ToUTF16(target_url.spec());
+ content::DispatchServiceWorkerNotificationClick(
+ profile()
+ ->GetStoragePartition(
+ source_url_info.storage_partition_config(profile()))
+ ->GetServiceWorkerContext(),
+ source_url_info.origin().GetURL(), notification_data);
+
+ EXPECT_EQ(true, content::EvalJs(web_contents, "window.__openWindowSettled"));
+
+ // The cross-origin `clients.openWindow()` must not open or focus 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
Potential bypass of cross-IWA source check via Service Worker clients.openWindow()
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 logic error in Isolated Web App (IWA) navigation capturing allows a potentially malicious IWA to force-navigate another IWA to an arbitrary internal URL. This occurs because security checks intended to prevent cross-app deep-linking are incorrectly restricted to link-based transitions, skipping service worker-initiated window openings.
Affected files:
chrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/ui/navigator/browser_navigator.cccontent/browser/service_worker/service_worker_version.cccontent/browser/renderer_host/isolated_web_app_throttle.cc
Estimated timestamp from git blame: 2025-07-01
Summary
Logic in the NavigationCapturingProcess responsible for Isolated Web Apps (IWAs) fails to enforce cross-origin source checks for navigations initiated by Service Workers. While link-based navigations between IWAs are currently restricted to prevent unauthorized deep-linking, an attacker can use clients.openWindow() from a Service Worker to bypass these protections and trigger sensitive actions in a victim IWA.
Potential Root Cause
In chrome/browser/ui/web_applications/navigation_capturing_process.cc, the function HandleIsolatedWebAppNavigation contains a security check (lines 893-899) designed to cancel cross-IWA navigations. However, this check is nested within a block that only executes if the transition type is ui::PAGE_TRANSITION_LINK:
// chrome/browser/ui/web_applications/navigation_capturing_process.cc:889
if (ui::PageTransitionCoreTypeIs(params.transition,
ui::PAGE_TRANSITION_LINK)) {
// Cross-IWA cancellation logic
if (source_browser_app_id_ != iwa_id && ...) {
return CancelInitialNavigation(...);
}
}
When a Service Worker calls clients.openWindow(), the navigation is initiated with ui::PAGE_TRANSITION_AUTO_TOPLEVEL (set in content/browser/service_worker/service_worker_client_utils.cc:538). Because AUTO_TOPLEVEL is not a LINK transition, the security check is bypassed entirely.
Attack Vector Analysis (Potential)
- Permission Grant: When a malicious IWA (App A) is launched, its renderer process is granted scheme-wide request permissions for the
isolated-app:scheme viaChildProcessSecurityPolicyImpl::GrantCommitURL. This is because the scheme is not registered as ‘web-safe’. - SW Trigger: App A’s Service Worker calls
self.clients.openWindow('isolated-app://AppB/sensitive-action'). - Policy Check: The browser’s
CanRequestURLcheck passes due to the scheme-wide grant in step 1. - Bypass: Navigation capturing processes the request. It identifies the IWA target but skips the cross-origin source check because the transition is
AUTO_TOPLEVELinstead ofLINK. - Execution: The victim IWA (App B) is launched or focused, and the attacker-controlled URL is processed via the
window.launchQueueor standard navigation, potentially executing a “confused deputy” action.
Impact
This allows a compromised or malicious IWA to bypass isolation policies and interact with the internal routes of other installed IWAs. Given that IWAs possess high-privilege capabilities (e.g., Direct Sockets, USB), unauthorized deep-linking into sensitive administrative or functional routes poses a significant security risk.
Suggested Fix
The cross-IWA source check in HandleIsolatedWebAppNavigation should be moved outside the ui::PAGE_TRANSITION_LINK block to ensure it applies to all renderer-initiated navigations, including those from Service Workers. Alternatively, explicitly handle ui::PAGE_TRANSITION_AUTO_TOPLEVEL when params.is_service_worker_open_window is true.
Note: These are potential steps identified through code analysis; our current environment does not support the execution of a live proof-of-concept.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
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.