CVE-2026-113185
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
IN_PROC_BROWSER_TEST_Fchrome/browser/extensions/api/tabs/tabs_test.cc |
modified | |
TESTchrome/browser/extensions/extension_tab_util_unittest.cc |
modified |
Files Changed
chrome/browser/extensions/api/tabs/tabs_test.ccchrome/browser/extensions/extension_tab_util.ccextensions/common/permissions/permissions_data.hchrome/browser/extensions/extension_tab_util_unittest.ccchrome/browser/extensions/user_host_restrictions_browsertest.ccextensions/common/permissions/permissions_data.ccchrome/common/extensions/permissions/permissions_data_unittest.cc
Patch
From 467689a0d8792f96dc5e0e333f5493529e36a4d4 Mon Sep 17 00:00:00 2001 From: Eva Su <[email protected]> Date: Thu, 17 Sep 2026 20:24:24 -0700 Subject: [PATCH] [Extensions] Use tab scrub behavior in TabsQueryFunction tab matching Previously, extensions were able to bypass enterprise policy and user-defined host-blocking restrictions to access sensitive tab metadata, including full URLs and page titles because GetScrubTabBehaviorImpl validates host permissions with a pattern match against the active permission set without consulting the policy-blocked or user-blocked host overlays. Additionally, query filtering logic fails to consult user restricted hosts. This CL updates TabsQueryFunction::MatchesTab to consult ExtensionTabUtil::GetScrubTabBehavior when evaluating URL and title query criteria, replacing ad-hoc permission checks. This aligns tab query matching with standard tab scrubbing logic, ensuring policy-blocked and user-restricted hosts are consistently respected when filtering tabs. Fixed: 517461056 Change-Id: I2ee562a94713ad4ec3ee2556abb094f1409daed5 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8393360 Reviewed-by: Kelvin Jiang <[email protected]> Commit-Queue: Eva Su <[email protected]> Cr-Commit-Position: refs/heads/main@{#1700417} --- diff --git a/chrome/browser/extensions/extension_tab_util.cc b/chrome/browser/extensions/extension_tab_util.cc index 71c4c9d..d218d288 100644 --- a/chrome/browser/extensions/extension_tab_util.cc +++ b/chrome/browser/extensions/extension_tab_util.cc @@ -175,8 +175,7 @@ // Tab-specific permission (e.g. activeTab) allowed, and the origin // matches. has_permission = true; - } else if (permissions->active_permissions().HasExplicitAccessToOrigin( - url)) { + } else if (permissions->HasHostPermission(url)) { // Explicit host permission allows access. has_permission = true; } diff --git a/extensions/common/permissions/permissions_data.cc b/extensions/common/permissions/permissions_data.cc index 6f5f682..8a8b267d 100644 --- a/extensions/common/permissions/permissions_data.cc +++ b/extensions/common/permissions/permissions_data.cc @@ -391,7 +391,7 @@ bool PermissionsData::HasHostPermission(const GURL& url) const { base::AutoLock auto_lock(runtime_lock_); return active_permissions_unsafe_->HasExplicitAccessToOrigin(url) && - !IsPolicyBlockedHostUnsafe(url); + !IsPolicyBlockedHostUnsafe(url) && !IsUrlBlockedByUser(url); } PermissionMessages PermissionsData::GetPermissionMessages() const { diff --git a/extensions/common/permissions/permissions_data.h b/extensions/common/permissions/permissions_data.h index 190c39f..8c3d5d0b 100644 --- a/extensions/common/permissions/permissions_data.h +++ b/extensions/common/permissions/permissions_data.h @@ -177,11 +177,8 @@ // active tab permissions for. URLPatternSet GetEffectiveHostPermissions() const; - // TODO(rdevlin.cronin): HasHostPermission() is just a forward for the active - // permissions. We should either get rid of it, and have callers use - // active_permissions(), or should get rid of active_permissions(), and make - // callers use PermissionsData for everything. We should not do both. - // Whether the extension has access to the given `url`. + // Returns true if the extension has active host permission to access the + // given `url`, taking into account policy and user host restrictions. bool HasHostPermission(const GURL& url) const; // Returns the full list of permission details for messages that should diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc index ce3bafca..be48a02 100644 --- a/chrome/browser/extensions/api/tabs/tabs_test.cc +++ b/chrome/browser/extensions/api/tabs/tabs_test.cc @@ -85,6 +85,7 @@ #include "extensions/common/extension_builder.h" #include "extensions/common/manifest_constants.h" #include "extensions/common/mojom/context_type.mojom.h" +#include "extensions/common/permissions/permissions_data.h" #include "extensions/test/extension_test_message_listener.h" #include "extensions/test/result_catcher.h" #include "extensions/test/test_extension_dir.h" @@ -4530,6 +4531,91 @@ } } +IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, QueryWithPolicyBlockedHost) { + ASSERT_TRUE(embedded_test_server()->Start()); + auto tab_urls = std::to_array<GURL>({ + embedded_test_server()->GetURL("www.google.com", "/empty.html"), + embedded_test_server()->GetURL("www.example.com", "/empty.html"), + }); + auto tab_titles = + std::to_array<std::string>({"Sample title", "Sample title"}); + + std::array<content::WebContents*, std::size(tab_urls)> web_contentses; + for (size_t i = 0; i < std::size(tab_urls); ++i) { + tabs::TabInterface* tab = GetTabListInterface()->OpenTab(tab_urls[i], -1); + content::WebContents* raw_web_contents = tab->GetContents(); + web_contentses[i] = raw_web_contents; + content::WaitForLoadStop(raw_web_contents); + raw_web_contents->GetController().GetVisibleEntry()->SetTitle( + base::ASCIIToUTF16(tab_titles[i])); + } + + scoped_refptr<const Extension> extension = + ExtensionBuilder() + .SetManifest(base::DictValue() + .Set("name", "Extension with host permissions") + .Set("version", "1.0") + .Set("manifest_version", 3) + .Set("host_permissions", + base::ListValue() + .Append("*://www.google.com/*") + .Append("*://www.example.com/*"))) + .Build(); + + URLPatternSet policy_blocked_hosts; + policy_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes, + GURL("http://www.google.com")); + extension->permissions_data()->SetPolicyHostRestrictions(policy_blocked_hosts, + URLPatternSet()); + + // Query for www.google.com tabs should return 0 tabs due to policy + // restriction. + const char* kBlockedURLQueryInfo = "[{\"url\": \"*://www.google.com/*\"}]"; + base::ListValue blocked_tabs = + RunQueryFunction(extension.get(), kBlockedURLQueryInfo); + EXPECT_EQ(0u, blocked_tabs.size()); + + // Query with title on blocked host should not match. + const char* kBlockedTitleQueryInfo = + "[{\"title\": \"Sample title\", \"url\": \"*://www.google.com/*\"}]"; + base::ListValue blocked_title_tabs = + RunQueryFunction(extension.get(), kBlockedTitleQueryInfo); + EXPECT_EQ(0u, blocked_title_tabs.size()); + + // Query for unblocked www.example.com should match. + const char* kAllowedURLQueryInfo = "[{\"url\": \"*://www.example.com/*\"}]"; + base::ListValue allowed_tabs = + RunQueryFunction(extension.get(), kAllowedURLQueryInfo); + EXPECT_EQ(1u, allowed_tabs.size()); + if (!allowed_tabs.empty()) { + EXPECT_TRUE(allowed_tabs[0].is_dict()); + EXPECT_EQ(ExtensionTabUtil::GetTabId(web_contentses[1]), + allowed_tabs[0].GetDict().FindInt("id")); + } +} + +IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, + QueryExtensionOwnOriginWithoutTabsPermission) { + scoped_refptr<const Extension> extension = + ExtensionBuilder("Extension own origin").Build(); + + GURL options_url = extension->GetResourceURL("options.html"); + tabs::TabInterface* tab = GetTabListInterface()->OpenTab(options_url, -1); + content::WebContents* raw_web_contents = tab->GetContents(); + content::WaitForLoadStop(raw_web_contents); + + std::string query_info = base::StringPrintf( + "[{\"url\": \"chrome-extension://%s/*\"}]", extension->id().c_str()); + base::ListValue tabs_list = + RunQueryFunction(extension.get(), query_info.c_str()); + EXPECT_EQ(1u, tabs_list.size()); + if (!tabs_list.empty()) { + EXPECT_TRUE(tabs_list[0].is_dict()); + EXPECT_EQ(ExtensionTabUtil::GetTabId(raw_web_contents), + tabs_list[0].GetDict().FindInt("id")); + } +} + #if BUILDFLAG(ENABLE_PDF) // Test that using the PDF extension for tab updates is treated as a // renderer-initiated navigation. crbug.com/40085816 diff --git a/chrome/browser/extensions/extension_tab_util_unittest.cc b/chrome/browser/extensions/extension_tab_util_unittest.cc index 2213bfc..c895322 100644 --- a/chrome/browser/extensions/extension_tab_util_unittest.cc +++ b/chrome/browser/extensions/extension_tab_util_unittest.cc @@ -7,6 +7,7 @@ #include "base/json/json_reader.h" #include "base/strings/stringprintf.h" #include "base/test/gmock_expected_support.h" +#include "base/test/scoped_feature_list.h" #include "base/values.h" #include "chrome/browser/extensions/extension_service_test_base.h" #include "chrome/browser/extensions/extension_util.h" @@ -19,6 +20,7 @@ #include "extensions/buildflags/buildflags.h" #include "extensions/common/extension.h" #include "extensions/common/extension_builder.h" +#include "extensions/common/extension_features.h" #include "extensions/common/mojom/context_type.mojom.h" #include "extensions/common/permissions/permission_set.h" #include "extensions/common/permissions/permissions_data.h" @@ -64,6 +66,51 @@ EXPECT_EQ(ExtensionTabUtil::kDontScrubTab, scrub_tab_behavior.pending_info); } +TEST(ExtensionTabUtilTest, ScrubTabBehaviorForPolicyBlockedHost) { + auto extension = ExtensionBuilder("Extension with host permission") + .AddHostPermission("*://www.google.com/*") + .Build(); + URLPatternSet policy_blocked_hosts; + policy_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes, + GURL("http://www.google.com")); + extension->permissions_data()->SetPolicyHostRestrictions(policy_blocked_hosts, + URLPatternSet()); + + ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior = + ExtensionTabUtil::GetScrubTabBehavior( + extension.get(), mojom::ContextType::kUnspecified, + GURL("http://www.google.com/some/path")); + EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, + scrub_tab_behavior.committed_info); + EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, scrub_tab_behavior.pending_info); +} + +TEST(ExtensionTabUtilTest, ScrubTabBehaviorForUserBlockedHost) { + base::test::ScopedFeatureList feature_list; + feature_list.InitAndEnableFeature( + extensions_features::kExtensionsMenuAccessControl); + + constexpr int kContextId = 1; + auto extension = ExtensionBuilder("Extension with host permission") + .AddHostPermission("*://www.google.com/*") + .Build(); + extension->permissions_data()->SetContextId(kContextId); + + URLPatternSet user_blocked_hosts; + user_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes, + GURL("http://www.google.com")); + PermissionsData::SetUserHostRestrictions( + kContextId, std::move(user_blocked_hosts), URLPatternSet()); + + ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior = + ExtensionTabUtil::GetScrubTabBehavior( + extension.get(), mojom::ContextType::kUnspecified, + GURL("http://www.google.com/some/path")); + EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, + scrub_tab_behavior.committed_info); + EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, scrub_tab_behavior.pending_info); +} + TEST(ExtensionTabUtilTest, ScrubTabBehaviorForExtensionOwnOrigin_GURL) { auto extension = ExtensionBuilder("Extension with activeTab permission") .AddAPIPermission("activeTab") diff --git a/chrome/browser/extensions/user_host_restrictions_browsertest.cc b/chrome/browser/extensions/user_host_restrictions_browsertest.cc index 50b055d..cd35e7aa 100644 --- a/chrome/browser/extensions/user_host_restrictions_browsertest.cc +++ b/chrome/browser/extensions/user_host_restrictions_browsertest.cc @@ -22,6 +22,7 @@ #include "extensions/test/result_catcher.h" #include "extensions/test/test_extension_dir.h" #include "net/dns/mock_host_resolver.h" +#include "testing/gmock/include/gmock/gmock.h" #include "testing/gtest/include/gtest/gtest.h" static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE)); @@ -143,6 +144,103 @@ } } +// Tests that tab metadata (such as URL and title) is scrubbed and cannot be +// queried on user-restricted sites when the extension only has host +// permissions. +IN_PROC_BROWSER_TEST_P(UserHostRestrictionsBrowserTest, + TabMetadataOnUserRestrictedSites) { + ASSERT_TRUE(StartEmbeddedTestServer()); + + static constexpr char kManifest[] = + R"({ + "name": "Test Extension", + "version": "0.1", + "manifest_version": 3, + "host_permissions": ["<all_urls>"], + "background": {"service_worker": "background.js"} + })"; + + static constexpr char kBackground[] = + R"(async function queryTabs(queryInfo) { + let tabs = await chrome.tabs.query(queryInfo); + chrome.test.sendScriptResult(tabs); + } + async function getTab(tabId) { + let tab = await chrome.tabs.get(tabId); + chrome.test.sendScriptResult(tab); + })"; + + TestExtensionDir test_dir; + test_dir.WriteManifest(kManifest); + test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackground); + const Extension* extension = LoadExtension(test_dir.UnpackedPath()); + ASSERT_TRUE(extension); + + auto get_tab = [this, extension](int tab_id) { + base::Value result = BackgroundScriptExecutor::ExecuteScript( + profile(), extension->id(), base::StringPrintf("getTab(%d)", tab_id), + BackgroundScriptExecutor::ResultCapture::kSendScriptResult); + return result.is_dict() ? std::move(result).TakeDict() : base::DictValue(); + }; + + auto query_tabs = [this, extension](const std::string& query_json) { + base::Value result = BackgroundScriptExecutor::ExecuteScript( + profile(), extension->id(), + base::StringPrintf("queryTabs(%s)", query_json.c_str()), + BackgroundScriptExecutor::ResultCapture::kSendScriptResult); + return result.is_list() ? std::move(result).TakeList() : base::ListValue(); + }; + + const GURL allowed_url = + embedded_test_server()->GetURL("allowed.example", "/title2.html"); + const GURL restricted_url = + embedded_test_server()->GetURL("restricted.example", "/title2.html"); + + PermissionsManager* permissions_manager = PermissionsManager::Get(profile()); + permissions_manager->AddUserRestrictedSite( + url::Origin::Create(restricted_url)); + + auto* web_contents = GetActiveWebContents(); + ASSERT_TRUE(NavigateToURL(web_contents, allowed_url)); + + // On allowed sites, the extension has host access, so the URL and title + // should not be scrubbed. + base::DictValue allowed_tab = get_tab(GetActiveTabId()); + EXPECT_THAT(allowed_tab.FindString("url"), + testing::Pointee(testing::Eq(allowed_url.spec()))); + EXPECT_THAT(allowed_tab.FindString("title"), + testing::Pointee(testing::Eq("Title Of Awesomeness"))); + + base::ListValue allowed_query = + query_tabs("{\"url\": \"*://allowed.example/*\"}"); + EXPECT_EQ(1u, allowed_query.size()); + + ASSERT_TRUE(NavigateToURL(web_contents, restricted_url)); + + base::DictValue restricted_tab = get_tab(GetActiveTabId()); + base::ListValue restricted_query_url = + query_tabs("{\"url\": \"*://restricted.example/*\"}"); + base::ListValue restricted_query_title = + query_tabs("{\"title\": \"Title Of Awesomeness\"}"); + + if (GetParam()) { + // If user host restrictions are enabled, tab metadata should be scrubbed + // and queries matching the URL or title should not match the tab. + EXPECT_EQ(nullptr, restricted_tab.FindString("url")); + EXPECT_EQ(nullptr, restricted_tab.FindString("title")); + EXPECT_EQ(0u, restricted_query_url.size()); + EXPECT_EQ(0u, restricted_query_title.size()); + } else { + // If user host restrictions are disabled, the extension retains access. + EXPECT_THAT(restricted_tab.FindString("url"), + testing::Pointee(testing::Eq(restricted_url.spec()))); + EXPECT_THAT(restricted_tab.FindString("title"), + testing::Pointee(testing::Eq("Title Of Awesomeness"))); + EXPECT_EQ(1u, restricted_query_url.size()); + EXPECT_EQ(1u, restricted_query_title.size()); + } +} + // Tests that extensions cannot run on user-restricted sites. This specifically // checks renderer-side permissions restrictions (with content scripts). IN_PROC_BROWSER_TEST_P(UserHostRestrictionsBrowserTest, diff --git a/chrome/common/extensions/permissions/permissions_data_unittest.cc b/chrome/common/extensions/permissions/permissions_data_unittest.cc index 426fd1a..149d1c8c 100644 --- a/chrome/common/extensions/permissions/permissions_data_unittest.cc +++ b/chrome/common/extensions/permissions/permissions_data_unittest.cc @@ -1335,6 +1335,44 @@ EXPECT_EQ(ALLOWED_SCRIPT_ONLY, GetExtensionAccess(component.get(), url)); } +// Tests that HasHostPermission respects user host restrictions. +TEST_F(ExtensionScriptAndCaptureVisibleTest, + UserHostRestrictions_HasHostPermission) { + base::test::ScopedFeatureList feature_list; + feature_list.InitAndEnableFeature( + extensions_features::kExtensionsMenuAccessControl); + + constexpr int kContextId = 0; + scoped_refptr<const Extension> extension = GetExtensionWithHostPermission( + "test_extension", kAllHostsPermission, ManifestLocation::kInternal); + extension->permissions_data()->SetContextId(kContextId); + + const GURL url("https://foo.example"); + + EXPECT_TRUE(extension->permissions_data()->HasHostPermission(url)); + + { + URLPatternSet blocked_urls; + blocked_urls.AddOrigin(Extension::kValidHostPermissionSchemes, url); + PermissionsData::SetUserHostRestrictions( + kContextId, std::move(blocked_urls), URLPatternSet()); + } + + EXPECT_FALSE(extension->permissions_data()->HasHostPermission(url)); + + { + URLPatternSet blocked_urls; ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index ce3bafca..be48a02 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -85,6 +85,7 @@
#include "extensions/common/extension_builder.h"
#include "extensions/common/manifest_constants.h"
#include "extensions/common/mojom/context_type.mojom.h"
+#include "extensions/common/permissions/permissions_data.h"
#include "extensions/test/extension_test_message_listener.h"
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
@@ -4530,6 +4531,91 @@
}
}
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest, QueryWithPolicyBlockedHost) {
+ ASSERT_TRUE(embedded_test_server()->Start());
+ auto tab_urls = std::to_array<GURL>({
+ embedded_test_server()->GetURL("www.google.com", "/empty.html"),
+ embedded_test_server()->GetURL("www.example.com", "/empty.html"),
+ });
+ auto tab_titles =
+ std::to_array<std::string>({"Sample title", "Sample title"});
+
+ std::array<content::WebContents*, std::size(tab_urls)> web_contentses;
+ for (size_t i = 0; i < std::size(tab_urls); ++i) {
+ tabs::TabInterface* tab = GetTabListInterface()->OpenTab(tab_urls[i], -1);
+ content::WebContents* raw_web_contents = tab->GetContents();
+ web_contentses[i] = raw_web_contents;
+ content::WaitForLoadStop(raw_web_contents);
+ raw_web_contents->GetController().GetVisibleEntry()->SetTitle(
+ base::ASCIIToUTF16(tab_titles[i]));
+ }
+
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder()
+ .SetManifest(base::DictValue()
+ .Set("name", "Extension with host permissions")
+ .Set("version", "1.0")
+ .Set("manifest_version", 3)
+ .Set("host_permissions",
+ base::ListValue()
+ .Append("*://www.google.com/*")
+ .Append("*://www.example.com/*")))
+ .Build();
+
+ URLPatternSet policy_blocked_hosts;
+ policy_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes,
+ GURL("http://www.google.com"));
+ extension->permissions_data()->SetPolicyHostRestrictions(policy_blocked_hosts,
+ URLPatternSet());
+
+ // Query for www.google.com tabs should return 0 tabs due to policy
+ // restriction.
+ const char* kBlockedURLQueryInfo = "[{\"url\": \"*://www.google.com/*\"}]";
+ base::ListValue blocked_tabs =
+ RunQueryFunction(extension.get(), kBlockedURLQueryInfo);
+ EXPECT_EQ(0u, blocked_tabs.size());
+
+ // Query with title on blocked host should not match.
+ const char* kBlockedTitleQueryInfo =
+ "[{\"title\": \"Sample title\", \"url\": \"*://www.google.com/*\"}]";
+ base::ListValue blocked_title_tabs =
+ RunQueryFunction(extension.get(), kBlockedTitleQueryInfo);
+ EXPECT_EQ(0u, blocked_title_tabs.size());
+
+ // Query for unblocked www.example.com should match.
+ const char* kAllowedURLQueryInfo = "[{\"url\": \"*://www.example.com/*\"}]";
+ base::ListValue allowed_tabs =
+ RunQueryFunction(extension.get(), kAllowedURLQueryInfo);
+ EXPECT_EQ(1u, allowed_tabs.size());
+ if (!allowed_tabs.empty()) {
+ EXPECT_TRUE(allowed_tabs[0].is_dict());
+ EXPECT_EQ(ExtensionTabUtil::GetTabId(web_contentses[1]),
+ allowed_tabs[0].GetDict().FindInt("id"));
+ }
+}
+
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest,
+ QueryExtensionOwnOriginWithoutTabsPermission) {
+ scoped_refptr<const Extension> extension =
+ ExtensionBuilder("Extension own origin").Build();
+
+ GURL options_url = extension->GetResourceURL("options.html");
+ tabs::TabInterface* tab = GetTabListInterface()->OpenTab(options_url, -1);
+ content::WebContents* raw_web_contents = tab->GetContents();
+ content::WaitForLoadStop(raw_web_contents);
+
+ std::string query_info = base::StringPrintf(
+ "[{\"url\": \"chrome-extension://%s/*\"}]", extension->id().c_str());
+ base::ListValue tabs_list =
+ RunQueryFunction(extension.get(), query_info.c_str());
+ EXPECT_EQ(1u, tabs_list.size());
+ if (!tabs_list.empty()) {
+ EXPECT_TRUE(tabs_list[0].is_dict());
+ EXPECT_EQ(ExtensionTabUtil::GetTabId(raw_web_contents),
+ tabs_list[0].GetDict().FindInt("id"));
+ }
+}
+
#if BUILDFLAG(ENABLE_PDF)
// Test that using the PDF extension for tab updates is treated as a
// renderer-initiated navigation. crbug.com/40085816
diff --git a/chrome/browser/extensions/extension_tab_util_unittest.cc b/chrome/browser/extensions/extension_tab_util_unittest.cc
index 2213bfc..c895322 100644
--- a/chrome/browser/extensions/extension_tab_util_unittest.cc
+++ b/chrome/browser/extensions/extension_tab_util_unittest.cc
@@ -7,6 +7,7 @@
#include "base/json/json_reader.h"
#include "base/strings/stringprintf.h"
#include "base/test/gmock_expected_support.h"
+#include "base/test/scoped_feature_list.h"
#include "base/values.h"
#include "chrome/browser/extensions/extension_service_test_base.h"
#include "chrome/browser/extensions/extension_util.h"
@@ -19,6 +20,7 @@
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension.h"
#include "extensions/common/extension_builder.h"
+#include "extensions/common/extension_features.h"
#include "extensions/common/mojom/context_type.mojom.h"
#include "extensions/common/permissions/permission_set.h"
#include "extensions/common/permissions/permissions_data.h"
@@ -64,6 +66,51 @@
EXPECT_EQ(ExtensionTabUtil::kDontScrubTab, scrub_tab_behavior.pending_info);
}
+TEST(ExtensionTabUtilTest, ScrubTabBehaviorForPolicyBlockedHost) {
+ auto extension = ExtensionBuilder("Extension with host permission")
+ .AddHostPermission("*://www.google.com/*")
+ .Build();
+ URLPatternSet policy_blocked_hosts;
+ policy_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes,
+ GURL("http://www.google.com"));
+ extension->permissions_data()->SetPolicyHostRestrictions(policy_blocked_hosts,
+ URLPatternSet());
+
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(
+ extension.get(), mojom::ContextType::kUnspecified,
+ GURL("http://www.google.com/some/path"));
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully,
+ scrub_tab_behavior.committed_info);
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, scrub_tab_behavior.pending_info);
+}
+
+TEST(ExtensionTabUtilTest, ScrubTabBehaviorForUserBlockedHost) {
+ base::test::ScopedFeatureList feature_list;
+ feature_list.InitAndEnableFeature(
+ extensions_features::kExtensionsMenuAccessControl);
+
+ constexpr int kContextId = 1;
+ auto extension = ExtensionBuilder("Extension with host permission")
+ .AddHostPermission("*://www.google.com/*")
+ .Build();
+ extension->permissions_data()->SetContextId(kContextId);
+
+ URLPatternSet user_blocked_hosts;
+ user_blocked_hosts.AddOrigin(Extension::kValidHostPermissionSchemes,
+ GURL("http://www.google.com"));
+ PermissionsData::SetUserHostRestrictions(
+ kContextId, std::move(user_blocked_hosts), URLPatternSet());
+
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(
+ extension.get(), mojom::ContextType::kUnspecified,
+ GURL("http://www.google.com/some/path"));
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully,
+ scrub_tab_behavior.committed_info);
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully, scrub_tab_behavior.pending_info);
+}
+
TEST(ExtensionTabUtilTest, ScrubTabBehaviorForExtensionOwnOrigin_GURL) {
auto extension = ExtensionBuilder("Extension with activeTab permission")
.AddAPIPermission("activeTab")
diff --git a/chrome/browser/extensions/user_host_restrictions_browsertest.cc b/chrome/browser/extensions/user_host_restrictions_browsertest.cc
index 50b055d..cd35e7aa 100644
--- a/chrome/browser/extensions/user_host_restrictions_browsertest.cc
+++ b/chrome/browser/extensions/user_host_restrictions_browsertest.cc
@@ -22,6 +22,7 @@
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -143,6 +144,103 @@
}
}
+// Tests that tab metadata (such as URL and title) is scrubbed and cannot be
+// queried on user-restricted sites when the extension only has host
+// permissions.
+IN_PROC_BROWSER_TEST_P(UserHostRestrictionsBrowserTest,
+ TabMetadataOnUserRestrictedSites) {
+ ASSERT_TRUE(StartEmbeddedTestServer());
+
+ static constexpr char kManifest[] =
+ R"({
+ "name": "Test Extension",
+ "version": "0.1",
+ "manifest_version": 3,
+ "host_permissions": ["<all_urls>"],
+ "background": {"service_worker": "background.js"}
+ })";
+
+ static constexpr char kBackground[] =
+ R"(async function queryTabs(queryInfo) {
+ let tabs = await chrome.tabs.query(queryInfo);
+ chrome.test.sendScriptResult(tabs);
+ }
+ async function getTab(tabId) {
+ let tab = await chrome.tabs.get(tabId);
+ chrome.test.sendScriptResult(tab);
+ })";
+
+ TestExtensionDir test_dir;
+ test_dir.WriteManifest(kManifest);
+ test_dir.WriteFile(FILE_PATH_LITERAL("background.js"), kBackground);
+ const Extension* extension = LoadExtension(test_dir.UnpackedPath());
+ ASSERT_TRUE(extension);
+
+ auto get_tab = [this, extension](int tab_id) {
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension->id(), base::StringPrintf("getTab(%d)", tab_id),
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ return result.is_dict() ? std::move(result).TakeDict() : base::DictValue();
+ };
+
+ auto query_tabs = [this, extension](const std::string& query_json) {
+ base::Value result = BackgroundScriptExecutor::ExecuteScript(
+ profile(), extension->id(),
+ base::StringPrintf("queryTabs(%s)", query_json.c_str()),
+ BackgroundScriptExecutor::ResultCapture::kSendScriptResult);
+ return result.is_list() ? std::move(result).TakeList() : base::ListValue();
+ };
+
+ const GURL allowed_url =
+ embedded_test_server()->GetURL("allowed.example", "/title2.html");
+ const GURL restricted_url =
+ embedded_test_server()->GetURL("restricted.example", "/title2.html");
+
+ PermissionsManager* permissions_manager = PermissionsManager::Get(profile());
+ permissions_manager->AddUserRestrictedSite(
+ url::Origin::Create(restricted_url));
+
+ auto* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, allowed_url));
+
+ // On allowed sites, the extension has host access, so the URL and title
+ // should not be scrubbed.
+ base::DictValue allowed_tab = get_tab(GetActiveTabId());
+ EXPECT_THAT(allowed_tab.FindString("url"),
+ testing::Pointee(testing::Eq(allowed_url.spec())));
+ EXPECT_THAT(allowed_tab.FindString("title"),
+ testing::Pointee(testing::Eq("Title Of Awesomeness")));
+
+ base::ListValue allowed_query =
+ query_tabs("{\"url\": \"*://allowed.example/*\"}");
+ EXPECT_EQ(1u, allowed_query.size());
+
+ ASSERT_TRUE(NavigateToURL(web_contents, restricted_url));
+
+ base::DictValue restricted_tab = get_tab(GetActiveTabId());
+ base::ListValue restricted_query_url =
+ query_tabs("{\"url\": \"*://restricted.example/*\"}");
+ base::ListValue restricted_query_title =
+ query_tabs("{\"title\": \"Title Of Awesomeness\"}");
+
+ if (GetParam()) {
+ // If user host restrictions are enabled, tab metadata should be scrubbed
+ // and queries matching the URL or title should not match the tab.
+ EXPECT_EQ(nullptr, restricted_tab.FindString("url"));
+ EXPECT_EQ(nullptr, restricted_tab.FindString("title"));
+ EXPECT_EQ(0u, restricted_query_url.size());
+ EXPECT_EQ(0u, restricted_query_title.size());
+ } else {
+ // If user host restrictions are disabled, the extension retains access.
+ EXPECT_THAT(restricted_tab.FindString("url"),
+ testing::Pointee(testing::Eq(restricted_url.spec())));
+ EXPECT_THAT(restricted_tab.FindString("title"),
+ testing::Pointee(testing::Eq("Title Of Awesomeness")));
+ EXPECT_EQ(1u, restricted_query_url.size());
+ EXPECT_EQ(1u, restricted_query_title.size());
+ }
+}
+
// Tests that extensions cannot run on user-restricted sites. This specifically
// checks renderer-side permissions restrictions (with content scripts).
IN_PROC_BROWSER_TEST_P(UserHostRestrictionsBrowserTest,
diff --git a/chrome/common/extensions/permissions/permissions_data_unittest.cc b/chrome/common/extensions/permissions/permissions_data_unittest.cc
index 426fd1a..149d1c8c 100644
--- a/chrome/common/extensions/permissions/permissions_data_unittest.cc
+++ b/chrome/common/extensions/permissions/permissions_data_unittest.cc
@@ -1335,6 +1335,44 @@
EXPECT_EQ(ALLOWED_SCRIPT_ONLY, GetExtensionAccess(component.get(), url));
}
... (truncated)
Original Bug Report
Bypass of extension host blocking policies and user restrictions leaking tab metadata
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 Chrome’s extension framework allows extensions to bypass enterprise policy and user-defined host-blocking restrictions to access sensitive tab metadata, including full URLs and page titles. This occurs because GetScrubTabBehaviorImpl validates host permissions via a bare pattern match against the active permission set without consulting the policy-blocked or user-blocked host overlays. Additionally, query filtering logic fails to consult user restricted hosts, creating a potential metadata disclosure oracle.
Affected files:
chrome/browser/extensions/extension_tab_util.ccchrome/browser/extensions/api/tabs/tabs_api.cc
Estimated timestamp from git blame: 2019-08-20
Root Cause Analysis
When determining whether sensitive fields such as url, pendingUrl, title, and favIconUrl must be scrubbed from an api::tabs::Tab object before sending it to an extension, the Chrome extension framework calls GetScrubTabBehaviorImpl in chrome/browser/extensions/extension_tab_util.cc.
Instead of verifying the host permission through wrapper methods that enforce administrative and user blocks, the code directly calls PermissionSet::HasExplicitAccessToOrigin on the extension’s active permission set:
// chrome/browser/extensions/extension_tab_util.cc
bool host_permission = extension->permissions_data()
->active_permissions()
.HasExplicitAccessToOrigin(url);
This method only performs a standard URLPattern match against explicit_hosts() and does not check the enterprise policy overlay (IsPolicyBlockedHostUnsafe) or user restricted/blocked hosts overlay (IsUrlBlockedByUser).
As a result, even if a host (e.g., victim.com) is explicitly blocked via an enterprise policy or a user settings block (“Don’t allow extensions on victim.com”), an extension that has declared host permissions matching victim.com will potentially bypass these boundaries and receive unscrubbed tab metadata via events and API returns (such as chrome.tabs.onUpdated and chrome.tabs.query).
Secondary Filter Bypass
In chrome/browser/extensions/api/tabs/tabs_api.cc, the query filter logic in TabsQueryFunction::MatchesTab gates tab title and URL query matches on PermissionsData::HasHostPermission (around line 2038). While HasHostPermission correctly enforces enterprise policy blocks, it does not check user restricted/blocked hosts (which are verified via IsUrlBlockedByUser). Therefore, an extension blocked on a host by a user can still query the titles of active tabs on that host by using chrome.tabs.query({title: '*pattern*'}) as a potential information disclosure oracle.
Potential Impact
An extension whose access to a target site has been explicitly revoked by enterprise policy or by the user’s per-site block potentially continues to receive sensitive real-time navigation metadata (including full URLs, which can contain path details, capability tokens, OAuth authorization codes, and document titles) for those restricted sites on every navigation, bypassing the host restriction.
Potential Reproduction Steps
Note: These are suggested and potential steps, as our tooling agent does not have the ability to run code or verify execution in a live environment.
- Launch Chrome with
--enable-features=ExtensionsMenuAccessControl(or configure an enterprise policy such asExtensionSettings: {'*': {'runtime_blocked_hosts': ['*://victim.com']}}). - Load an unpacked Manifest V3 extension with host permissions matching
*://victim.com/*but without the'tabs'API permission. In its background service worker, register a listener:chrome.tabs.onUpdated.addListener((id, changeInfo, tab) => { console.log(JSON.stringify(tab)); }); - Navigate to
https://victim.comand use the extensions menu to select ‘Don’t allow extensions on victim.com’. - Observe that the extension is blocked from running scripts (e.g., executing
chrome.scripting.executeScripton the tab fails with a blocked-host error). - Navigate to a sensitive URL on the victim domain, such as
https://victim.com/secret/path?token=sensitive_session_token. - Inspect the service worker console. Under the vulnerability logic, it is expected to print an unscrubbed Tab object containing the full URL, including path and query parameters, as well as the page title, bypassing the host restriction.
Suggested Fix
- In
chrome/browser/extensions/extension_tab_util.cc, updateGetScrubTabBehaviorImplto check both enterprise policy and user restrictions:
bool host_permission = extension->permissions_data()->HasHostPermission(url) &&
!extension->permissions_data()->IsUrlBlockedByUser(url);
- In
chrome/browser/extensions/api/tabs/tabs_api.cc, updateTabsQueryFunction::MatchesTabto ensure user restricted/blocked hosts are checked:
if (!extension_->permissions_data()->HasAPIPermissionForTab(
ExtensionTabUtil::GetTabId(web_contents),
mojom::APIPermissionID::kTab) &&
(!extension_->permissions_data()->HasHostPermission(web_contents->GetURL()) ||
extension_->permissions_data()->IsUrlBlockedByUser(web_contents->GetURL()))) {
return false;
}
Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8
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.