CVE-2026-87658
Overview
Background
- `activeTab` permission
- a Chrome extension permission that grants temporary host access to the currently active tab’s page, but only for the origin present when the user explicitly invokes the extension.
- Tab-specific permissions
- per-tab grants stored in
PermissionsData(thekTabAPI permission plus host patterns) that authorize an extension to act on one tab and are meant to be scoped to the origin that was live when they were granted. - URL scrubbing
- Chrome’s practice, driven by
ExtensionTabUtil::GetScrubTabBehavior, of stripping a tab’s URL before exposing it to an extension via thetabsAPI when the extension lacks permission for that URL’s origin. - Cross-origin page activation
- restoring a page from BFCache or a prerender, which commits through
DidFinishNavigationwithout a precedingReadyToCommitNavigation, a path on which temporary tab grants may not have been revoked yet.
Root Cause Analysis
The tab-URL permission check treated any tab carrying a tab-specific kTab grant (i.e. activeTab) as authorized for whatever URL currently occupied that tab, without verifying that the grant applied to the URL’s origin. In the removed code, HasAPIPermissionForTab(tab_id, APIPermissionID::kTab) set api_permission = true, which made has_permission true regardless of the URL passed in, so the URL was not scrubbed. When a tab navigated cross-origin — especially via a page activation (BFCache/prerender) that commits through DidFinishNavigation without ReadyToCommitNavigation, so the temporary grant had not yet been revoked — the extension’s stale grant on origin A wrongly authorized visibility of origin B’s URL. This violated the invariant that activeTab access is confined to the origin the user actually granted it on.
The fix adds permissions->HasTabPermissionsForSecurityOrigin(tab_id, url) to the tab-specific branch, so the per-tab grant must match the URL’s security origin; if it does not, the URL is scrubbed unless the extension holds the global tabs permission or explicit host permission (HasExplicitAccessToOrigin(url)) for that origin.
activeTab/kTab grant on this tab” with “has permission for this URL’s origin,” letting a stale per-tab grant authorize a cross-origin URL; the fix requires the grant to be origin-scoped by additionally calling HasTabPermissionsForSecurityOrigin(tab_id, url).Attack Path
- Extension obtains `activeTab` on origin A
A malicious or curious extension holding only
activeTabis invoked by the user on a page at origin A, granting a tab-specifickTabpermission scoped to A. - Cross-origin activation of the tab
The tab is moved cross-origin to origin B through a path that commits via
DidFinishNavigationwithoutReadyToCommitNavigation(BFCache restore or prerender activation), delaying revocation of the tab-specific grant. - Stale grant passes the check
During that window
HasAPIPermissionForTab(tab_id, APIPermissionID::kTab)still returns true, so the pre-patch code sethas_permission = truewithout checking B’s origin. - Cross-origin URL leak
GetScrubTabBehaviortherefore returnskDontScrubTab, and the extension reads the full URL of origin B through thetabsAPI even though it was never granted access to B (information disclosure).
Impact Assessment
activeTab can observe the full URL (and thus the origin and path) of a cross-origin page it was never granted access to, as surfaced by the browser process through the tabs API — an information leak. The preconditions are that the extension holds a tab-specific kTab grant on the tab and that the tab undergoes a cross-origin page activation before the temporary permission is revoked. The advisory classifies this as an information leak in Extensions (LogicError); no exploitation-in-the-wild and no escalation beyond information disclosure is stated by the advisory or demonstrated by the patch.Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/extensions/extension_tab_util.cc |
modified |
Files Changed
chrome/browser/extensions/back_forward_cache_browsertest.ccchrome/browser/extensions/extension_tab_util.ccchrome/browser/extensions/extension_tab_util_unittest.cc
Audit Directions
- Origin-scoped per-tab permission checksaudit every gate that uses
HasAPIPermissionForTab/tab-specific grants to authorize URL or content access, and confirm the current URL’s security origin is re-verified (e.g. viaHasTabPermissionsForSecurityOrigin) rather than trusting the tab id alone. - Delayed revocation on activation pathslook for navigation-completion handlers that commit through
DidFinishNavigationwithoutReadyToCommitNavigation(BFCache, prerender, other activations), where temporary permissions may outlive the origin they were scoped to. - URL scrubbing and tabs surfacesreview other
GetScrubTabBehavior-style callers andtabs/webNavigationAPIs for permission logic that treats a per-tab API grant as origin-agnostic and could expose cross-origin URLs.
Patch
From f10a719fca1ad1da1f44101117e9599f944763a5 Mon Sep 17 00:00:00 2001
From: Liam Brady <lbrady@google.com>
Date: Mon, 27 Jul 2026 18:36:24 -0700
Subject: [PATCH] [Extensions] Check security origin for tab-specific URL scrubbing.
This CL ensures that extensions with tab-specific permissions (such as
activeTab) cannot access or observe cross-origin URLs on a tab before
temporary permissions are revoked.
Update the tab URL permission check to verify that tab-specific
permissions are granted for the specific security origin of the URL
being checked. If the origin does not match, scrub the URL unless the
extension possesses global tab or explicit host permissions for that
origin.
This CL also adds a test to check that activeTab permissions are cleared
on cross-origin page activations (i.e. prerendering), which commit
without triggering ReadyToCommitNavigation but still call
DidFinishNavigation.
Bug: 496615345
Change-Id: Id2a1ac4b9b5d461ffc0f5a2ab6b64e28cc57c4e5
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8139506
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Liam Brady <lbrady@google.com>
Cr-Commit-Position: refs/heads/main@{#1669144}
---
diff --git a/chrome/browser/extensions/extension_tab_util.cc b/chrome/browser/extensions/extension_tab_util.cc
index 6238051..dae43a7 100644
--- a/chrome/browser/extensions/extension_tab_util.cc
+++ b/chrome/browser/extensions/extension_tab_util.cc
@@ -157,19 +157,22 @@
bool has_permission = false;
if (extension) {
- bool api_permission = false;
- if (tab_id == api::tabs::TAB_ID_NONE) {
- api_permission = extension->permissions_data()->HasAPIPermission(
- APIPermissionID::kTab);
- } else {
- api_permission = extension->permissions_data()->HasAPIPermissionForTab(
- tab_id, APIPermissionID::kTab);
+ const PermissionsData* permissions = extension->permissions_data();
+ if (permissions->HasAPIPermission(APIPermissionID::kTab)) {
+ // Global "tabs" permission allows access to any URL.
+ has_permission = true;
+ } else if (tab_id != api::tabs::TAB_ID_NONE &&
+ permissions->HasAPIPermissionForTab(tab_id,
+ APIPermissionID::kTab) &&
+ permissions->HasTabPermissionsForSecurityOrigin(tab_id, url)) {
+ // Tab-specific permission (e.g. activeTab) allowed, and the origin
+ // matches.
+ has_permission = true;
+ } else if (permissions->active_permissions().HasExplicitAccessToOrigin(
+ url)) {
+ // Explicit host permission allows access.
+ has_permission = true;
}
-
- bool host_permission = extension->permissions_data()
- ->active_permissions()
- .HasExplicitAccessToOrigin(url);
- has_permission = api_permission || host_permission;
}
if (!has_permission) {
diff --git a/chrome/browser/extensions/back_forward_cache_browsertest.cc b/chrome/browser/extensions/back_forward_cache_browsertest.cc
index b1401761..2b91bcfa 100644
--- a/chrome/browser/extensions/back_forward_cache_browsertest.cc
+++ b/chrome/browser/extensions/back_forward_cache_browsertest.cc
@@ -27,6 +27,8 @@
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension.h"
#include "extensions/common/manifest_constants.h"
+#include "extensions/common/mojom/api_permission_id.mojom-shared.h"
+#include "extensions/common/permissions/permissions_data.h"
#include "extensions/test/extension_test_message_listener.h"
#include "net/dns/mock_host_resolver.h"
#include "third_party/blink/public/common/features.h"
@@ -1095,6 +1097,65 @@
ExpectTitleChangeFail(*extension);
}
+// Flaky on desktop Android.
+#if BUILDFLAG(IS_ANDROID)
+#define MAYBE_ActiveTabPermissionClearedOnBFCacheRestore \
+ DISABLED_ActiveTabPermissionClearedOnBFCacheRestore
+#else
+#define MAYBE_ActiveTabPermissionClearedOnBFCacheRestore \
+ ActiveTabPermissionClearedOnBFCacheRestore
+#endif
+// Test that an activeTab permission granted to an extension for a page is
+// cleared when a cross-origin page activation (restoring a BFCache entry)
+// commits.
+IN_PROC_BROWSER_TEST_F(ExtensionBackForwardCacheBrowserTest,
+ MAYBE_ActiveTabPermissionClearedOnBFCacheRestore) {
+ scoped_refptr<const Extension> extension =
+ LoadExtension(test_data_dir_.AppendASCII("back_forward_cache")
+ .AppendASCII("active_tab"));
+ ASSERT_TRUE(extension);
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+ // Navigate to A.
+ content::WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, url_a));
+ content::RenderFrameHostWrapper render_frame_host_a(
+ web_contents->GetPrimaryMainFrame());
+
+ // Navigate to B.
+ ASSERT_TRUE(NavigateToURL(web_contents, url_b));
+
+ // Ensure that `render_frame_host_a` is in the cache.
+ EXPECT_FALSE(render_frame_host_a.IsDestroyed());
+ EXPECT_EQ(render_frame_host_a->GetLifecycleState(),
+ content::RenderFrameHost::LifecycleState::kInBackForwardCache);
+
+ // Grant the activeTab permission on B.
+ ExtensionActionRunner::GetForWebContents(web_contents)
+ ->RunAction(extension.get(), /*grant_tab_permissions=*/true);
+
+ ExpectTitleChangeSuccess(*extension, "changed_title_on_b");
+
+ // Go back to A (page activation from BFCache).
+ web_contents->GetController().GoBack();
+ EXPECT_TRUE(WaitForLoadStop(web_contents));
+
+ // Verify the tab-specific permission grant is cleared.
+ EXPECT_FALSE(extension->permissions_data()->HasAPIPermissionForTab(
+ GetActiveTabId(), mojom::APIPermissionID::kTab));
+
+ // Go forward to B again from BFCache.
+ web_contents->GetController().GoForward();
+ EXPECT_TRUE(WaitForLoadStop(web_contents));
+
+ // Verify the extension can no longer script B without re-requesting activeTab
+ // permission.
+ ExpectTitleChangeFail(*extension);
+}
+
// Verifies that pages with extension subframes are not eligible for
// bfcache. This is important because all extension contexts can script each
// other and are thus associated to the same agent, so we can't prevent
diff --git a/chrome/browser/extensions/extension_tab_util_unittest.cc b/chrome/browser/extensions/extension_tab_util_unittest.cc
index 04a8176a..3d86fc6 100644
--- a/chrome/browser/extensions/extension_tab_util_unittest.cc
+++ b/chrome/browser/extensions/extension_tab_util_unittest.cc
@@ -10,12 +10,18 @@
#include "base/values.h"
#include "chrome/browser/extensions/extension_service_test_base.h"
#include "chrome/browser/extensions/extension_util.h"
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "components/sessions/content/session_tab_helper.h"
#include "components/sync_preferences/testing_pref_service_syncable.h"
+#include "content/public/test/web_contents_tester.h"
#include "extensions/browser/pref_names.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension.h"
#include "extensions/common/extension_builder.h"
#include "extensions/common/mojom/context_type.mojom.h"
+#include "extensions/common/permissions/permission_set.h"
+#include "extensions/common/permissions/permissions_data.h"
+#include "extensions/common/url_pattern_set.h"
#include "testing/gtest/include/gtest/gtest.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -199,6 +205,48 @@
}
}
+// Regression test for https://crbug.com/496615345. Tests that when an extension
+// has a tab-specific permission (e.g. activeTab) granted for a specific origin,
+// GetScrubTabBehavior scrubs cross-origin URLs on that tab even if the tab's
+// temporary permissions have not yet been revoked.
+TEST_F(ChromeExtensionNavigationTest,
+ ScrubTabBehaviorForTabSpecificPermissionOriginScoped) {
+ auto extension = ExtensionBuilder("Extension with activeTab permission")
+ .AddAPIPermission("activeTab")
+ .Build();
+ std::unique_ptr<content::WebContents> web_contents(
+ content::WebContentsTester::CreateTestWebContents(profile(), nullptr));
+ sessions::SessionTabHelper::CreateForWebContents(web_contents.get(),
+ base::NullCallback());
+ content::WebContentsTester::For(web_contents.get())
+ ->NavigateAndCommit(GURL("http://www.google.com/some/path"));
+ // Simulate the extension having activeTab on this tab by directly populating
+ // tab-specific permissions in PermissionsData (avoiding async CORS and IPC
+ // side effects from ActiveTabPermissionGranter in a unittest).
+ const int kTabId = ExtensionTabUtil::GetTabId(web_contents.get());
+ APIPermissionSet apis;
+ apis.insert(mojom::APIPermissionID::kTab);
+ URLPatternSet hosts;
+ hosts.AddOrigin(URLPattern::SCHEME_ALL, GURL("http://www.google.com"));
+ PermissionSet tab_permissions(std::move(apis), ManifestPermissionSet(),
+ hosts.Clone(), hosts.Clone());
+ extension->permissions_data()->UpdateTabSpecificPermissions(kTabId,
+ tab_permissions);
+
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(extension.get(),
+ mojom::ContextType::kUnspecified,
+ web_contents.get());
+ EXPECT_EQ(ExtensionTabUtil::kDontScrubTab, scrub_tab_behavior.committed_info);
+
+ content::WebContentsTester::For(web_contents.get())
+ ->NavigateAndCommit(GURL("http://www.evil.com/some/path"));
+ scrub_tab_behavior = ExtensionTabUtil::GetScrubTabBehavior(
+ extension.get(), mojom::ContextType::kUnspecified, web_contents.get());
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully,
+ scrub_tab_behavior.committed_info);
+}
+
TEST_F(ChromeExtensionNavigationTest,
PrepareURLForNavigationWithEnterprisePolicy) {
// Set the extension to allow file URL navigation via enterprise policy.
Regression Test / PoC
diff --git a/chrome/browser/extensions/back_forward_cache_browsertest.cc b/chrome/browser/extensions/back_forward_cache_browsertest.cc
index b1401761..2b91bcfa 100644
--- a/chrome/browser/extensions/back_forward_cache_browsertest.cc
+++ b/chrome/browser/extensions/back_forward_cache_browsertest.cc
@@ -27,6 +27,8 @@
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension.h"
#include "extensions/common/manifest_constants.h"
+#include "extensions/common/mojom/api_permission_id.mojom-shared.h"
+#include "extensions/common/permissions/permissions_data.h"
#include "extensions/test/extension_test_message_listener.h"
#include "net/dns/mock_host_resolver.h"
#include "third_party/blink/public/common/features.h"
@@ -1095,6 +1097,65 @@
ExpectTitleChangeFail(*extension);
}
+// Flaky on desktop Android.
+#if BUILDFLAG(IS_ANDROID)
+#define MAYBE_ActiveTabPermissionClearedOnBFCacheRestore \
+ DISABLED_ActiveTabPermissionClearedOnBFCacheRestore
+#else
+#define MAYBE_ActiveTabPermissionClearedOnBFCacheRestore \
+ ActiveTabPermissionClearedOnBFCacheRestore
+#endif
+// Test that an activeTab permission granted to an extension for a page is
+// cleared when a cross-origin page activation (restoring a BFCache entry)
+// commits.
+IN_PROC_BROWSER_TEST_F(ExtensionBackForwardCacheBrowserTest,
+ MAYBE_ActiveTabPermissionClearedOnBFCacheRestore) {
+ scoped_refptr<const Extension> extension =
+ LoadExtension(test_data_dir_.AppendASCII("back_forward_cache")
+ .AppendASCII("active_tab"));
+ ASSERT_TRUE(extension);
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+ GURL url_a(embedded_test_server()->GetURL("a.com", "/title1.html"));
+ GURL url_b(embedded_test_server()->GetURL("b.com", "/title1.html"));
+
+ // Navigate to A.
+ content::WebContents* web_contents = GetActiveWebContents();
+ ASSERT_TRUE(NavigateToURL(web_contents, url_a));
+ content::RenderFrameHostWrapper render_frame_host_a(
+ web_contents->GetPrimaryMainFrame());
+
+ // Navigate to B.
+ ASSERT_TRUE(NavigateToURL(web_contents, url_b));
+
+ // Ensure that `render_frame_host_a` is in the cache.
+ EXPECT_FALSE(render_frame_host_a.IsDestroyed());
+ EXPECT_EQ(render_frame_host_a->GetLifecycleState(),
+ content::RenderFrameHost::LifecycleState::kInBackForwardCache);
+
+ // Grant the activeTab permission on B.
+ ExtensionActionRunner::GetForWebContents(web_contents)
+ ->RunAction(extension.get(), /*grant_tab_permissions=*/true);
+
+ ExpectTitleChangeSuccess(*extension, "changed_title_on_b");
+
+ // Go back to A (page activation from BFCache).
+ web_contents->GetController().GoBack();
+ EXPECT_TRUE(WaitForLoadStop(web_contents));
+
+ // Verify the tab-specific permission grant is cleared.
+ EXPECT_FALSE(extension->permissions_data()->HasAPIPermissionForTab(
+ GetActiveTabId(), mojom::APIPermissionID::kTab));
+
+ // Go forward to B again from BFCache.
+ web_contents->GetController().GoForward();
+ EXPECT_TRUE(WaitForLoadStop(web_contents));
+
+ // Verify the extension can no longer script B without re-requesting activeTab
+ // permission.
+ ExpectTitleChangeFail(*extension);
+}
+
// Verifies that pages with extension subframes are not eligible for
// bfcache. This is important because all extension contexts can script each
// other and are thus associated to the same agent, so we can't prevent
diff --git a/chrome/browser/extensions/extension_tab_util_unittest.cc b/chrome/browser/extensions/extension_tab_util_unittest.cc
index 04a8176a..3d86fc6 100644
--- a/chrome/browser/extensions/extension_tab_util_unittest.cc
+++ b/chrome/browser/extensions/extension_tab_util_unittest.cc
@@ -10,12 +10,18 @@
#include "base/values.h"
#include "chrome/browser/extensions/extension_service_test_base.h"
#include "chrome/browser/extensions/extension_util.h"
+#include "chrome/test/base/chrome_render_view_host_test_harness.h"
+#include "components/sessions/content/session_tab_helper.h"
#include "components/sync_preferences/testing_pref_service_syncable.h"
+#include "content/public/test/web_contents_tester.h"
#include "extensions/browser/pref_names.h"
#include "extensions/buildflags/buildflags.h"
#include "extensions/common/extension.h"
#include "extensions/common/extension_builder.h"
#include "extensions/common/mojom/context_type.mojom.h"
+#include "extensions/common/permissions/permission_set.h"
+#include "extensions/common/permissions/permissions_data.h"
+#include "extensions/common/url_pattern_set.h"
#include "testing/gtest/include/gtest/gtest.h"
static_assert(BUILDFLAG(ENABLE_EXTENSIONS_CORE));
@@ -199,6 +205,48 @@
}
}
+// Regression test for https://crbug.com/496615345. Tests that when an extension
+// has a tab-specific permission (e.g. activeTab) granted for a specific origin,
+// GetScrubTabBehavior scrubs cross-origin URLs on that tab even if the tab's
+// temporary permissions have not yet been revoked.
+TEST_F(ChromeExtensionNavigationTest,
+ ScrubTabBehaviorForTabSpecificPermissionOriginScoped) {
+ auto extension = ExtensionBuilder("Extension with activeTab permission")
+ .AddAPIPermission("activeTab")
+ .Build();
+ std::unique_ptr<content::WebContents> web_contents(
+ content::WebContentsTester::CreateTestWebContents(profile(), nullptr));
+ sessions::SessionTabHelper::CreateForWebContents(web_contents.get(),
+ base::NullCallback());
+ content::WebContentsTester::For(web_contents.get())
+ ->NavigateAndCommit(GURL("http://www.google.com/some/path"));
+ // Simulate the extension having activeTab on this tab by directly populating
+ // tab-specific permissions in PermissionsData (avoiding async CORS and IPC
+ // side effects from ActiveTabPermissionGranter in a unittest).
+ const int kTabId = ExtensionTabUtil::GetTabId(web_contents.get());
+ APIPermissionSet apis;
+ apis.insert(mojom::APIPermissionID::kTab);
+ URLPatternSet hosts;
+ hosts.AddOrigin(URLPattern::SCHEME_ALL, GURL("http://www.google.com"));
+ PermissionSet tab_permissions(std::move(apis), ManifestPermissionSet(),
+ hosts.Clone(), hosts.Clone());
+ extension->permissions_data()->UpdateTabSpecificPermissions(kTabId,
+ tab_permissions);
+
+ ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+ ExtensionTabUtil::GetScrubTabBehavior(extension.get(),
+ mojom::ContextType::kUnspecified,
+ web_contents.get());
+ EXPECT_EQ(ExtensionTabUtil::kDontScrubTab, scrub_tab_behavior.committed_info);
+
+ content::WebContentsTester::For(web_contents.get())
+ ->NavigateAndCommit(GURL("http://www.evil.com/some/path"));
+ scrub_tab_behavior = ExtensionTabUtil::GetScrubTabBehavior(
+ extension.get(), mojom::ContextType::kUnspecified, web_contents.get());
+ EXPECT_EQ(ExtensionTabUtil::kScrubTabFully,
+ scrub_tab_behavior.committed_info);
+}
+
TEST_F(ChromeExtensionNavigationTest,
PrepareURLForNavigationWithEnterprisePolicy) {
// Set the extension to allow file URL navigation via enterprise policy.
Original Bug Report
Info leak of cross-origin URLs via activeTab due to event ordering mismatch
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A potential logic flaw in Chrome’s extension system may allow an extension with the activeTab permission to leak the URL, title, and favicon of the subsequent cross-origin navigation. This occurs because the tabs.onUpdated event is dispatched before the activeTab permission is revoked for the new origin. Consequently, sensitive data like OAuth tokens in the new URL might be exposed to the extension.
Affected files:
chrome/browser/extensions/extension_tab_util.ccchrome/browser/extensions/api/tabs/tabs_event_router.ccchrome/browser/extensions/active_tab_permission_granter.cc
Estimated timestamp from git blame: 2026-01-07
Background
The activeTab permission temporarily grants an extension access to the currently active tab when the user invokes the extension. To maintain security, this grant (internally the kTab API permission) is designed to be revoked when the user navigates the tab to a different origin, preventing the extension from tracking the user across sites.
The Vulnerability
A potential ordering bug exists during the navigation commit phase, where a cross-origin URL is leaked to the extension before its activeTab permission is formally revoked.
- Event Ordering: When a navigation commits,
Navigator::DidNavigatefirst triggers theNavigationEntryCommittednotification. Later, when theNavigationRequestis destroyed, it triggers theDidFinishNavigationnotification. - The Leak:
TabsEventRouterobservesNavigationEntryCommittedand synchronously builds and dispatches thetabs.onUpdatedevent to extensions.- To determine if the new URL should be redacted (scrubbed), it calls
ExtensionTabUtil::GetScrubTabBehaviorImpl, which checks if the extension holds thekTabpermission for that tab. - However,
ActiveTabPermissionGranterrelies on the laterDidFinishNavigationevent to revoke thekTabpermission on cross-origin navigations.
- Result: Because
DidFinishNavigationhas not yet fired whentabs.onUpdatedis dispatched, the extension technically still holds thekTabpermission. The scrubbing logic returnskDontScrubTab, and the full, unredacted cross-origin URL is sent to the extension.
Note: This is a potential vulnerability identified by an AI agent (Fortify). A runnable proof-of-concept has not yet been executed to confirm the exact IPC timing, but the synchronous C++ call chains indicate the leak occurs.
Proposed Attacker Steps
- An attacker publishes a malicious extension requiring the
activeTabpermission and registers a listener forchrome.tabs.onUpdated. - The user installs the extension, opens a tab to a benign site (e.g.,
https://attacker.example), and clicks the extension icon, grantingactiveTabaccess. - The extension injects a script that triggers a navigation to a sensitive cross-origin endpoint, such as an OAuth flow that results in a server-side redirect to
https://victim.example/callback?token=SECRET_OAUTH_CODE. - As the browser commits the navigation to
victim.example,NavigationEntryCommittedfires. TabsEventRoutersynchronously queries the extension’s permissions. SinceDidFinishNavigationhasn’t fired to revokeactiveTab, the check passes.- The extension’s background script receives the
tabs.onUpdatedevent containing the fullvictim.exampleURL, including the sensitive OAuth token. - Shortly after,
DidFinishNavigationfires and revokes the permission, but the data has already been exfiltrated.
Suggested Fix
Revoke the activeTab permission earlier in the navigation lifecycle to ensure it precedes the event dispatch.
Instead of revoking the permission in ActiveTabPermissionGranter::DidFinishNavigation, consider moving the revocation logic to ActiveTabPermissionGranter::ReadyToCommitNavigation or ActiveTabPermissionGranter::NavigationEntryCommitted (ensuring it runs before TabsEventRouter processes the same event, though relying on observer ordering can be fragile).
Alternatively, update the scrubbing logic in ExtensionTabUtil::GetScrubTabBehaviorImpl to verify that the tab’s newly committed origin strictly matches the origin for which the activeTab permission was originally granted.
Evaluated with Chrome root at commit: bb48272cafb7e24c93f55ef40da398cd206ee651
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. Please feel free to reach out to me if you have concerns or feedback.