Overview

Medium
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Extensions
DescriptionInformation leak in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassInfo Disclosure
Action tierwatch — information disclosure — security-relevant, below the RCE-class bar
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker496615345
Fix commitf10a719fca1a (chromium/src) +124/-12
CWECWE-200 (Information exposure)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-26 (per vendor advisory)
Fix landed2026-07-28 (public git)
Disclosed2026-09-08
Patch gap42 days — fix public in source before the advisory

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 (the kTab API 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 the tabs API 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 DidFinishNavigation without a preceding ReadyToCommitNavigation, 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.

Key insight
The old check conflated “has an 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

  1. Extension obtains `activeTab` on origin A A malicious or curious extension holding only activeTab is invoked by the user on a page at origin A, granting a tab-specific kTab permission scoped to A.
  2. Cross-origin activation of the tab The tab is moved cross-origin to origin B through a path that commits via DidFinishNavigation without ReadyToCommitNavigation (BFCache restore or prerender activation), delaying revocation of the tab-specific grant.
  3. Stale grant passes the check During that window HasAPIPermissionForTab(tab_id, APIPermissionID::kTab) still returns true, so the pre-patch code set has_permission = true without checking B’s origin.
  4. Cross-origin URL leak GetScrubTabBehavior therefore returns kDontScrubTab, and the extension reads the full URL of origin B through the tabs API even though it was never granted access to B (information disclosure).

Impact Assessment

An extension limited to 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

FunctionChangeNotes
if
chrome/browser/extensions/extension_tab_util.cc
modified

Files Changed

  • chrome/browser/extensions/back_forward_cache_browsertest.cc
  • chrome/browser/extensions/extension_tab_util.cc
  • chrome/browser/extensions/extension_tab_util_unittest.cc

Audit Directions

  • Origin-scoped per-tab permission checks
    audit 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. via HasTabPermissionsForSecurityOrigin) rather than trusting the tab id alone.
  • Delayed revocation on activation paths
    look for navigation-completion handlers that commit through DidFinishNavigation without ReadyToCommitNavigation (BFCache, prerender, other activations), where temporary permissions may outlive the origin they were scoped to.
  • URL scrubbing and tabs surfaces
    review other GetScrubTabBehavior-style callers and tabs/webNavigation APIs for permission logic that treats a per-tab API grant as origin-agnostic and could expose cross-origin URLs.
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.
Loading diff…

Regression Test / PoC

shipped with the fix
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.
Loading diff…

Original Bug Report

reported by vi...@google.com

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.cc
  • chrome/browser/extensions/api/tabs/tabs_event_router.cc
  • chrome/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.

  1. Event Ordering: When a navigation commits, Navigator::DidNavigate first triggers the NavigationEntryCommitted notification. Later, when the NavigationRequest is destroyed, it triggers the DidFinishNavigation notification.
  2. The Leak:
    • TabsEventRouter observes NavigationEntryCommitted and synchronously builds and dispatches the tabs.onUpdated event to extensions.
    • To determine if the new URL should be redacted (scrubbed), it calls ExtensionTabUtil::GetScrubTabBehaviorImpl, which checks if the extension holds the kTab permission for that tab.
    • However, ActiveTabPermissionGranter relies on the later DidFinishNavigation event to revoke the kTab permission on cross-origin navigations.
  3. Result: Because DidFinishNavigation has not yet fired when tabs.onUpdated is dispatched, the extension technically still holds the kTab permission. The scrubbing logic returns kDontScrubTab, 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

  1. An attacker publishes a malicious extension requiring the activeTab permission and registers a listener for chrome.tabs.onUpdated.
  2. The user installs the extension, opens a tab to a benign site (e.g., https://attacker.example), and clicks the extension icon, granting activeTab access.
  3. 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.
  4. As the browser commits the navigation to victim.example, NavigationEntryCommitted fires.
  5. TabsEventRouter synchronously queries the extension’s permissions. Since DidFinishNavigation hasn’t fired to revoke activeTab, the check passes.
  6. The extension’s background script receives the tabs.onUpdated event containing the full victim.example URL, including the sensitive OAuth token.
  7. Shortly after, DidFinishNavigation fires 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.

View on issue tracker