CVE-2026-17781
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/tabs/tabs_api.cc |
modified | |
IN_PROC_BROWSER_TEST_Fchrome/browser/extensions/api/tabs/tabs_test.cc |
modified | |
forchrome/browser/extensions/api/tabs/tabs_test.cc |
modified | |
ifchrome/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 56537b9bf26b4408979a0afc8a0b12d99ce3249e Mon Sep 17 00:00:00 2001
From: Tim Judkins <tjudkins@chromium.org>
Date: Thu, 11 Jun 2026 11:27:32 -0700
Subject: [PATCH] [Extensions] Filter profiles for tabs.discard appropriately
When a tab ID is not passed to tabs.discard we leave the choice of which
tab to discard up to resource_coordinator::DiscardLeastImportantTab.
This CL adds the ability to pass a set of allowed_browser_context_ids to
additionally limit which browser contexts the resource coordinator can
discard from and passes the appropriate IDs for these based on the
extension incognito settings.
Also adds associated tests to cover these cases.
Fixed: 513502990
Change-Id: I2b373146e921a352afc8859f8ff4ecdf104c6303
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7866020
Commit-Queue: Tim <tjudkins@chromium.org>
Reviewed-by: Joe Mason <joenotcharles@google.com>
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1645500}
---
diff --git a/chrome/browser/extensions/api/tabs/tabs_api.cc b/chrome/browser/extensions/api/tabs/tabs_api.cc
index 7e41237..ecb7ef48 100644
--- a/chrome/browser/extensions/api/tabs/tabs_api.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_api.cc
@@ -14,6 +14,7 @@
#include "base/types/expected.h"
#include "base/types/expected_macros.h"
#include "base/types/optional_util.h"
+#include "base/unguessable_token.h"
#include "chrome/browser/devtools/devtools_window.h"
#include "chrome/browser/extensions/api/tabs/tabs_constants.h"
#include "chrome/browser/extensions/api/tabs/windows_util.h"
@@ -64,6 +65,7 @@
#include "extensions/common/permissions/permissions_data.h"
#include "services/metrics/public/cpp/ukm_builders.h"
#include "services/metrics/public/cpp/ukm_recorder.h"
+#include "third_party/abseil-cpp/absl/container/flat_hash_set.h"
#include "third_party/blink/public/common/page/page_zoom.h"
#include "third_party/skia/include/core/SkBitmap.h"
#include "ui/base/base_window.h"
@@ -4082,8 +4084,24 @@
contents = tab_list->DiscardTab(tab_list->GetTab(tab_index)->GetHandle());
} else {
+ // Make sure we only discard tabs from profiles the extension is allowed to
+ // access.
+ Profile* profile = Profile::FromBrowserContext(browser_context());
+ absl::flat_hash_set<base::UnguessableToken> allowed_tokens;
+ allowed_tokens.insert(profile->UniqueToken());
+
+ if (include_incognito_information()) {
+ Profile* maybe_incognito_profile =
+ profile->GetPrimaryOTRProfile(/*create_if_needed=*/false);
+ if (maybe_incognito_profile) {
+ allowed_tokens.insert(maybe_incognito_profile->UniqueToken());
+ }
+ }
+
contents = resource_coordinator::DiscardLeastImportantTab(
- ::mojom::LifecycleUnitDiscardReason::EXTERNAL);
+ ::mojom::LifecycleUnitDiscardReason::EXTERNAL,
+ /*ignore_recent_visibility=*/false,
+ /*allowed_browser_context_ids=*/std::move(allowed_tokens));
}
if (!contents) {
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 33bfb4da..30222c9 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -67,6 +67,7 @@
#include "extensions/browser/api_test_utils.h"
#include "extensions/browser/event_router.h"
#include "extensions/browser/extension_function_dispatcher.h"
+#include "extensions/browser/extension_prefs.h"
#include "extensions/browser/test_event_router_observer.h"
#include "extensions/common/constants.h"
#include "extensions/common/error_utils.h"
@@ -2078,6 +2079,251 @@
EXPECT_TRUE(base::MatchPattern(error, ExtensionTabUtil::kTabNotFoundError));
}
+// Tests chrome.tabs.discard for an incognito tab when the extension doesn't
+// have incognito access.
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, DiscardIncognitoWithoutPermission) {
+ // Create an extra normal tab so we have more to discard.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Create an incognito browser with several tabs.
+ BrowserWindowInterface* incognito_browser = CreateIncognitoBrowserWindow();
+ TabListInterface* incognito_tab_list =
+ TabListInterface::From(incognito_browser);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ content::WebContents* incognito_web_contents =
+ incognito_tab_list->GetTab(1)->GetContents();
+ content::WaitForLoadStop(incognito_web_contents);
+ EXPECT_FALSE(incognito_web_contents->WasDiscarded());
+
+ // Set up the function with an extension that does not have incognito access,
+ // but does have the "tabs" permission.
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test").AddAPIPermission("tabs").Build();
+ auto discard = base::MakeRefCounted<TabsDiscardFunction>();
+ discard->set_extension(extension.get());
+
+ int tab_id = ExtensionTabUtil::GetTabId(incognito_web_contents);
+
+ // First try discarding the incognito tab based on the tab ID. The tab should
+ // not be discarded and we should get an error.
+ std::string error = utils::RunFunctionAndReturnError(
+ discard.get(), base::StringPrintf("[%u]", tab_id), profile());
+ EXPECT_FALSE(incognito_web_contents->WasDiscarded());
+ EXPECT_TRUE(base::MatchPattern(error, ExtensionTabUtil::kTabNotFoundError));
+
+ // Now run without passing an id. The extension only has access to the normal
+ // tabs, so only the normal tabs should be discardable.
+ int normal_tab_count = GetTabListInterface()->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(normal_tab_count, 0);
+
+ std::vector<base::DictValue> results;
+ for (int i = 0; i < normal_tab_count; ++i) {
+ auto discard_no_id = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_no_id->set_extension(extension.get());
+ std::optional<base::Value> result_value =
+ utils::RunFunctionAndReturnSingleResult(discard_no_id.get(), "[]",
+ profile());
+ if (result_value) {
+ results.push_back(utils::ToDict(std::move(*result_value)));
+ }
+ }
+
+ // We should have discarded all normal tabs.
+ EXPECT_EQ(static_cast<size_t>(normal_tab_count), results.size());
+ for (const auto& result : results) {
+ // Check that the returned tab does not have sensitive incognito
+ // information. It must be a normal tab. Since the extension has "tabs"
+ // permission, it should include url and title for the normal tab.
+ EXPECT_FALSE(api_test_utils::GetBoolean(result, "incognito"));
+ EXPECT_TRUE(result.contains("url"));
+ EXPECT_TRUE(result.contains("title"));
+ }
+
+ // The next attempt to discard without an ID should fail.
+ auto discard_fail = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_fail->set_extension(extension.get());
+ std::string fail_error =
+ utils::RunFunctionAndReturnError(discard_fail.get(), "[]", profile());
+ EXPECT_EQ("Cannot find a tab to discard.", fail_error);
+}
+
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, DiscardIncognitoSplitMode) {
+ // Create an extra normal tab so we have more to discard.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Create an incognito browser with several tabs.
+ BrowserWindowInterface* incognito_browser = CreateIncognitoBrowserWindow();
+ TabListInterface* incognito_tab_list =
+ TabListInterface::From(incognito_browser);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Set up the extension with incognito: split and tabs permission.
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test")
+ .SetManifestKey("incognito", "split")
+ .AddAPIPermission("tabs")
+ .Build();
+ // Grant incognito access.
+ ExtensionPrefs::Get(profile())->SetIsIncognitoEnabled(extension->id(), true);
+
+ // 1. Test Regular Profile Context
+ // The regular profile context only sees normal tabs, all of which should be
+ // discardable.
+ int normal_tab_count = GetTabListInterface()->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(normal_tab_count, 0);
+
+ std::vector<base::DictValue> regular_results;
+ for (int i = 0; i < normal_tab_count; ++i) {
+ auto discard = base::MakeRefCounted<TabsDiscardFunction>();
+ discard->set_extension(extension.get());
+ std::optional<base::Value> result_value =
+ utils::RunFunctionAndReturnSingleResult(discard.get(), "[]", profile());
+ if (result_value) {
+ regular_results.push_back(utils::ToDict(std::move(*result_value)));
+ }
+ }
+
+ EXPECT_EQ(static_cast<size_t>(normal_tab_count), regular_results.size());
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 33bfb4da..30222c9 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -67,6 +67,7 @@
#include "extensions/browser/api_test_utils.h"
#include "extensions/browser/event_router.h"
#include "extensions/browser/extension_function_dispatcher.h"
+#include "extensions/browser/extension_prefs.h"
#include "extensions/browser/test_event_router_observer.h"
#include "extensions/common/constants.h"
#include "extensions/common/error_utils.h"
@@ -2078,6 +2079,251 @@
EXPECT_TRUE(base::MatchPattern(error, ExtensionTabUtil::kTabNotFoundError));
}
+// Tests chrome.tabs.discard for an incognito tab when the extension doesn't
+// have incognito access.
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, DiscardIncognitoWithoutPermission) {
+ // Create an extra normal tab so we have more to discard.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Create an incognito browser with several tabs.
+ BrowserWindowInterface* incognito_browser = CreateIncognitoBrowserWindow();
+ TabListInterface* incognito_tab_list =
+ TabListInterface::From(incognito_browser);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ content::WebContents* incognito_web_contents =
+ incognito_tab_list->GetTab(1)->GetContents();
+ content::WaitForLoadStop(incognito_web_contents);
+ EXPECT_FALSE(incognito_web_contents->WasDiscarded());
+
+ // Set up the function with an extension that does not have incognito access,
+ // but does have the "tabs" permission.
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test").AddAPIPermission("tabs").Build();
+ auto discard = base::MakeRefCounted<TabsDiscardFunction>();
+ discard->set_extension(extension.get());
+
+ int tab_id = ExtensionTabUtil::GetTabId(incognito_web_contents);
+
+ // First try discarding the incognito tab based on the tab ID. The tab should
+ // not be discarded and we should get an error.
+ std::string error = utils::RunFunctionAndReturnError(
+ discard.get(), base::StringPrintf("[%u]", tab_id), profile());
+ EXPECT_FALSE(incognito_web_contents->WasDiscarded());
+ EXPECT_TRUE(base::MatchPattern(error, ExtensionTabUtil::kTabNotFoundError));
+
+ // Now run without passing an id. The extension only has access to the normal
+ // tabs, so only the normal tabs should be discardable.
+ int normal_tab_count = GetTabListInterface()->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(normal_tab_count, 0);
+
+ std::vector<base::DictValue> results;
+ for (int i = 0; i < normal_tab_count; ++i) {
+ auto discard_no_id = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_no_id->set_extension(extension.get());
+ std::optional<base::Value> result_value =
+ utils::RunFunctionAndReturnSingleResult(discard_no_id.get(), "[]",
+ profile());
+ if (result_value) {
+ results.push_back(utils::ToDict(std::move(*result_value)));
+ }
+ }
+
+ // We should have discarded all normal tabs.
+ EXPECT_EQ(static_cast<size_t>(normal_tab_count), results.size());
+ for (const auto& result : results) {
+ // Check that the returned tab does not have sensitive incognito
+ // information. It must be a normal tab. Since the extension has "tabs"
+ // permission, it should include url and title for the normal tab.
+ EXPECT_FALSE(api_test_utils::GetBoolean(result, "incognito"));
+ EXPECT_TRUE(result.contains("url"));
+ EXPECT_TRUE(result.contains("title"));
+ }
+
+ // The next attempt to discard without an ID should fail.
+ auto discard_fail = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_fail->set_extension(extension.get());
+ std::string fail_error =
+ utils::RunFunctionAndReturnError(discard_fail.get(), "[]", profile());
+ EXPECT_EQ("Cannot find a tab to discard.", fail_error);
+}
+
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, DiscardIncognitoSplitMode) {
+ // Create an extra normal tab so we have more to discard.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Create an incognito browser with several tabs.
+ BrowserWindowInterface* incognito_browser = CreateIncognitoBrowserWindow();
+ TabListInterface* incognito_tab_list =
+ TabListInterface::From(incognito_browser);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Set up the extension with incognito: split and tabs permission.
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test")
+ .SetManifestKey("incognito", "split")
+ .AddAPIPermission("tabs")
+ .Build();
+ // Grant incognito access.
+ ExtensionPrefs::Get(profile())->SetIsIncognitoEnabled(extension->id(), true);
+
+ // 1. Test Regular Profile Context
+ // The regular profile context only sees normal tabs, all of which should be
+ // discardable.
+ int normal_tab_count = GetTabListInterface()->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(normal_tab_count, 0);
+
+ std::vector<base::DictValue> regular_results;
+ for (int i = 0; i < normal_tab_count; ++i) {
+ auto discard = base::MakeRefCounted<TabsDiscardFunction>();
+ discard->set_extension(extension.get());
+ std::optional<base::Value> result_value =
+ utils::RunFunctionAndReturnSingleResult(discard.get(), "[]", profile());
+ if (result_value) {
+ regular_results.push_back(utils::ToDict(std::move(*result_value)));
+ }
+ }
+
+ EXPECT_EQ(static_cast<size_t>(normal_tab_count), regular_results.size());
+ for (const auto& result : regular_results) {
+ // Regular context should only see normal tabs.
+ EXPECT_FALSE(api_test_utils::GetBoolean(result, "incognito"));
+ EXPECT_TRUE(result.contains("url"));
+ }
+
+ // The next attempt from the regular context should fail.
+ auto discard_regular_fail = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_regular_fail->set_extension(extension.get());
+ std::string regular_fail_error = utils::RunFunctionAndReturnError(
+ discard_regular_fail.get(), "[]", profile());
+ EXPECT_EQ("Cannot find a tab to discard.", regular_fail_error);
+
+ // 2. Test Incognito Profile Context
+ // The incognito context only sees incognito tabs. We created several tabs in
+ // the incognito browser, all of which should be discardable.
+ int incognito_tab_count = incognito_tab_list->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(incognito_tab_count, 0);
+
+ // Add a few more regular tabs, as all the previous ones are already
+ // discarded.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ Profile* incognito_profile = incognito_browser->GetProfile();
+ std::vector<base::DictValue> incognito_results;
+ for (int i = 0; i < incognito_tab_count; ++i) {
+ auto discard_incognito = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_incognito->set_extension(extension.get());
+ std::optional<base::Value> incognito_result_value =
+ utils::RunFunctionAndReturnSingleResult(
+ discard_incognito.get(), "[]", incognito_profile,
+ api_test_utils::FunctionMode::kIncognito);
+ if (incognito_result_value) {
+ incognito_results.push_back(
+ utils::ToDict(std::move(*incognito_result_value)));
+ }
+ }
+
+ EXPECT_EQ(static_cast<size_t>(incognito_tab_count), incognito_results.size());
+ for (const auto& result : incognito_results) {
+ // Incognito split context should only see incognito tabs.
+ EXPECT_TRUE(api_test_utils::GetBoolean(result, "incognito"));
+ EXPECT_TRUE(result.contains("url"));
+ }
+
+ // The next attempt from the incognito context should fail.
+ auto discard_incognito_fail = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_incognito_fail->set_extension(extension.get());
+ std::string incognito_fail_error = utils::RunFunctionAndReturnError(
+ discard_incognito_fail.get(), "[]", incognito_profile,
+ api_test_utils::FunctionMode::kIncognito);
+ EXPECT_EQ("Cannot find a tab to discard.", incognito_fail_error);
+}
+
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, DiscardIncognitoSpanningMode) {
+ // Create an extra normal tab so we have more to discard.
+ GetTabListInterface()->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Create an incognito browser with several tabs.
+ BrowserWindowInterface* incognito_browser = CreateIncognitoBrowserWindow();
+ TabListInterface* incognito_tab_list =
+ TabListInterface::From(incognito_browser);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+ incognito_tab_list->OpenTab(GURL(url::kAboutBlankURL), -1);
+
+ // Set up the extension with incognito: spanning and tabs permission.
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Test")
+ .SetManifestKey("incognito", "spanning")
+ .AddAPIPermission("tabs")
+ .Build();
+ // Grant incognito access.
+ ExtensionPrefs::Get(profile())->SetIsIncognitoEnabled(extension->id(), true);
+
+ // Test Regular Profile Context
+ // In spanning mode, the extension shares a single process for both normal and
+ // incognito contexts. All tabs across both windows are discardable.
+ // We determine the number of tabs dynamically rather than using a hardcoded
+ // value because some platforms (like Android) may create an extra initial
+ // tab in some window creation scenarios.
+ int total_tab_count =
+ GetTabListInterface()->GetTabCount() + incognito_tab_list->GetTabCount();
+ // Note: To avoid having to deal with any platform differences between default
+ // tabs when creating a browser changing the total count, we just validate we
+ // have more than 0 tabs, so we know the loop below actually does something.
+ EXPECT_GT(total_tab_count, 0);
+
+ std::vector<base::DictValue> results;
+ bool saw_incognito = false;
+ bool saw_normal = false;
+ for (int i = 0; i < total_tab_count; ++i) {
+ auto discard = base::MakeRefCounted<TabsDiscardFunction>();
+ discard->set_extension(extension.get());
+ std::optional<base::Value> result_value =
+ utils::RunFunctionAndReturnSingleResult(
+ discard.get(), "[]", profile(),
+ api_test_utils::FunctionMode::kIncognito);
+ if (result_value) {
+ base::DictValue dict = utils::ToDict(std::move(*result_value));
+ if (api_test_utils::GetBoolean(dict, "incognito")) {
+ saw_incognito = true;
+ } else {
+ saw_normal = true;
+ }
+ results.push_back(std::move(dict));
+ }
+ }
+
+ EXPECT_EQ(static_cast<size_t>(total_tab_count), results.size());
+ // A spanning extension should receive info about both normal and incognito
+ // tabs and they should contain URL info since it has tabs permission.
+ EXPECT_TRUE(saw_incognito);
+ EXPECT_TRUE(saw_normal);
+ for (const auto& result : results) {
+ EXPECT_TRUE(result.contains("url"));
+ }
+
+ // The next attempt should fail.
+ auto discard_fail = base::MakeRefCounted<TabsDiscardFunction>();
+ discard_fail->set_extension(extension.get());
+ std::string fail_error = utils::RunFunctionAndReturnError(
+ discard_fail.get(), "[]", profile(),
+ api_test_utils::FunctionMode::kIncognito);
+ EXPECT_EQ("Cannot find a tab to discard.", fail_error);
+}
+
// TODO(crbug.com/487907630): Flaky on macos
#if BUILDFLAG(IS_MAC)
#define MAYBE_DiscardWithoutId DISABLED_DiscardWithoutId
diff --git a/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc b/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc
index 52050f8..0e7d3b59 100644
--- a/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc
+++ b/chrome/browser/performance_manager/policies/page_discarding_helper_browsertest.cc
@@ -49,6 +49,7 @@
#include "content/public/test/test_navigation_observer.h"
#include "content/public/test/test_utils.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
+#include "third_party/abseil-cpp/absl/container/flat_hash_set.h"
#include "ui/base/page_transition_types.h"
#include "ui/base/window_open_disposition.h"
#include "url/gurl.h"
@@ -154,12 +155,16 @@
EXPECT_EQ(num_tabs, tab_strip_model->count());
}
-// Creates a browser with `num_tabs` tabs.
-BrowserWindowInterface* CreateBrowserWithTabs(int num_tabs) {
+BrowserWindowInterface* CreateBrowserWithTabsImpl(int num_tabs,
+ bool incognito) {
BrowserWindowInterface* const current_browser =
GetLastActiveBrowserWindowInterfaceWithAnyProfile();
ui_test_utils::BrowserCreatedObserver browser_created_observer;
- chrome::NewWindow(current_browser);
+ if (incognito) {
+ chrome::NewIncognitoWindow(current_browser->GetProfile());
+ } else {
+ chrome::NewWindow(current_browser);
+ }
ui_test_utils::WaitForBrowserSetLastActive(browser_created_observer.Wait());
BrowserWindowInterface* const new_browser =
GetLastActiveBrowserWindowInterfaceWithAnyProfile();
@@ -169,6 +174,16 @@
return new_browser;
}
... (truncated)
Original Bug Report
Information Leak and Cross-Profile Tab Manipulation via chrome.tabs.discard()
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 flaw in the chrome.tabs.discard() API allows an extension to discard tabs and retrieve sensitive metadata (URLs, titles) from other browser profiles or Incognito windows. This bypasses the ‘Allow in Incognito’ setting and the fundamental security boundary between Chrome profiles.
Affected files:
chrome/browser/extensions/api/tabs/tabs_api.ccchrome/browser/performance_manager/policies/page_discarding_helper.ccchrome/browser/resource_coordinator/utils.ccchrome/browser/extensions/extension_tab_util.ccchrome/browser/performance_manager/policies/discard_eligibility_policy.cc
Estimated timestamp from git blame: 2026-02-23
Description
A potential vulnerability has been identified in the chrome.tabs.discard() extension API implementation. When the API is called without a tabId parameter, the selection of the tab to be discarded is delegated to the global PerformanceManager graph. This graph tracks every active tab across all browser profiles and Incognito windows within the browser process.
The current implementation fails to restrict this selection to tabs belonging to the calling extension’s BrowserContext. As a result, an extension running in one profile can trigger the discard of a tab in a different profile or an Incognito window. Furthermore, the API returns a tabs.Tab object constructed from the selected WebContents without verifying whether the extension is permitted to access that tab’s profile, leading to the disclosure of sensitive information such as the tab’s URL and title.
Potential Root Cause
- In
chrome/browser/extensions/api/tabs/tabs_api.cc: TheTabsDiscardFunction::Run()method (line 4040) handles the no-argument case by callingresource_coordinator::DiscardLeastImportantTab. This call lacks any mechanism to filter candidates by profile. - In
chrome/browser/performance_manager/policies/page_discarding_helper.cc: TheDiscardMultiplePagesImplmethod (line 161) iterates through all availablePageNodeobjects globally viaGetOwningGraph()->GetAllPageNodes(). While it evaluates discard eligibility based on tab state (e.g., background vs. visible), it does not filter byBrowserContextID. - In
chrome/browser/extensions/extension_tab_util.cc: TheGetScrubTabBehaviorandCreateTabObjectfunctions populate the returnedTabobject. While they verify the extension’s manifest permissions (e.g., thetabspermission), they omit checks for whether the extension is permitted to access the specificBrowserContextof the discarded tab, especially regarding cross-profile or unauthorized Incognito access.
Suggested Reproduction Steps (Potential)
- Open two Chrome profiles (e.g., Profile A and Profile B) or a regular window and an Incognito window.
- In Profile B (or Incognito), navigate to a sensitive URL (e.g.,
https://example.com/private_data) and switch to another tab so the sensitive tab remains in the background. - In Profile A, install an extension with the
tabspermission and ensure ‘Allow in Incognito’ is disabled. - Execute
chrome.tabs.discard()from the extension in Profile A. - Observe if the background tab in Profile B (or Incognito) is discarded.
- Verify if the console output of the extension in Profile A contains a
tabs.Tabobject for the foreign tab, including its URL and title.
Impact
- Information Disclosure: A malicious extension can obtain sensitive information (URLs and titles) from tabs in other profiles or Incognito windows, bypassing the ‘Allow in Incognito’ security boundary.
- Cross-Profile Action: An extension can forcibly discard tabs in other profiles, potentially leading to the loss of unsaved form state, scroll position, and transient JavaScript state in those tabs.
Suggested Fix
Modify TabsDiscardFunction::Run to ensure that any automatically selected tab is accessible by the calling extension. This could be achieved by passing the calling BrowserContext to the discard selection logic to filter candidates, or by validating the resulting WebContents profile in TabsDiscardFunction::Run. If the selected tab is not accessible, the API should either fail, select a different tab, or return a result that does not disclose sensitive information.
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.