CVE-2026-87540
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
Browserchrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc |
modified | |
IsolatedWebAppsWindowOpenPermissionServiceScopeExtensionBrowserTestchrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc |
modified |
Files Changed
chrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/web_applications/isolated_web_apps/window_management/BUILD.gnchrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc
Patch
From 964c5f9e3beddaeb5c1accb7166dc6bc153e9819 Mon Sep 17 00:00:00 2001 From: Magdalena Skarbinska <[email protected]> Date: Mon, 03 Aug 2026 03:25:17 -0700 Subject: [PATCH] [IWA] Fix IWA scope extension check in NavigationCapturingProcess NavigationCapturingProcess::MaybeNotifyIwaTabCounterService previously called FindBestAppWithUrlInScope for the initiator origin without excluding scope extensions. As a result, when a window was opened from an origin matching an IWA's scope extension, the navigation was incorrectly attributed to the IWA as the opener. This change passes {.exclude_scope_extensions = true} to FindBestAppWithUrlInScope in NavigationCapturingProcess so that scope extensions are excluded when resolving whether an IWA is the opener. Adds a browser test NoNotificationForTabOpenedFromScopeExtensionOrigin verifying no notification is emitted when opening a tab from a scope extension origin. Bug: 517721914 Change-Id: I9e3dd945d9452d0a76326e7e50b24785409185f6 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8159009 Reviewed-by: Mike Wasserman <[email protected]> Reviewed-by: Andrew Rayskiy <[email protected]> Commit-Queue: Magdalena Skarbińska <[email protected]> Cr-Commit-Position: refs/heads/main@{#1672550} --- diff --git a/chrome/browser/ui/web_applications/navigation_capturing_process.cc b/chrome/browser/ui/web_applications/navigation_capturing_process.cc index a81948d..86341075 100644 --- a/chrome/browser/ui/web_applications/navigation_capturing_process.cc +++ b/chrome/browser/ui/web_applications/navigation_capturing_process.cc @@ -1057,7 +1057,8 @@ } iwa_opener_app_id = provider->registrar_unsafe().FindBestAppWithUrlInScope( - initiator_origin->GetURL(), WebAppFilter::IsIsolatedApp()); + initiator_origin->GetURL(), WebAppFilter::IsIsolatedApp(), + {.exclude_scope_extensions = true}); } // If the "iwa_opener_app_id" is still not found, then there is a chance that diff --git a/chrome/browser/web_applications/isolated_web_apps/window_management/BUILD.gn b/chrome/browser/web_applications/isolated_web_apps/window_management/BUILD.gn index 09931fe0..b5841ccf 100644 --- a/chrome/browser/web_applications/isolated_web_apps/window_management/BUILD.gn +++ b/chrome/browser/web_applications/isolated_web_apps/window_management/BUILD.gn @@ -68,6 +68,7 @@ "//components/permissions", "//components/webapps/common", "//components/webapps/isolated_web_apps/test_support", + "//components/webapps/services/web_app_origin_association/test:test_support", "//content/public/browser", "//content/test:test_support", "//ui/base", diff --git a/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc index 06e8e66..2006fbb2 100644 --- a/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc +++ b/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc @@ -9,6 +9,8 @@ #include <vector> #include "base/i18n/message_formatter.h" +#include "base/json/json_writer.h" +#include "base/strings/stringprintf.h" #include "base/strings/utf_string_conversions.h" #include "base/test/simple_test_clock.h" #include "base/test/test_future.h" @@ -26,16 +28,24 @@ #include "chrome/browser/web_applications/test/web_app_install_test_utils.h" #include "chrome/browser/web_applications/test/web_app_test_utils.h" #include "chrome/browser/web_applications/web_app_command_scheduler.h" +#include "chrome/browser/web_applications/web_app_origin_association_manager.h" #include "chrome/browser/web_applications/web_app_provider.h" #include "chrome/browser/web_applications/web_app_registrar.h" #include "chrome/test/base/in_process_browser_test.h" +#include "chrome/test/base/ui_test_utils.h" #include "components/webapps/common/web_app_id.h" #include "components/webapps/isolated_web_apps/test_support/signing_keys.h" +#include "components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h" +#include "content/public/browser/page_navigator.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/site_instance.h" #include "content/public/browser/web_contents.h" #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/test_navigation_observer.h" +#include "net/test/embedded_test_server/embedded_test_server.h" #include "ui/base/l10n/l10n_util.h" +#include "url/origin.h" class Browser; @@ -502,4 +512,97 @@ GURL("https://example.com/child1")); EXPECT_EQ(0u, GetNotificationCount()); } + +class IsolatedWebAppsWindowOpenPermissionServiceScopeExtensionBrowserTest + : public IsolatedWebAppsWindowOpenPermissionServiceBrowserTest { + public: + void SetUpOnMainThread() override { + IsolatedWebAppsWindowOpenPermissionServiceBrowserTest::SetUpOnMainThread(); + + auto fetcher = + std::make_unique<webapps::TestWebAppOriginAssociationFetcher>(); + origin_association_fetcher_ = fetcher.get(); + provider().origin_association_manager().SetFetcherForTest( + std::move(fetcher)); + } + + void TearDownOnMainThread() override { + origin_association_fetcher_ = nullptr; + IsolatedWebAppsWindowOpenPermissionServiceBrowserTest:: + TearDownOnMainThread(); + } + + protected: + webapps::AppId InstallIsolatedWebAppWithScopeExtension( + const url::Origin& scope_extension_origin) { + origin_association_fetcher_->SetData( + {{scope_extension_origin, + *base::WriteJson(base::DictValue().Set( + base::StringPrintf( + "isolated-app://%s/", + web_package::test::GetDefaultEd25519WebBundleId() + .id() + .c_str()), + base::DictValue().Set("scope", "/")))}}); + + webapps::AppId app_id = + IsolatedWebAppBuilder( + ManifestBuilder() + .SetName(kIsolatedApp1DefaultName) + .SetVersion(kIsolatedAppVersion) + .AddScopeExtension(scope_extension_origin, + /*has_origin_wildcard=*/false)) + .BuildBundle(web_package::test::GetDefaultEd25519KeyPair()) + ->InstallChecked(profile()) + .app_id(); + EXPECT_EQ(1u, provider() + .registrar_unsafe() + .GetValidatedScopeExtensions(app_id) + .size()); + return app_id; + } + + private: + raw_ptr<webapps::TestWebAppOriginAssociationFetcher> + origin_association_fetcher_ = nullptr; +}; + +IN_PROC_BROWSER_TEST_F( + IsolatedWebAppsWindowOpenPermissionServiceScopeExtensionBrowserTest, + NoNotificationForTabOpenedFromScopeExtensionOrigin) { + url::Origin scope_extension_origin = embedded_https_test_server().GetOrigin(); + webapps::AppId app_id = + InstallIsolatedWebAppWithScopeExtension(scope_extension_origin); + + // Open the scope-extension origin in a regular browser tab (not in the IWA). + ASSERT_TRUE(ui_test_utils::NavigateToURL( + browser(), embedded_https_test_server().GetURL("/simple.html"))); + content::WebContents* opener_contents = + browser()->tab_strip_model()->GetActiveWebContents(); + ASSERT_EQ(scope_extension_origin, + opener_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin()); + + // Open a new background tab from the scope-extension page, as the renderer + // does for e.g. a middle-click on a link. + content::OpenURLParams params( + GURL("https://example.com/destination"), content::Referrer(), + WindowOpenDisposition::NEW_BACKGROUND_TAB, ui::PAGE_TRANSITION_LINK, + /*is_renderer_initiated=*/true); + params.initiator_origin = scope_extension_origin; + params.source_site_instance = + opener_contents->GetPrimaryMainFrame()->GetSiteInstance(); + params.user_gesture = true; + + content::WebContentsAddedObserver new_contents_observer; + opener_contents->OpenURL(params, /*navigation_handle_callback=*/{}); + content::WebContents* new_contents = new_contents_observer.GetWebContents(); + WaitForLoadStopWithoutSuccessCheck(new_contents); + + // The IWA itself never opened a window, so it must not be attributed as the + // opener. + EXPECT_EQ(0u, GetNotificationCount()); + EXPECT_FALSE( + display_service_tester_->GetNotification(GetNotificationIdForApp(app_id)) + .has_value()); +} } // namespace web_app
Regression Test / PoC
diff --git a/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc b/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc
index 06e8e66..2006fbb2 100644
--- a/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc
+++ b/chrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service_browsertest.cc
@@ -9,6 +9,8 @@
#include <vector>
#include "base/i18n/message_formatter.h"
+#include "base/json/json_writer.h"
+#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/test/simple_test_clock.h"
#include "base/test/test_future.h"
@@ -26,16 +28,24 @@
#include "chrome/browser/web_applications/test/web_app_install_test_utils.h"
#include "chrome/browser/web_applications/test/web_app_test_utils.h"
#include "chrome/browser/web_applications/web_app_command_scheduler.h"
+#include "chrome/browser/web_applications/web_app_origin_association_manager.h"
#include "chrome/browser/web_applications/web_app_provider.h"
#include "chrome/browser/web_applications/web_app_registrar.h"
#include "chrome/test/base/in_process_browser_test.h"
+#include "chrome/test/base/ui_test_utils.h"
#include "components/webapps/common/web_app_id.h"
#include "components/webapps/isolated_web_apps/test_support/signing_keys.h"
+#include "components/webapps/services/web_app_origin_association/test/test_web_app_origin_association_fetcher.h"
+#include "content/public/browser/page_navigator.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/site_instance.h"
#include "content/public/browser/web_contents.h"
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/test_navigation_observer.h"
+#include "net/test/embedded_test_server/embedded_test_server.h"
#include "ui/base/l10n/l10n_util.h"
+#include "url/origin.h"
class Browser;
@@ -502,4 +512,97 @@
GURL("https://example.com/child1"));
EXPECT_EQ(0u, GetNotificationCount());
}
+
+class IsolatedWebAppsWindowOpenPermissionServiceScopeExtensionBrowserTest
+ : public IsolatedWebAppsWindowOpenPermissionServiceBrowserTest {
+ public:
+ void SetUpOnMainThread() override {
+ IsolatedWebAppsWindowOpenPermissionServiceBrowserTest::SetUpOnMainThread();
+
+ auto fetcher =
+ std::make_unique<webapps::TestWebAppOriginAssociationFetcher>();
+ origin_association_fetcher_ = fetcher.get();
+ provider().origin_association_manager().SetFetcherForTest(
+ std::move(fetcher));
+ }
+
+ void TearDownOnMainThread() override {
+ origin_association_fetcher_ = nullptr;
+ IsolatedWebAppsWindowOpenPermissionServiceBrowserTest::
+ TearDownOnMainThread();
+ }
+
+ protected:
+ webapps::AppId InstallIsolatedWebAppWithScopeExtension(
+ const url::Origin& scope_extension_origin) {
+ origin_association_fetcher_->SetData(
+ {{scope_extension_origin,
+ *base::WriteJson(base::DictValue().Set(
+ base::StringPrintf(
+ "isolated-app://%s/",
+ web_package::test::GetDefaultEd25519WebBundleId()
+ .id()
+ .c_str()),
+ base::DictValue().Set("scope", "/")))}});
+
+ webapps::AppId app_id =
+ IsolatedWebAppBuilder(
+ ManifestBuilder()
+ .SetName(kIsolatedApp1DefaultName)
+ .SetVersion(kIsolatedAppVersion)
+ .AddScopeExtension(scope_extension_origin,
+ /*has_origin_wildcard=*/false))
+ .BuildBundle(web_package::test::GetDefaultEd25519KeyPair())
+ ->InstallChecked(profile())
+ .app_id();
+ EXPECT_EQ(1u, provider()
+ .registrar_unsafe()
+ .GetValidatedScopeExtensions(app_id)
+ .size());
+ return app_id;
+ }
+
+ private:
+ raw_ptr<webapps::TestWebAppOriginAssociationFetcher>
+ origin_association_fetcher_ = nullptr;
+};
+
+IN_PROC_BROWSER_TEST_F(
+ IsolatedWebAppsWindowOpenPermissionServiceScopeExtensionBrowserTest,
+ NoNotificationForTabOpenedFromScopeExtensionOrigin) {
+ url::Origin scope_extension_origin = embedded_https_test_server().GetOrigin();
+ webapps::AppId app_id =
+ InstallIsolatedWebAppWithScopeExtension(scope_extension_origin);
+
+ // Open the scope-extension origin in a regular browser tab (not in the IWA).
+ ASSERT_TRUE(ui_test_utils::NavigateToURL(
+ browser(), embedded_https_test_server().GetURL("/simple.html")));
+ content::WebContents* opener_contents =
+ browser()->tab_strip_model()->GetActiveWebContents();
+ ASSERT_EQ(scope_extension_origin,
+ opener_contents->GetPrimaryMainFrame()->GetLastCommittedOrigin());
+
+ // Open a new background tab from the scope-extension page, as the renderer
+ // does for e.g. a middle-click on a link.
+ content::OpenURLParams params(
+ GURL("https://example.com/destination"), content::Referrer(),
+ WindowOpenDisposition::NEW_BACKGROUND_TAB, ui::PAGE_TRANSITION_LINK,
+ /*is_renderer_initiated=*/true);
+ params.initiator_origin = scope_extension_origin;
+ params.source_site_instance =
+ opener_contents->GetPrimaryMainFrame()->GetSiteInstance();
+ params.user_gesture = true;
+
+ content::WebContentsAddedObserver new_contents_observer;
+ opener_contents->OpenURL(params, /*navigation_handle_callback=*/{});
+ content::WebContents* new_contents = new_contents_observer.GetWebContents();
+ WaitForLoadStopWithoutSuccessCheck(new_contents);
+
+ // The IWA itself never opened a window, so it must not be attributed as the
+ // opener.
+ EXPECT_EQ(0u, GetNotificationCount());
+ EXPECT_FALSE(
+ display_service_tester_->GetNotification(GetNotificationIdForApp(app_id))
+ .has_value());
+}
} // namespace web_app
Original Bug Report
Potential IWA warning quota exhaustion via initiator fallback scope extension mismatch
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: In NavigationCapturingProcess::MaybeNotifyIwaTabCounterService, resolving an IWA opener via the initiator-origin fallback fails to exclude scope extensions. As a result, an ordinary HTTPS origin listed in an IWA’s validated scope extensions can falsely match as the IWA itself. This allows a regular website to trigger misleading system notifications and exhaust the safety warning quota for the associated IWA.
Affected files:
chrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/web_applications/isolated_web_apps/window_management/isolated_web_apps_window_open_permission_service.cc
Estimated timestamp from git blame: 2025-10-30
Potential Security Mitigation Bypass and UI Spoofing in IWA Tab Counter Service
Root Cause Analysis
In NavigationCapturingProcess::MaybeNotifyIwaTabCounterService (chrome/browser/ui/web_applications/navigation_capturing_process.cc), the potential IWA opener is resolved via an initiator-origin fallback when an opener-chain app ID is not present:
// chrome/browser/ui/web_applications/navigation_capturing_process.cc
if (!iwa_opener_app_id) {
if (!navigation_handle) {
return;
}
const auto& initiator_origin = navigation_handle->GetInitiatorOrigin();
if (!initiator_origin) {
return;
}
iwa_opener_app_id = provider->registrar_unsafe().FindBestAppWithUrlInScope(
initiator_origin->GetURL(), WebAppFilter::IsIsolatedApp());
}
However, the call to FindBestAppWithUrlInScope uses the 2-argument overload (defined in chrome/browser/web_applications/web_app_registrar.h), which defaults scope_score_options to {}. Consequently, exclude_scope_extensions is false during the scope resolution.
This causes GetScopeScore to walk the target app’s validated_scope_extensions. If a regular HTTPS origin (e.g., https://ext.example) is listed in a user-installed Isolated Web App’s (IWA) validated_scope_extensions, the IWA wins the scope match. Since the post-match check only tests the resolved app’s isolation properties rather than the initiator’s scheme, the initiator is incorrectly treated as the IWA.
This is an anti-pattern that has previously been fixed at other security-sensitive sites, such as:
DeviceServiceImpl::IsTrustedContext(chrome/browser/device_api/device_service_impl.cc:112)WebAppTabHelper::FindAppIdForUrl(chrome/browser/web_applications/web_app_tab_helper.cc:60)
In both cases, {.exclude_scope_extensions = true} is explicitly passed to ensure regular HTTPS origins in scope extensions cannot pretend to be the trusted IWA.
Potential Trigger Path
Note: These are potential steps for a proof of concept; our tooling agent does not have the ability to run code.
- A user-installed IWA (matching
IsIsolatedWebAppWithOnlyUserManagement) declareshttps://ext.examplein itsscope_extensions, and the origin has opted in via.well-known/web-app-origin-association(successfully registered invalidated_scope_extensions). - The user has
https://ext.exampleopen in an ordinary browser tab and clicks an<a target="_blank" rel="noopener">link or executeswindow.openwithout keeping an opener reference. - This is routed to
MaybeNotifyIwaTabCounterServicewith the initiator origin set tohttps://ext.examplebut no live opener chain. - The initiator-origin fallback invokes
FindBestAppWithUrlInScopewithout excluding scope extensions, potentially resolvingiwa_opener_app_idto the IWA. IsolatedWebAppsWindowOpenPermissionService::OnWebContentsCreatedis notified with this App ID.
Potential Impact
An HTTPS origin listed in an IWA’s scope_extensions can potentially cause the following security issues:
- False Attribution UI Notification: It triggers a trusted ChromeOS system notification ("
<IWA name>is opening tabs") with a settings button pointing to the IWA’s popup-permission page. This presents a trusted UI misattribution/spoofing vector. - Safety Warning Quota Exhaustion: It silently increments and persists the IWA’s
opened_tabs_counter_notification_state().times_shownup tokMaxNotificationShowCount = 3in the WebApp database. Once this safety quota is exhausted, the user is never again warned when the IWA itself actually spawns many tabs or windows in the future, effectively bypassing a built-in safety boundary.
Suggested Fix
Update the fallback logic in NavigationCapturingProcess::MaybeNotifyIwaTabCounterService to explicitly exclude scope extensions during resolution:
iwa_opener_app_id = provider->registrar_unsafe().FindBestAppWithUrlInScope(
initiator_origin->GetURL(), WebAppFilter::IsIsolatedApp(),
{.exclude_scope_extensions = true});
Evaluated with Chrome root at commit: 5133b93d189b383c37805b1cf3a9d2dbfe8d7379
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.