CVE-2026-17821
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ExtensionApiTabsIwaMoveTestchrome/browser/extensions/api/tabs/tabs_test.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/extensions/api/tabs/tabs_test.cc |
modified |
Files Changed
chrome/browser/extensions/api/tabs/tabs_api.ccchrome/browser/extensions/api/tabs/tabs_test.cc
Patch
From d6da53cf360a20c10440e797bb39711d163cc8a6 Mon Sep 17 00:00:00 2001 From: Bhaskar Sharma <[email protected]> Date: Tue, 23 Jun 2026 03:50:55 -0700 Subject: [PATCH] Block chrome.tabs.create and chrome.tabs.update for Isolated Web Apps. Isolated Web App URLs ('isolated-app:' scheme) are not allowed to be opened via chrome.tabs.create or navigated to via chrome.tabs.update. Extensions must use chrome.windows.create to open IWAs. Fixed: 517597914 Change-Id: Idbe1816b717ae2f91aa78113ed9de890ef85bdcd Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7977846 Reviewed-by: Reilly Grant <[email protected]> Reviewed-by: Andrew Rayskiy <[email protected]> Commit-Queue: Bhaskar Sharma <[email protected]> Cr-Commit-Position: refs/heads/main@{#1650899} --- diff --git a/chrome/browser/extensions/api/tabs/tabs_api.cc b/chrome/browser/extensions/api/tabs/tabs_api.cc index ecb7ef48..be91971 100644 --- a/chrome/browser/extensions/api/tabs/tabs_api.cc +++ b/chrome/browser/extensions/api/tabs/tabs_api.cc @@ -143,6 +143,12 @@ "tab by its ID."; constexpr char kCannotMoveIwaTabError[] = "The tab of an Isolated Web App cannot be moved."; +constexpr char kTabsCreateIwaUrlNotAllowedError[] = + "URLs with the 'isolated-app:' scheme cannot be opened with tabs.create. " + "Use windows.create instead."; +constexpr char kTabsUpdateIwaUrlNotAllowedError[] = + "Cannot navigate to a URL with the 'isolated-app:' scheme via tabs.update. " + "Use windows.create instead."; #endif #if BUILDFLAG(IS_ANDROID) @@ -2102,6 +2108,14 @@ validated_url_ = std::move(maybe_url.value()); } +#if !BUILDFLAG(IS_ANDROID) + // Isolated Web Apps must be opened at their start URL with the requested + // URL routed via launchQueue, which is handled by `windows.create`. + if (validated_url_.SchemeIs(webapps::kIsolatedAppScheme)) { + return RespondNow(Error(kTabsCreateIwaUrlNotAllowedError)); + } +#endif + opener_tab_id_ = create_properties.opener_tab_id; // TODO(jstritar): Add a constant, chrome.tabs.TAB_ID_ACTIVE, that @@ -2817,6 +2831,15 @@ return false; } +#if !BUILDFLAG(IS_ANDROID) + // Isolated Web Apps must be opened at their start URL with the requested + // URL routed via launchQueue, which is handled by `windows.create`. + if (url->SchemeIs(webapps::kIsolatedAppScheme)) { + *error = kTabsUpdateIwaUrlNotAllowedError; + return false; + } +#endif + if (IsDSERedirect(extension()->id(), *browser_context(), render_frame_host(), *web_contents, *url, user_gesture())) { ukm::builders::Extensions_Tabs_UpdateDSE( diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc index 1b20d91..53ac97a 100644 --- a/chrome/browser/extensions/api/tabs/tabs_test.cc +++ b/chrome/browser/extensions/api/tabs/tabs_test.cc @@ -1442,6 +1442,26 @@ return bundle->InstallChecked(profile()); } + BrowserWindowInterface* OpenIwa( + const web_app::IsolatedWebAppUrlInfo& url_info) { + scoped_refptr<const Extension> extension = + ExtensionBuilder("IwaOpenerExtension").Build(); + auto function = base::MakeRefCounted<WindowsCreateFunction>(); + function->set_extension(extension); + + std::string args = base::StringPrintf( + R"([{"url": "%s"}])", url_info.origin().GetURL().spec().c_str()); + + bool result = api_test_utils::RunFunction( + function.get(), args, profile(), api_test_utils::FunctionMode::kNone); + EXPECT_TRUE(result) << function->GetError(); + + BrowserWindowInterface* iwa_browser = + GetLastActiveBrowserWindowInterfaceWithAnyProfile(); + EXPECT_TRUE(iwa_browser); + return iwa_browser; + } + private: base::test::ScopedFeatureList scoped_feature_list_; web_app::OsIntegrationManager::ScopedSuppressForTesting os_hooks_suppress_; @@ -1564,31 +1584,79 @@ [](const testing::TestParamInfo<ExtensionWindowCreateIwaTest::ParamType>& info) { return info.param.test_name; }); -class ExtensionApiTabsIwaMoveTest : public ExtensionIwaTestBase { - public: - ExtensionApiTabsIwaMoveTest() = default; +using ExtensionApiTabsIwaMoveTest = ExtensionIwaTestBase; - protected: - BrowserWindowInterface* OpenIwa( - const web_app::IsolatedWebAppUrlInfo& url_info) { - scoped_refptr<const Extension> extension = - ExtensionBuilder("IwaOpenerExtension").Build(); - auto function = base::MakeRefCounted<WindowsCreateFunction>(); - function->set_extension(extension); +using ExtensionApiTabsIwaNavigateTest = ExtensionIwaTestBase; - std::string args = base::StringPrintf( - R"([{"url": "%s"}])", url_info.origin().GetURL().spec().c_str()); +// `tabs.create` does not support `isolated-app:` URLs, even when targeting an +// existing IWA window. `windows.create` is the supported entry point and +// always opens IWAs at their `start_url`. +IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaNavigateTest, + TabsCreateRejectsIwaUrl) { + auto url_info = InstallAndTrustBundle(); + BrowserWindowInterface* iwa_browser = OpenIwa(url_info); + int iwa_window_id = ExtensionTabUtil::GetWindowId(iwa_browser); - bool result = api_test_utils::RunFunction( - function.get(), args, profile(), api_test_utils::FunctionMode::kNone); - EXPECT_TRUE(result) << function->GetError(); + TabListInterface* iwa_tab_list = TabListInterface::From(iwa_browser); + ASSERT_EQ(iwa_tab_list->GetTabCount(), 1); + auto* iwa_web_contents = iwa_tab_list->GetActiveTab()->GetContents(); + content::WaitForLoadStop(iwa_web_contents); - BrowserWindowInterface* iwa_browser = - GetLastActiveBrowserWindowInterfaceWithAnyProfile(); - EXPECT_TRUE(iwa_browser); - return iwa_browser; - } -}; + GURL deep_url = url_info.origin().GetURL().Resolve("/deep/page.html"); + std::string args = base::StringPrintf(R"([{"url": "%s", "windowId": %d}])", + deep_url.spec().c_str(), iwa_window_id); + + scoped_refptr<const Extension> extension = + ExtensionBuilder("ExtensionApiTabsIwaNavigateTest").Build(); + auto function = base::MakeRefCounted<TabsCreateFunction>(); + function->set_extension(extension); + + std::string error = api_test_utils::RunFunctionAndReturnError( + function.get(), args, profile()); + EXPECT_EQ(error, + "URLs with the 'isolated-app:' scheme cannot be opened with " + "tabs.create. Use windows.create instead."); + + // Only the original IWA window remains, still showing the start URL. + ASSERT_EQ(GlobalBrowserCollection::GetInstance()->GetSize(), 1ul); + ASSERT_EQ(iwa_tab_list->GetTabCount(), 1); + EXPECT_EQ(iwa_web_contents->GetLastCommittedURL(), + url_info.origin().GetURL()); +} + +// `tabs.update` cannot be used to navigate any tab (IWA or otherwise) to an +// `isolated-app:` URL; IWA navigations are only supported via the launch entry +// point used by `windows.create`. +IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaNavigateTest, + TabsUpdateRejectsIwaUrl) { + auto url_info = InstallAndTrustBundle(); + BrowserWindowInterface* iwa_browser = OpenIwa(url_info); + + TabListInterface* iwa_tab_list = TabListInterface::From(iwa_browser); + ASSERT_EQ(iwa_tab_list->GetTabCount(), 1); + auto* iwa_web_contents = iwa_tab_list->GetActiveTab()->GetContents(); + content::WaitForLoadStop(iwa_web_contents); + int iwa_tab_id = ExtensionTabUtil::GetTabId(iwa_web_contents); + + GURL deep_url = url_info.origin().GetURL().Resolve("/deep/page.html"); + std::string args = base::StringPrintf(R"([%d, {"url": "%s"}])", iwa_tab_id, + deep_url.spec().c_str()); + + scoped_refptr<const Extension> extension = + ExtensionBuilder("ExtensionApiTabsIwaNavigateTest").Build(); + auto function = base::MakeRefCounted<TabsUpdateFunction>(); + function->set_extension(extension); + + std::string error = api_test_utils::RunFunctionAndReturnError( + function.get(), args, profile()); + EXPECT_EQ(error, + "Cannot navigate to a URL with the 'isolated-app:' scheme via " + "tabs.update. Use windows.create instead."); + + // The IWA tab is still at its start URL. + EXPECT_EQ(iwa_web_contents->GetLastCommittedURL(), + url_info.origin().GetURL()); +} IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaMoveTest, CannotMoveIwaTab) { auto url_info = InstallAndTrustBundle();
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 1b20d91..53ac97a 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -1442,6 +1442,26 @@
return bundle->InstallChecked(profile());
}
+ BrowserWindowInterface* OpenIwa(
+ const web_app::IsolatedWebAppUrlInfo& url_info) {
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("IwaOpenerExtension").Build();
+ auto function = base::MakeRefCounted<WindowsCreateFunction>();
+ function->set_extension(extension);
+
+ std::string args = base::StringPrintf(
+ R"([{"url": "%s"}])", url_info.origin().GetURL().spec().c_str());
+
+ bool result = api_test_utils::RunFunction(
+ function.get(), args, profile(), api_test_utils::FunctionMode::kNone);
+ EXPECT_TRUE(result) << function->GetError();
+
+ BrowserWindowInterface* iwa_browser =
+ GetLastActiveBrowserWindowInterfaceWithAnyProfile();
+ EXPECT_TRUE(iwa_browser);
+ return iwa_browser;
+ }
+
private:
base::test::ScopedFeatureList scoped_feature_list_;
web_app::OsIntegrationManager::ScopedSuppressForTesting os_hooks_suppress_;
@@ -1564,31 +1584,79 @@
[](const testing::TestParamInfo<ExtensionWindowCreateIwaTest::ParamType>&
info) { return info.param.test_name; });
-class ExtensionApiTabsIwaMoveTest : public ExtensionIwaTestBase {
- public:
- ExtensionApiTabsIwaMoveTest() = default;
+using ExtensionApiTabsIwaMoveTest = ExtensionIwaTestBase;
- protected:
- BrowserWindowInterface* OpenIwa(
- const web_app::IsolatedWebAppUrlInfo& url_info) {
- scoped_refptr<const Extension> extension =
- ExtensionBuilder("IwaOpenerExtension").Build();
- auto function = base::MakeRefCounted<WindowsCreateFunction>();
- function->set_extension(extension);
+using ExtensionApiTabsIwaNavigateTest = ExtensionIwaTestBase;
- std::string args = base::StringPrintf(
- R"([{"url": "%s"}])", url_info.origin().GetURL().spec().c_str());
+// `tabs.create` does not support `isolated-app:` URLs, even when targeting an
+// existing IWA window. `windows.create` is the supported entry point and
+// always opens IWAs at their `start_url`.
+IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaNavigateTest,
+ TabsCreateRejectsIwaUrl) {
+ auto url_info = InstallAndTrustBundle();
+ BrowserWindowInterface* iwa_browser = OpenIwa(url_info);
+ int iwa_window_id = ExtensionTabUtil::GetWindowId(iwa_browser);
- bool result = api_test_utils::RunFunction(
- function.get(), args, profile(), api_test_utils::FunctionMode::kNone);
- EXPECT_TRUE(result) << function->GetError();
+ TabListInterface* iwa_tab_list = TabListInterface::From(iwa_browser);
+ ASSERT_EQ(iwa_tab_list->GetTabCount(), 1);
+ auto* iwa_web_contents = iwa_tab_list->GetActiveTab()->GetContents();
+ content::WaitForLoadStop(iwa_web_contents);
- BrowserWindowInterface* iwa_browser =
- GetLastActiveBrowserWindowInterfaceWithAnyProfile();
- EXPECT_TRUE(iwa_browser);
- return iwa_browser;
- }
-};
+ GURL deep_url = url_info.origin().GetURL().Resolve("/deep/page.html");
+ std::string args = base::StringPrintf(R"([{"url": "%s", "windowId": %d}])",
+ deep_url.spec().c_str(), iwa_window_id);
+
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("ExtensionApiTabsIwaNavigateTest").Build();
+ auto function = base::MakeRefCounted<TabsCreateFunction>();
+ function->set_extension(extension);
+
+ std::string error = api_test_utils::RunFunctionAndReturnError(
+ function.get(), args, profile());
+ EXPECT_EQ(error,
+ "URLs with the 'isolated-app:' scheme cannot be opened with "
+ "tabs.create. Use windows.create instead.");
+
+ // Only the original IWA window remains, still showing the start URL.
+ ASSERT_EQ(GlobalBrowserCollection::GetInstance()->GetSize(), 1ul);
+ ASSERT_EQ(iwa_tab_list->GetTabCount(), 1);
+ EXPECT_EQ(iwa_web_contents->GetLastCommittedURL(),
+ url_info.origin().GetURL());
+}
+
+// `tabs.update` cannot be used to navigate any tab (IWA or otherwise) to an
+// `isolated-app:` URL; IWA navigations are only supported via the launch entry
+// point used by `windows.create`.
+IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaNavigateTest,
+ TabsUpdateRejectsIwaUrl) {
+ auto url_info = InstallAndTrustBundle();
+ BrowserWindowInterface* iwa_browser = OpenIwa(url_info);
+
+ TabListInterface* iwa_tab_list = TabListInterface::From(iwa_browser);
+ ASSERT_EQ(iwa_tab_list->GetTabCount(), 1);
+ auto* iwa_web_contents = iwa_tab_list->GetActiveTab()->GetContents();
+ content::WaitForLoadStop(iwa_web_contents);
+ int iwa_tab_id = ExtensionTabUtil::GetTabId(iwa_web_contents);
+
+ GURL deep_url = url_info.origin().GetURL().Resolve("/deep/page.html");
+ std::string args = base::StringPrintf(R"([%d, {"url": "%s"}])", iwa_tab_id,
+ deep_url.spec().c_str());
+
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("ExtensionApiTabsIwaNavigateTest").Build();
+ auto function = base::MakeRefCounted<TabsUpdateFunction>();
+ function->set_extension(extension);
+
+ std::string error = api_test_utils::RunFunctionAndReturnError(
+ function.get(), args, profile());
+ EXPECT_EQ(error,
+ "Cannot navigate to a URL with the 'isolated-app:' scheme via "
+ "tabs.update. Use windows.create instead.");
+
+ // The IWA tab is still at its start URL.
+ EXPECT_EQ(iwa_web_contents->GetLastCommittedURL(),
+ url_info.origin().GetURL());
+}
IN_PROC_BROWSER_TEST_F(ExtensionApiTabsIwaMoveTest, CannotMoveIwaTab) {
auto url_info = InstallAndTrustBundle();
Original Bug Report
IWA start_url forcing deep-link guard bypass via tabs.create and tabs.update
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 potential vulnerability in the Google Chrome extension APIs chrome.tabs.create and chrome.tabs.update allows low-privilege extensions to bypass the security guard designed to force Isolated Web Apps (IWAs) to launch at their start_url. By specifying the IWA’s window ID or updating an existing IWA tab, a malicious extension can navigate the IWA directly to sensitive, internal deep-link URLs. This can facilitate CSRF-style attacks against installed IWAs.
Affected files:
chrome/browser/extensions/api/tabs/tabs_api.ccchrome/browser/extensions/open_tab_helper.ccchrome/browser/web_applications/isolated_web_apps/isolated_web_app_throttle.ccchrome/browser/ui/web_applications/navigation_capturing_process.ccchrome/browser/extensions/extension_tab_util.cc
Estimated timestamp from git blame: 2025-06-13
Summary
Isolated Web Apps (IWAs) implement a deep-linking defense mechanism where any launch of the application is forced to its start_url in WindowsCreateFunction (chrome/browser/extensions/api/tabs/tabs_api.cc). The original, deep-link URL is routed securely via window.launchQueue so the IWA can validate the requested path and prevent cross-site request forgery (CSRF) or unauthorized state transitions.
However, the chrome.tabs.create and chrome.tabs.update extension APIs potentially lack equivalent enforcement. This allows a low-privilege extension (with zero special permission requirements) to bypass this deep-link defense and navigate an installed IWA directly to an arbitrary internal URL.
Root Cause Analysis
1. Potential tabs.create Bypass Vector
In WindowsCreateFunction (tabs_api.cc), there is an explicit routing block for IWAs:
if (isolated_web_app_url_info_) {
CHECK_EQ(urls_.size(), 1U);
const GURL& original_url = urls_[0];
...
if (registrar.AppMatches(iwa_id, web_app::WebAppFilter::IsIsolatedApp())) {
NavigateParams navigate_params = create_nav_params(
registrar.GetAppStartUrl(iwa_id), /*is_first_nav=*/true);
base::WeakPtr<content::NavigationHandle> handle = Navigate(&navigate_params);
web_app::EnqueueLaunchParams(handle->GetWebContents(), iwa_id, original_url, ...);
}
}
However, TabsCreateFunction has no equivalent restriction. When an extension calls chrome.tabs.create({url, windowId}), it resolves windowId to a BrowserWindowInterface* and routes to OpenTabHelper::OpenTab (chrome/browser/extensions/open_tab_helper.cc), which navigates to the requested URL verbatim.
While lower-level navigation checks exist in HandleIsolatedWebAppNavigation (chrome/browser/ui/web_applications/navigation_capturing_process.cc), they are bypassed if the extension specifies the IWA’s window ID. In NavigationCapturingProcess, source_browser_app_id_ is derived from params.browser:
source_browser_app_id_(
params.browser && web_app::AppBrowserController::IsWebApp(params.browser)
? std::optional(params.browser->...->app_controller()->app_id())
: std::nullopt),
By supplying the target IWA window’s ID, source_browser_app_id_ resolves to the IWA’s app ID. Consequently, the cross-IWA cancellation guard evaluates to false (since the source and target app IDs match):
if (ui::PageTransitionCoreTypeIs(params.transition, ui::PAGE_TRANSITION_LINK)) {
if (source_browser_app_id_ != iwa_id && ...) { // Evaluates to false; no cancellation occurs
return CancelInitialNavigation(...);
}
}
The navigation proceeds with the original, unvetted params.url, opening a new IWA tab/window navigated directly to the deep path.
2. Potential tabs.update Bypass Vector
When an extension invokes chrome.tabs.update(<iwa-tab-id>, {url: 'isolated-app://<same-iwa>/deep'}), TabsUpdateFunction::UpdateURL (tabs_api.cc) directly calls:
web_contents->GetController().LoadURLWithParams(...);
This entirely bypasses Navigate() and the HandleIsolatedWebAppNavigation navigation capturing pipelines. Because the navigation occurs within an existing IWA-associated WebContents, the IsolatedWebAppThrottle (chrome/browser/web_applications/isolated_web_apps/isolated_web_app_throttle.cc) permits the request because the destination origin matches the existing WebContentsIsolationInfo origin. This navigates the live, active IWA tab to the deep path in-place.
Potential Impact
An installed, low-privilege extension with no manifest permissions can query all open windows via chrome.windows.getAll({windowTypes: ['app']}) to retrieve the active IWA’s window and tab IDs. It can then trigger sensitive, internal routes (e.g., administrative actions, state modification endpoints) directly, circumventing the application’s launchQueue validation logic and performing a localized CSRF attack against the IWA.
Suggested / Potential Reproduction Steps
Note: Our automated tooling does not yet have the ability to run code, so these are suggested/potential steps to reproduce.
- Install an Isolated Web App (IWA) and open it so that its window is active.
- Load an unpacked Manifest V3 extension with an empty
permissionsblock. - Execute the following potential code from the extension’s background service worker to perform a direct deep navigation bypassing the
start_urlguard:// 1. Locate the open IWA window const windows = await chrome.windows.getAll({ windowTypes: ['app'] }); const targetIwaWindow = windows[0]; const targetIwaId = "<insert-iwa-app-id>"; // 2. Bypass via tabs.create await chrome.tabs.create({ url: `isolated-app://${targetIwaId}/secret-admin-route?action=wipe`, windowId: targetIwaWindow.id, active: true }); - Observe if a fresh IWA tab opens and navigates directly to
/secret-admin-route?action=wipeinstead of routing the request through the standard launch queue. - Alternatively, to reproduce via the
tabs.updatesibling vector on the active tab:const [tab] = await chrome.tabs.query({ windowId: targetIwaWindow.id }); await chrome.tabs.update(tab.id, { url: `isolated-app://${targetIwaId}/secret-admin-route?action=wipe` }); - Observe if the existing IWA tab navigates directly and in-place to the sensitive deep link.
Suggested Fix
- In
chrome/browser/extensions/api/tabs/tabs_api.cc, bothTabsCreateFunctionandTabsUpdateFunctionshould detect if the target navigation belongs to an Isolated Web App. - When an extension-initiated navigation targets an IWA, the navigation should either be strictly filtered/disallowed if it targets a deep path, or it should be forced to route through the standard
start_urlandEnqueueLaunchParamslaunch pipeline (similar to howWindowsCreateFunctionenforces this).
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.