CVE-2026-106253
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/api/tabs/tabs_api.cc |
modified |
Files Changed
chrome/browser/extensions/api/tabs/tabs_test.ccchrome/browser/extensions/api/tabs/tabs_api.cc
Patch
From 4c8966cccbf2a46c7c2dfcd5b897558f41cbb2ec Mon Sep 17 00:00:00 2001
From: Anh Mac <anhmac@chromium.org>
Date: Thu, 10 Sep 2026 15:43:04 -0700
Subject: [PATCH] [Extensions] Fix cross-origin permission leaks during pending navigation in chrome.tabs.query
This CL updates chrome.tabs.query to check
ExtensionTabUtil::GetScrubTabBehavior when querying tabs by title or
URL. This ensures that permissions are correctly evaluated for both
committed and pending navigation states.
In the existing state, during a pending navigation where url_a is
committed, and url_b is pending (i.e. tab.url:url_a,
tab.pendingUrl:url_b), an extension with host permissions to only url_b
can perform chrome.tabs.query({url:url_b, title:title_a}) and get a
title result for url_a since we are leaking permissions between the
committed and pending host url when using web_contents->GetURL().
This CL updates the permission check when matching against "title" to
ensure that chrome.tabs.query only succeeds if the extension has host
permission on the committed url. While matching against "url", a query
will succeed if it matches against either the committed url OR the
pendingUrl.
Fixed: 513741326
Change-Id: Iba6bb1743567ff08911b65b76cf5de9c23fe012f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8376661
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Anh Mac <anhmac@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1695728}
---
diff --git a/chrome/browser/extensions/api/tabs/tabs_api.cc b/chrome/browser/extensions/api/tabs/tabs_api.cc
index 6d67307..0892d1b 100644
--- a/chrome/browser/extensions/api/tabs/tabs_api.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_api.cc
@@ -2068,28 +2068,38 @@
return false;
}
- bool check_title = query_info_.title && !query_info_.title->empty();
- if (check_title || !target_url_patterns.is_empty()) {
- // "title" and "url" properties are considered privileged data and can
- // only be checked if the extension has the "tabs" permission or it has
- // access to the WebContents's origin. Otherwise, this tab is considered
- // not matched.
- if (!extension_->permissions_data()->HasAPIPermissionForTab(
- ExtensionTabUtil::GetTabId(web_contents),
- mojom::APIPermissionID::kTab) &&
- !extension_->permissions_data()->HasHostPermission(
- web_contents->GetURL())) {
+ // "title" and "url" properties are considered privileged data and can
+ // only be checked if the extension has access to the tab's data.
+ // Otherwise, this tab is considered not matched.
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(extension(), source_context_type(),
+ web_contents);
+ if (query_info_.title && !query_info_.title->empty()) {
+ bool matches_title =
+ scrub_tab_behavior.committed_info != ExtensionTabUtil::kScrubTabFully &&
+ base::MatchPattern(web_contents->GetTitle(),
+ base::UTF8ToUTF16(*query_info_.title));
+ if (!matches_title) {
return false;
}
+ }
- if (check_title &&
- !base::MatchPattern(web_contents->GetTitle(),
- base::UTF8ToUTF16(*query_info_.title))) {
- return false;
- }
+ if (!target_url_patterns.is_empty()) {
+ bool matches_committed =
+ scrub_tab_behavior.committed_info != ExtensionTabUtil::kScrubTabFully &&
+ target_url_patterns.MatchesURL(web_contents->GetLastCommittedURL());
- if (!target_url_patterns.is_empty() &&
- !target_url_patterns.MatchesURL(web_contents->GetURL())) {
+ content::NavigationEntry* pending_entry =
+ web_contents->GetController().GetPendingEntry();
+ bool matches_pending =
+ pending_entry &&
+ scrub_tab_behavior.pending_info != ExtensionTabUtil::kScrubTabFully &&
+ target_url_patterns.MatchesURL(pending_entry->GetVirtualURL());
+
+ // Note: It's okay to indicate the tab matched even if it only has access
+ // to one of [pending, committed]. The tab will be scrubbed appropriately
+ // when it's returned below.
+ if (!matches_committed && !matches_pending) {
return false;
}
}
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 723621d8..4c5477f 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -9,6 +9,7 @@
#include <array>
#include <memory>
#include <string>
+#include <string_view>
#include "base/feature_list.h"
#include "base/files/scoped_temp_dir.h"
@@ -87,6 +88,7 @@
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "pdf/buildflags.h"
#include "testing/gmock/include/gmock/gmock.h"
@@ -4402,6 +4404,127 @@
}
}
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest,
+ QueryPermissionsWithPendingNavigation) {
+ net::test_server::ControllableHttpResponse response(embedded_test_server(),
+ "/slow");
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Constants for testing.
+ static constexpr char kHostA[] = "a.example";
+ static constexpr char kHostB[] = "b.example";
+ static constexpr char kTitleA[] = "Site A Title";
+ static constexpr char kTitleB[] = "Site B Title";
+ // Query templates.
+ constexpr char kTitleQuery[] = R"([{"title": "%s"}])";
+ constexpr char kUrlQuery[] = R"([{"url": "*://%s/*"}])";
+ constexpr char kCombinedQuery[] = R"([{"title": "%s", "url": "*://%s/*"}])";
+
+ auto count_matches = [&](const Extension* ext,
+ const std::string& query_json) {
+ return RunQueryFunction(ext, query_json.c_str()).size();
+ };
+
+ const GURL url_a = embedded_test_server()->GetURL(kHostA, "/empty.html");
+ const GURL url_b = embedded_test_server()->GetURL(kHostB, "/slow");
+
+ // Set up the initial tab.
+ tabs::TabInterface* tab = GetTabListInterface()->OpenTab(url_a, -1);
+ ASSERT_TRUE(tab);
+ content::WebContents* web_contents = tab->GetContents();
+ content::WaitForLoadStop(web_contents);
+ web_contents->GetController().GetVisibleEntry()->SetTitle(
+ base::ASCIIToUTF16(std::string(kTitleA)));
+ EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ // Build test extensions with distinct permission scopes.
+ auto extension_a =
+ ExtensionBuilder("ExtensionA")
+ .AddHostPermission(base::StringPrintf("*://%s/*", kHostA))
+ .Build();
+ auto extension_b =
+ ExtensionBuilder("ExtensionB")
+ .AddHostPermission(base::StringPrintf("*://%s/*", kHostB))
+ .Build();
+ auto extension_tabs =
+ ExtensionBuilder("TabsPermission").AddAPIPermission("tabs").Build();
+
+ // Start in-flight browser-initiated navigation to url_b.
+ content::NavigationController::LoadURLParams params(url_b);
+ params.transition_type = ui::PAGE_TRANSITION_TYPED;
+ web_contents->GetController().LoadURLWithParams(params);
+ response.WaitForRequest();
+
+ // Verify that the committed URL and title have not yet been updated to url_b.
+ EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+ EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ {
+ SCOPED_TRACE("Before navigation (A committed, B pending)");
+
+ // Extension A: matches committed A info, but cannot see pending URL for B.
+ EXPECT_EQ(1u, count_matches(extension_a.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(1u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+
+ // Extension B: can match its pending URL, but cannot see committed title or
+ // URL from A.
+ EXPECT_EQ(0u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ // Bug fix for crbug.com/513741326: Cross-origin check (B cannot see A's
+ // committed info).
+ EXPECT_EQ(0u, count_matches(extension_b.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(
+ 0u, count_matches(extension_b.get(),
+ base::StringPrintf(kCombinedQuery, kTitleA, kHostB)));
+
+ // Tabs extension: has global access to all titles and URLs.
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ }
+
+ // Complete the navigation.
+ response.Send(
+ base::StringPrintf("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
+ "<html><head><title>%s</title></head></html>",
+ kTitleB));
+ response.Done();
+ content::WaitForLoadStop(web_contents);
+
+ // Verify that the committed URL and title have been updated to url_b.
+ EXPECT_EQ(url_b, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+ EXPECT_EQ(kTitleB, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ {
+ SCOPED_TRACE("After navigation (B committed)");
+
+ // Extension A no longer matches anything on this tab.
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kTitleQuery, kTitleB)));
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+
+ // Extension B now matches committed B title and URL.
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kTitleQuery, kTitleB)));
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ }
+}
+
#if BUILDFLAG(ENABLE_PDF)
// Test that using the PDF extension for tab updates is treated as a
// renderer-initiated navigation. crbug.com/40085816
@@ -5726,13 +5849,13 @@
)";
}
test_dir.WriteFile(FILE_PATH_LITERAL("background.js"),
- absl::StrFormat(R"(
+ base::StringPrintf(R"(
chrome.tabs.create({"url": "about:blank"}, function(created_tab) {
chrome.tabs.%s.addListener(%s);
chrome.tabs.discard(created_tab.id);
});
)",
- event_name, event_listener));
+ event_name, event_listener));
ExtensionTestMessageListener success_listener("success");
Regression Test / PoC
diff --git a/chrome/browser/extensions/api/tabs/tabs_test.cc b/chrome/browser/extensions/api/tabs/tabs_test.cc
index 723621d8..4c5477f 100644
--- a/chrome/browser/extensions/api/tabs/tabs_test.cc
+++ b/chrome/browser/extensions/api/tabs/tabs_test.cc
@@ -9,6 +9,7 @@
#include <array>
#include <memory>
#include <string>
+#include <string_view>
#include "base/feature_list.h"
#include "base/files/scoped_temp_dir.h"
@@ -87,6 +88,7 @@
#include "extensions/test/result_catcher.h"
#include "extensions/test/test_extension_dir.h"
#include "net/dns/mock_host_resolver.h"
+#include "net/test/embedded_test_server/controllable_http_response.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "pdf/buildflags.h"
#include "testing/gmock/include/gmock/gmock.h"
@@ -4402,6 +4404,127 @@
}
}
+IN_PROC_BROWSER_TEST_F(ExtensionTabsTest,
+ QueryPermissionsWithPendingNavigation) {
+ net::test_server::ControllableHttpResponse response(embedded_test_server(),
+ "/slow");
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ // Constants for testing.
+ static constexpr char kHostA[] = "a.example";
+ static constexpr char kHostB[] = "b.example";
+ static constexpr char kTitleA[] = "Site A Title";
+ static constexpr char kTitleB[] = "Site B Title";
+ // Query templates.
+ constexpr char kTitleQuery[] = R"([{"title": "%s"}])";
+ constexpr char kUrlQuery[] = R"([{"url": "*://%s/*"}])";
+ constexpr char kCombinedQuery[] = R"([{"title": "%s", "url": "*://%s/*"}])";
+
+ auto count_matches = [&](const Extension* ext,
+ const std::string& query_json) {
+ return RunQueryFunction(ext, query_json.c_str()).size();
+ };
+
+ const GURL url_a = embedded_test_server()->GetURL(kHostA, "/empty.html");
+ const GURL url_b = embedded_test_server()->GetURL(kHostB, "/slow");
+
+ // Set up the initial tab.
+ tabs::TabInterface* tab = GetTabListInterface()->OpenTab(url_a, -1);
+ ASSERT_TRUE(tab);
+ content::WebContents* web_contents = tab->GetContents();
+ content::WaitForLoadStop(web_contents);
+ web_contents->GetController().GetVisibleEntry()->SetTitle(
+ base::ASCIIToUTF16(std::string(kTitleA)));
+ EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ // Build test extensions with distinct permission scopes.
+ auto extension_a =
+ ExtensionBuilder("ExtensionA")
+ .AddHostPermission(base::StringPrintf("*://%s/*", kHostA))
+ .Build();
+ auto extension_b =
+ ExtensionBuilder("ExtensionB")
+ .AddHostPermission(base::StringPrintf("*://%s/*", kHostB))
+ .Build();
+ auto extension_tabs =
+ ExtensionBuilder("TabsPermission").AddAPIPermission("tabs").Build();
+
+ // Start in-flight browser-initiated navigation to url_b.
+ content::NavigationController::LoadURLParams params(url_b);
+ params.transition_type = ui::PAGE_TRANSITION_TYPED;
+ web_contents->GetController().LoadURLWithParams(params);
+ response.WaitForRequest();
+
+ // Verify that the committed URL and title have not yet been updated to url_b.
+ EXPECT_EQ(url_a, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+ EXPECT_EQ(kTitleA, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ {
+ SCOPED_TRACE("Before navigation (A committed, B pending)");
+
+ // Extension A: matches committed A info, but cannot see pending URL for B.
+ EXPECT_EQ(1u, count_matches(extension_a.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(1u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+
+ // Extension B: can match its pending URL, but cannot see committed title or
+ // URL from A.
+ EXPECT_EQ(0u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ // Bug fix for crbug.com/513741326: Cross-origin check (B cannot see A's
+ // committed info).
+ EXPECT_EQ(0u, count_matches(extension_b.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(
+ 0u, count_matches(extension_b.get(),
+ base::StringPrintf(kCombinedQuery, kTitleA, kHostB)));
+
+ // Tabs extension: has global access to all titles and URLs.
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kTitleQuery, kTitleA)));
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kUrlQuery, kHostA)));
+ EXPECT_EQ(1u, count_matches(extension_tabs.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ }
+
+ // Complete the navigation.
+ response.Send(
+ base::StringPrintf("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
+ "<html><head><title>%s</title></head></html>",
+ kTitleB));
+ response.Done();
+ content::WaitForLoadStop(web_contents);
+
+ // Verify that the committed URL and title have been updated to url_b.
+ EXPECT_EQ(url_b, web_contents->GetLastCommittedURL());
+ EXPECT_EQ(url_b, web_contents->GetVisibleURL());
+ EXPECT_EQ(kTitleB, base::UTF16ToUTF8(web_contents->GetTitle()));
+
+ {
+ SCOPED_TRACE("After navigation (B committed)");
+
+ // Extension A no longer matches anything on this tab.
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kTitleQuery, kTitleB)));
+ EXPECT_EQ(0u, count_matches(extension_a.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+
+ // Extension B now matches committed B title and URL.
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kTitleQuery, kTitleB)));
+ EXPECT_EQ(1u, count_matches(extension_b.get(),
+ base::StringPrintf(kUrlQuery, kHostB)));
+ }
+}
+
#if BUILDFLAG(ENABLE_PDF)
// Test that using the PDF extension for tab updates is treated as a
// renderer-initiated navigation. crbug.com/40085816
@@ -5726,13 +5849,13 @@
)";
}
test_dir.WriteFile(FILE_PATH_LITERAL("background.js"),
- absl::StrFormat(R"(
+ base::StringPrintf(R"(
chrome.tabs.create({"url": "about:blank"}, function(created_tab) {
chrome.tabs.%s.addListener(%s);
chrome.tabs.discard(created_tab.id);
});
)",
- event_name, event_listener));
+ event_name, event_listener));
ExtensionTestMessageListener success_listener("success");
Original Bug Report
Cross-origin title disclosure in tabs.query during browser-initiated navigation
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: The chrome.tabs.query API incorrectly uses the visible URL for permission checks while matching against the last-committed page title. During a browser-initiated navigation, this allows an extension to leak a cross-origin site’s title using the title filter as an oracle.
Affected files:
chrome/browser/extensions/api/tabs/tabs_api.ccchrome/browser/extensions/extension_tab_util.cccontent/browser/web_contents/web_contents_impl.cccontent/browser/renderer_host/navigation_controller_impl.cc
Estimated timestamp from git blame: 2016-01-21
Root Cause Analysis
In TabsQueryFunction::MatchesTab (located in chrome/browser/extensions/api/tabs/tabs_api.cc), the logic gates access to privileged tab data (title and URL) by checking the extension’s host permissions. However, there is a discrepancy between the URL used for the permission check and the title data actually matched.
Specifically, the check uses web_contents->GetURL(), which returns the visible URL. During a pending browser-initiated navigation (e.g., initiated via the omnibox), GetURL() returns the destination URL. Conversely, the matching logic uses web_contents->GetTitle(), which returns the last-committed title—the title of the page the user is currently navigating away from.
// chrome/browser/extensions/api/tabs/tabs_api.cc
if (check_title || !target_url_patterns.is_empty()) {
if (!extension_->permissions_data()->HasAPIPermissionForTab(...) &&
!extension_->permissions_data()->HasHostPermission(
web_contents->GetURL())) { // Returns visible URL (pending destination)
return false;
}
if (check_title &&
!base::MatchPattern(web_contents->GetTitle(), // Returns last-committed title
base::UTF8ToUTF16(*query_info_.title))) {
return false;
}
}
WebContentsImpl::GetURL() calls GetVisibleURL(), which for browser-initiated pending entries returns the pending destination. In contrast, WebContentsImpl::GetTitle() typically uses the last-committed entry’s title during navigations to maintain UI stability. This discrepancy creates a Time-of-Check Time-of-Use (TOCTOU) window.
Potential Vulnerability Mechanism
- Victim State: A tab is on
https://victim.examplewith a sensitive title (e.g., “Inbox (5) - alice@victim.example”). - User Action: The user initiates a navigation to
https://permitted.examplevia the omnibox. This creates a browser-initiated pending navigation.GetVisibleURL()now returnshttps://permitted.examplebecause browser-initiated pending entries are considered safe to show in the omnibox. - Exploitation: An extension with host permissions for
https://permitted.example(but notabspermission and no permissions forvictim.example) callschrome.tabs.query({title: 'Inbox*'})while the navigation is in flight. - Bypass: The permission check passes because the visible URL matches the permitted destination. However, the title match is evaluated against the victim’s title retrieved via
GetTitle().
Even though the resulting Tab object is scrubbed before being returned to the extension (removing the title and URL fields), the fact that the tab is included in the result array confirms the match, acting as a 1-bit oracle.
Suggested Fix
Ensure that the permission check and the data matching logic use consistent NavigationEntries. TabsQueryFunction::MatchesTab should likely check permissions against the same entry used to retrieve the title. Specifically, the permission check should verify host permissions against the last-committed URL if the title being matched is the last-committed title.
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.