Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInformation leak in Extensions
DescriptionInformation leak in Extensions
ComponentExtensions
Bug ClassLogic Error
Tracker496615345
Fix commitf10a719fca1a (chromium/src) +124/-12
CISA KEVNot listed
CreditedGoogle
Disclosed2026-09-08

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
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/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.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/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);
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