Medium chrome UAF 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactUse after free in Extensions
DescriptionUse after free in Extensions
ComponentExtensions
Sub-componentExtensions
Bug ClassUAF
Tracker513521516
Fix commitf571bdc350d6 (chromium/src) +338/-15
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-20

Changed Functions

FunctionChangeNotes
if
chrome/browser/extensions/menu_manager.cc
modified
ContextMenusTabScrubbingTest
chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
modified
for
chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
modified
switch
chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
modified
if
chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
modified

Files Changed

  • chrome/browser/extensions/menu_manager.cc
  • chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
From f571bdc350d6f8555367db9ceb62eb93c89bdb8b Mon Sep 17 00:00:00 2001
From: Tim <tjudkins@chromium.org>
Date: Thu, 17 Sep 2026 15:46:58 -0700
Subject: [PATCH] [Extensions] Apply tab scrubbing in context menu command execution

When dispatching `chrome.contextMenus.onClicked` in
`MenuManager::ExecuteCommand`, tab objects were constructed using a
hardcoded `kDontScrubTab` policy rather than evaluating extension
permissions. Consequently, tab details were not scrubbed for extensions
lacking corresponding permissions.

Update `MenuManager::ExecuteCommand` to determine tab scrubbing behavior
dynamically via `ExtensionTabUtil::GetScrubTabBehavior`. In addition,
invoke `ActiveTabPermissionGranter::GrantIfRequested` prior to tab
object construction so extensions with the `activeTab` permission
receive the appropriate tab data upon invocation.

Also add tests covering tab scrubbing behavior for context menus.

Bug: 513521516
Change-Id: I170a73d86dc77555ce1a1af85f14b99feaaa9764
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8382844
Reviewed-by: Devlin Cronin <rdevlin.cronin@chromium.org>
Commit-Queue: Tim <tjudkins@chromium.org>
Reviewed-by: Oliver Dunk <oliverdunk@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1700202}
---
diff --git a/chrome/browser/extensions/menu_manager.cc b/chrome/browser/extensions/menu_manager.cc
index 649e665..41b7719d 100644
--- a/chrome/browser/extensions/menu_manager.cc
+++ b/chrome/browser/extensions/menu_manager.cc
@@ -37,6 +37,7 @@
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/extension.h"
 #include "extensions/common/manifest_handlers/background_info.h"
+#include "extensions/common/mojom/context_type.mojom.h"
 #include "extensions/common/mojom/event_dispatcher.mojom.h"
 #include "ipc/constants.mojom.h"
 #include "third_party/blink/public/mojom/context_menu/context_menu.mojom.h"
@@ -737,6 +738,14 @@
       WebViewGuest::FromRenderFrameHost(render_frame_host);
 #endif  // BUILDFLAG(ENABLE_GUEST_VIEW)
 
+  // Note: web_contents are null in unit tests :(
+  if (extension && web_contents) {
+    if (auto* granter =
+            ActiveTabPermissionGranter::FromWebContents(web_contents)) {
+      granter->GrantIfRequested(extension);
+    }
+  }
+
   base::ListValue args;
   args.Append(std::move(properties));
 
@@ -747,15 +756,17 @@
     // Note: web_contents are null in unit tests :(
     if (web_contents) {
       int frame_id = ExtensionApiFrameIdMap::GetFrameId(render_frame_host);
-      if (frame_id != ExtensionApiFrameIdMap::kInvalidFrameId)
+      if (frame_id != ExtensionApiFrameIdMap::kInvalidFrameId) {
         args[0].GetDict().Set("frameId", frame_id);
+      }
 
-      // We intentionally don't scrub the tab data here, since the user chose to
-      // invoke the extension on the page.
-      // TODO(tjudkins) Potentially use GetScrubTabBehavior here to gate based
-      // on permissions.
-      ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior = {
-          ExtensionTabUtil::kDontScrubTab, ExtensionTabUtil::kDontScrubTab};
+      // The contextMenus API is only available to privileged extension
+      // contexts.
+      constexpr mojom::ContextType context_type =
+          mojom::ContextType::kPrivilegedExtension;
+      ExtensionTabUtil::ScrubTabBehavior scrub_tab_behavior =
+          ExtensionTabUtil::GetScrubTabBehavior(extension, context_type,
+                                                web_contents);
       args.Append(ExtensionTabUtil::CreateTabObject(
                       web_contents, scrub_tab_behavior, extension)
                       .ToValue());
@@ -776,15 +787,9 @@
     item->SetChecked(checked);
     args[0].GetDict().Set("checked", item->checked());
 
-    if (extension)
+    if (extension) {
       WriteToStorage(extension, item->id().extension_key);
-  }
-
-  // Note: web_contents are null in unit tests :(
-  if (web_contents &&
-      ActiveTabPermissionGranter::FromWebContents(web_contents)) {
-    ActiveTabPermissionGranter::FromWebContents(web_contents)
-        ->GrantIfRequested(extension);
+    }
   }
   {
     // Dispatch to menu item's .onclick handler (this is the legacy API, from
diff --git a/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc b/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
index 6d9c888a..fc2df13 100644
--- a/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
+++ b/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
@@ -4,14 +4,20 @@
 
 #include <memory>
 #include <optional>
+#include <string>
 #include <utility>
+#include <vector>
 
+#include "base/json/json_reader.h"
 #include "base/memory/raw_ptr.h"
+#include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/values.h"
 #include "chrome/app/chrome_command_ids.h"
 #include "chrome/browser/extensions/context_menu_matcher.h"
 #include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/extensions/extension_context_menu_model.h"
 #include "chrome/browser/extensions/extension_service.h"
 #include "chrome/browser/extensions/menu_manager.h"
 #include "chrome/browser/profiles/profile.h"
@@ -29,8 +35,12 @@
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/common/extension_id.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 "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/embedded_test_server.h"
 #include "ui/base/models/menu_model.h"
 
@@ -292,6 +302,314 @@
   ASSERT_TRUE(RunExtensionTest("context_menus/item_ids")) << message_;
 }
 
+namespace {
+
+// The background script used by the tab scrubbing tests below. Rather than
+// asserting on the click data itself, it echoes the `info` and `tab` objects
+// it was given back to the browser process, so that the expectations can live
+// in the tests themselves. `%s` is the context the menu item is shown in.
+constexpr char kScrubbingScriptTemplate[] = R"(
+  chrome.contextMenus.onClicked.addListener((info, tab) => {
+    chrome.test.sendMessage(JSON.stringify({info: info, tab: tab}));
+  });
+
+  chrome.contextMenus.create(
+      {id: 'test_item', title: 'Test Item', contexts: ['%s']},
+      () => chrome.test.sendMessage('created'));
+)";
+
+// Verifies that the sensitive metadata the extension is not allowed to see was
+// removed from `tab`.
+void ExpectTabScrubbed(const base::DictValue& tab) {
+  EXPECT_FALSE(tab.contains("url"));
+  EXPECT_FALSE(tab.contains("title"));
+  EXPECT_FALSE(tab.contains("favIconUrl"));
+  EXPECT_FALSE(tab.contains("pendingUrl"));
+  // Non-sensitive properties should still be present. This also guards against
+  // an unexpected object trivially satisfying the checks above.
+  EXPECT_TRUE(tab.FindInt("id").has_value());
+}
+
+// Verifies that `tab` still has the metadata for the page at `url`.
+void ExpectTabNotScrubbed(const base::DictValue& tab, const GURL& url) {
+  const std::string* tab_url = tab.FindString("url");
+  ASSERT_TRUE(tab_url);
+  EXPECT_EQ(url.spec(), *tab_url);
+  const std::string* title = tab.FindString("title");
+  ASSERT_TRUE(title);
+  // simple.html has a title of "OK".
+  EXPECT_EQ("OK", *title);
+  EXPECT_TRUE(tab.FindInt("id").has_value());
+}
+
+// Verifies the `pageUrl` the extension was given for the click.
+void ExpectPageUrl(const base::DictValue& info, const GURL& url) {
+  const std::string* page_url = info.FindString("pageUrl");
+  ASSERT_TRUE(page_url);
+  EXPECT_EQ(url.spec(), *page_url);
+}
+
+}  // namespace
+
+class ContextMenusTabScrubbingTest : public ExtensionContextMenuApiTest {
+ public:
+  // The context the extension's menu item is shown in.
+  enum class MenuContext {
+    kPage,
+    kAction,
+  };
+
+  // The `info` and `tab` objects an extension was given for a menu click.
+  struct ClickData {
+    base::DictValue info;
+    base::DictValue tab;
+  };
+
+  void SetUpOnMainThread() override {
+    ExtensionContextMenuApiTest::SetUpOnMainThread();
+    host_resolver()->AddRule("*", "127.0.0.1");
+    ASSERT_TRUE(embedded_test_server()->Start());
+  }
+
+  // Loads an extension with a single context menu item shown in
+  // `menu_context`, granted the contextMenus permission along with any
+  // `extra_permissions` and `host_permissions`. Returns nullptr on failure.
+  const Extension* LoadScrubbingExtension(
+      MenuContext menu_context,
+      const std::vector<std::string>& extra_permissions = {},
+      const std::vector<std::string>& host_permissions = {}) {
+    base::ListValue permissions;
+    permissions.Append("contextMenus");
+    for (const std::string& permission : extra_permissions) {
+      permissions.Append(permission);
+    }
+
+    base::DictValue manifest =
+        base::DictValue()
+            .Set("name", "ContextMenus Tab Scrubbing Test")
+            .Set("version", "1")
+            .Set("manifest_version", 3)
+            .Set("permissions", std::move(permissions))
+            .Set("background",
+                 base::DictValue().Set("service_worker", "sw.js"));
+
+    if (!host_permissions.empty()) {
+      base::ListValue hosts;
+      for (const std::string& host : host_permissions) {
+        hosts.Append(host);
+      }
+      manifest.Set("host_permissions", std::move(hosts));
+    }
+
+    const char* context_name = nullptr;
+    switch (menu_context) {
+      case MenuContext::kPage:
+        context_name = "page";
+        break;
+      case MenuContext::kAction:
+        // Items shown in the action context require the extension to have an
+        // action to show them on.
+        manifest.Set("action", base::DictValue());
+        context_name = "action";
+        break;
+    }
+
+    test_dir_.WriteManifest(manifest);
+    test_dir_.WriteFile(
+        FILE_PATH_LITERAL("sw.js"),
+        base::StringPrintf(kScrubbingScriptTemplate, context_name));
+
+    // Wait for the item to be created, otherwise it won't be in the menu when
+    // one of the Click*ContextMenuItem() methods shows it.
+    ExtensionTestMessageListener created_listener("created");
+    const Extension* extension = LoadExtension(test_dir_.UnpackedPath());
+    if (!extension || !created_listener.WaitUntilSatisfied()) {
+      return nullptr;
+    }
+    return extension;
+  }
+
+  GURL GetTestUrl(std::string_view host) {
+    return embedded_test_server()->GetURL(host, "/simple.html");
+  }
+
+  // Shows a page context menu on the active tab and activates the extension's
+  // item in it, returning the data the extension received for the click.
+  std::optional<ClickData> ClickPageContextMenuItem() {
+    content::RenderFrameHost* frame =
+        GetActiveWebContents()->GetPrimaryMainFrame();
+    content::ContextMenuParams params;
+    params.page_url = frame->GetLastCommittedURL();
+
+    const int command_id =
+        ContextMenuMatcher::ConvertToExtensionsCustomCommandId(/*id=*/0);
+
+    ExtensionTestMessageListener click_listener;
+#if BUILDFLAG(IS_ANDROID)
+    ExtensionMenuModel menu(*frame, params);
+    menu.PopulateModel();
+#else
+    TestRenderViewContextMenu menu(*frame, params);
+    menu.Init();
+#endif
+    EXPECT_TRUE(menu.IsCommandIdVisible(command_id));
+    EXPECT_TRUE(menu.IsCommandIdEnabled(command_id));
+    menu.ExecuteCommand(command_id, /*event_flags=*/0);
+
+    return WaitForClickData(click_listener);
+  }
+
+  // Shows the context menu for the extension's toolbar action and activates
+  // the extension's item in it, returning the data the extension received for
+  // the click.
+  std::optional<ClickData> ClickActionContextMenuItem(
+      const Extension& extension) {
+    const int command_id =
+        ContextMenuMatcher::ConvertToExtensionsCustomCommandId(/*id=*/0);
+    ExtensionContextMenuModel menu(
+        &extension, browser_window_interface(),
+        /*is_pinned=*/true, /*delegate=*/nullptr,
+        /*can_show_icon_in_toolbar=*/true,
+        ExtensionContextMenuModel::ContextMenuSource::kToolbarAction);
+    EXPECT_TRUE(menu.GetIndexOfCommandId(command_id).has_value());
+
+    ExtensionTestMessageListener click_listener;
+    menu.ExecuteCommand(command_id, /*event_flags=*/0);
+
+    return WaitForClickData(click_listener);
+  }
+
+ private:
+  // Waits for the extension to echo back the data for a menu item click.
+  std::optional<ClickData> WaitForClickData(
+      ExtensionTestMessageListener& listener) {
+    if (!listener.WaitUntilSatisfied()) {
+      ADD_FAILURE() << "Never received a click from the extension.";
+      return std::nullopt;
+    }
+
+    std::optional<base::DictValue> click_data =
+        base::JSONReader::ReadDict(listener.message(), base::JSON_PARSE_RFC);
+    base::DictValue* info = click_data ? click_data->FindDict("info") : nullptr;
+    base::DictValue* tab = click_data ? click_data->FindDict("tab") : nullptr;
+    if (!info || !tab) {
+      ADD_FAILURE() << "Unexpected click data: " << listener.message();
+      return std::nullopt;
+    }
+    return ClickData{std::move(*info), std::move(*tab)};
+  }
+
+  // The files for the extension loaded by LoadScrubbingExtension(). Owned by
+  // the fixture so that they outlive the loaded extension.
+  TestExtensionDir test_dir_;
+};
+
+// An extension without tab or host permissions should have sensitive tab
+// metadata scrubbed when its menu item is invoked from the toolbar action.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       ActionContextMenuWithoutPermissionsScrubsTab) {
+  const Extension* extension = LoadScrubbingExtension(MenuContext::kAction);
+  ASSERT_TRUE(extension);
+  ASSERT_TRUE(
+      NavigateToURL(GetActiveWebContents(), GetTestUrl("example.test")));
+
+  std::optional<ClickData> click_data = ClickActionContextMenuItem(*extension);
+  ASSERT_TRUE(click_data);
+  // `pageUrl` is only provided for items invoked on a page.
+  EXPECT_FALSE(click_data->info.contains("pageUrl"));
+  ExpectTabScrubbed(click_data->tab);
+}
+
+// An extension with the activeTab permission is granted access to the tab when
+// its menu item is invoked, so the tab should not be scrubbed.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       ActionContextMenuWithActiveTabDoesNotScrubTab) {
+  const Extension* extension =
+      LoadScrubbingExtension(MenuContext::kAction, {"activeTab"});
+  ASSERT_TRUE(extension);
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickActionContextMenuItem(*extension);
+  ASSERT_TRUE(click_data);
+  EXPECT_FALSE(click_data->info.contains("pageUrl"));
+  ExpectTabNotScrubbed(click_data->tab, url);
+}
+
+// An extension without tab or host permissions should have sensitive tab
+// metadata scrubbed when its menu item is invoked from a page context menu,
+// though it is still told which page the click happened on.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       PageContextMenuWithoutPermissionsScrubsTab) {
+  ASSERT_TRUE(LoadScrubbingExtension(MenuContext::kPage));
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickPageContextMenuItem();
+  ASSERT_TRUE(click_data);
+  ExpectPageUrl(click_data->info, url);
+  ExpectTabScrubbed(click_data->tab);
+}
+
+// An extension with the activeTab permission is granted access to the tab when
+// its menu item is invoked, so the tab should not be scrubbed.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       PageContextMenuWithActiveTabDoesNotScrubTab) {
+  ASSERT_TRUE(LoadScrubbingExtension(MenuContext::kPage, {"activeTab"}));
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickPageContextMenuItem();
+  ASSERT_TRUE(click_data);
+  ExpectPageUrl(click_data->info, url);
+  ExpectTabNotScrubbed(click_data->tab, url);
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc b/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
index 6d9c888a..fc2df13 100644
--- a/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
+++ b/chrome/browser/extensions/api/context_menus/context_menu_apitest.cc
@@ -4,14 +4,20 @@
 
 #include <memory>
 #include <optional>
+#include <string>
 #include <utility>
+#include <vector>
 
+#include "base/json/json_reader.h"
 #include "base/memory/raw_ptr.h"
+#include "base/strings/stringprintf.h"
 #include "base/strings/utf_string_conversions.h"
 #include "base/test/scoped_feature_list.h"
+#include "base/values.h"
 #include "chrome/app/chrome_command_ids.h"
 #include "chrome/browser/extensions/context_menu_matcher.h"
 #include "chrome/browser/extensions/extension_apitest.h"
+#include "chrome/browser/extensions/extension_context_menu_model.h"
 #include "chrome/browser/extensions/extension_service.h"
 #include "chrome/browser/extensions/menu_manager.h"
 #include "chrome/browser/profiles/profile.h"
@@ -29,8 +35,12 @@
 #include "extensions/buildflags/buildflags.h"
 #include "extensions/common/extension_features.h"
 #include "extensions/common/extension_id.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 "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/embedded_test_server.h"
 #include "ui/base/models/menu_model.h"
 
@@ -292,6 +302,314 @@
   ASSERT_TRUE(RunExtensionTest("context_menus/item_ids")) << message_;
 }
 
+namespace {
+
+// The background script used by the tab scrubbing tests below. Rather than
+// asserting on the click data itself, it echoes the `info` and `tab` objects
+// it was given back to the browser process, so that the expectations can live
+// in the tests themselves. `%s` is the context the menu item is shown in.
+constexpr char kScrubbingScriptTemplate[] = R"(
+  chrome.contextMenus.onClicked.addListener((info, tab) => {
+    chrome.test.sendMessage(JSON.stringify({info: info, tab: tab}));
+  });
+
+  chrome.contextMenus.create(
+      {id: 'test_item', title: 'Test Item', contexts: ['%s']},
+      () => chrome.test.sendMessage('created'));
+)";
+
+// Verifies that the sensitive metadata the extension is not allowed to see was
+// removed from `tab`.
+void ExpectTabScrubbed(const base::DictValue& tab) {
+  EXPECT_FALSE(tab.contains("url"));
+  EXPECT_FALSE(tab.contains("title"));
+  EXPECT_FALSE(tab.contains("favIconUrl"));
+  EXPECT_FALSE(tab.contains("pendingUrl"));
+  // Non-sensitive properties should still be present. This also guards against
+  // an unexpected object trivially satisfying the checks above.
+  EXPECT_TRUE(tab.FindInt("id").has_value());
+}
+
+// Verifies that `tab` still has the metadata for the page at `url`.
+void ExpectTabNotScrubbed(const base::DictValue& tab, const GURL& url) {
+  const std::string* tab_url = tab.FindString("url");
+  ASSERT_TRUE(tab_url);
+  EXPECT_EQ(url.spec(), *tab_url);
+  const std::string* title = tab.FindString("title");
+  ASSERT_TRUE(title);
+  // simple.html has a title of "OK".
+  EXPECT_EQ("OK", *title);
+  EXPECT_TRUE(tab.FindInt("id").has_value());
+}
+
+// Verifies the `pageUrl` the extension was given for the click.
+void ExpectPageUrl(const base::DictValue& info, const GURL& url) {
+  const std::string* page_url = info.FindString("pageUrl");
+  ASSERT_TRUE(page_url);
+  EXPECT_EQ(url.spec(), *page_url);
+}
+
+}  // namespace
+
+class ContextMenusTabScrubbingTest : public ExtensionContextMenuApiTest {
+ public:
+  // The context the extension's menu item is shown in.
+  enum class MenuContext {
+    kPage,
+    kAction,
+  };
+
+  // The `info` and `tab` objects an extension was given for a menu click.
+  struct ClickData {
+    base::DictValue info;
+    base::DictValue tab;
+  };
+
+  void SetUpOnMainThread() override {
+    ExtensionContextMenuApiTest::SetUpOnMainThread();
+    host_resolver()->AddRule("*", "127.0.0.1");
+    ASSERT_TRUE(embedded_test_server()->Start());
+  }
+
+  // Loads an extension with a single context menu item shown in
+  // `menu_context`, granted the contextMenus permission along with any
+  // `extra_permissions` and `host_permissions`. Returns nullptr on failure.
+  const Extension* LoadScrubbingExtension(
+      MenuContext menu_context,
+      const std::vector<std::string>& extra_permissions = {},
+      const std::vector<std::string>& host_permissions = {}) {
+    base::ListValue permissions;
+    permissions.Append("contextMenus");
+    for (const std::string& permission : extra_permissions) {
+      permissions.Append(permission);
+    }
+
+    base::DictValue manifest =
+        base::DictValue()
+            .Set("name", "ContextMenus Tab Scrubbing Test")
+            .Set("version", "1")
+            .Set("manifest_version", 3)
+            .Set("permissions", std::move(permissions))
+            .Set("background",
+                 base::DictValue().Set("service_worker", "sw.js"));
+
+    if (!host_permissions.empty()) {
+      base::ListValue hosts;
+      for (const std::string& host : host_permissions) {
+        hosts.Append(host);
+      }
+      manifest.Set("host_permissions", std::move(hosts));
+    }
+
+    const char* context_name = nullptr;
+    switch (menu_context) {
+      case MenuContext::kPage:
+        context_name = "page";
+        break;
+      case MenuContext::kAction:
+        // Items shown in the action context require the extension to have an
+        // action to show them on.
+        manifest.Set("action", base::DictValue());
+        context_name = "action";
+        break;
+    }
+
+    test_dir_.WriteManifest(manifest);
+    test_dir_.WriteFile(
+        FILE_PATH_LITERAL("sw.js"),
+        base::StringPrintf(kScrubbingScriptTemplate, context_name));
+
+    // Wait for the item to be created, otherwise it won't be in the menu when
+    // one of the Click*ContextMenuItem() methods shows it.
+    ExtensionTestMessageListener created_listener("created");
+    const Extension* extension = LoadExtension(test_dir_.UnpackedPath());
+    if (!extension || !created_listener.WaitUntilSatisfied()) {
+      return nullptr;
+    }
+    return extension;
+  }
+
+  GURL GetTestUrl(std::string_view host) {
+    return embedded_test_server()->GetURL(host, "/simple.html");
+  }
+
+  // Shows a page context menu on the active tab and activates the extension's
+  // item in it, returning the data the extension received for the click.
+  std::optional<ClickData> ClickPageContextMenuItem() {
+    content::RenderFrameHost* frame =
+        GetActiveWebContents()->GetPrimaryMainFrame();
+    content::ContextMenuParams params;
+    params.page_url = frame->GetLastCommittedURL();
+
+    const int command_id =
+        ContextMenuMatcher::ConvertToExtensionsCustomCommandId(/*id=*/0);
+
+    ExtensionTestMessageListener click_listener;
+#if BUILDFLAG(IS_ANDROID)
+    ExtensionMenuModel menu(*frame, params);
+    menu.PopulateModel();
+#else
+    TestRenderViewContextMenu menu(*frame, params);
+    menu.Init();
+#endif
+    EXPECT_TRUE(menu.IsCommandIdVisible(command_id));
+    EXPECT_TRUE(menu.IsCommandIdEnabled(command_id));
+    menu.ExecuteCommand(command_id, /*event_flags=*/0);
+
+    return WaitForClickData(click_listener);
+  }
+
+  // Shows the context menu for the extension's toolbar action and activates
+  // the extension's item in it, returning the data the extension received for
+  // the click.
+  std::optional<ClickData> ClickActionContextMenuItem(
+      const Extension& extension) {
+    const int command_id =
+        ContextMenuMatcher::ConvertToExtensionsCustomCommandId(/*id=*/0);
+    ExtensionContextMenuModel menu(
+        &extension, browser_window_interface(),
+        /*is_pinned=*/true, /*delegate=*/nullptr,
+        /*can_show_icon_in_toolbar=*/true,
+        ExtensionContextMenuModel::ContextMenuSource::kToolbarAction);
+    EXPECT_TRUE(menu.GetIndexOfCommandId(command_id).has_value());
+
+    ExtensionTestMessageListener click_listener;
+    menu.ExecuteCommand(command_id, /*event_flags=*/0);
+
+    return WaitForClickData(click_listener);
+  }
+
+ private:
+  // Waits for the extension to echo back the data for a menu item click.
+  std::optional<ClickData> WaitForClickData(
+      ExtensionTestMessageListener& listener) {
+    if (!listener.WaitUntilSatisfied()) {
+      ADD_FAILURE() << "Never received a click from the extension.";
+      return std::nullopt;
+    }
+
+    std::optional<base::DictValue> click_data =
+        base::JSONReader::ReadDict(listener.message(), base::JSON_PARSE_RFC);
+    base::DictValue* info = click_data ? click_data->FindDict("info") : nullptr;
+    base::DictValue* tab = click_data ? click_data->FindDict("tab") : nullptr;
+    if (!info || !tab) {
+      ADD_FAILURE() << "Unexpected click data: " << listener.message();
+      return std::nullopt;
+    }
+    return ClickData{std::move(*info), std::move(*tab)};
+  }
+
+  // The files for the extension loaded by LoadScrubbingExtension(). Owned by
+  // the fixture so that they outlive the loaded extension.
+  TestExtensionDir test_dir_;
+};
+
+// An extension without tab or host permissions should have sensitive tab
+// metadata scrubbed when its menu item is invoked from the toolbar action.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       ActionContextMenuWithoutPermissionsScrubsTab) {
+  const Extension* extension = LoadScrubbingExtension(MenuContext::kAction);
+  ASSERT_TRUE(extension);
+  ASSERT_TRUE(
+      NavigateToURL(GetActiveWebContents(), GetTestUrl("example.test")));
+
+  std::optional<ClickData> click_data = ClickActionContextMenuItem(*extension);
+  ASSERT_TRUE(click_data);
+  // `pageUrl` is only provided for items invoked on a page.
+  EXPECT_FALSE(click_data->info.contains("pageUrl"));
+  ExpectTabScrubbed(click_data->tab);
+}
+
+// An extension with the activeTab permission is granted access to the tab when
+// its menu item is invoked, so the tab should not be scrubbed.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       ActionContextMenuWithActiveTabDoesNotScrubTab) {
+  const Extension* extension =
+      LoadScrubbingExtension(MenuContext::kAction, {"activeTab"});
+  ASSERT_TRUE(extension);
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickActionContextMenuItem(*extension);
+  ASSERT_TRUE(click_data);
+  EXPECT_FALSE(click_data->info.contains("pageUrl"));
+  ExpectTabNotScrubbed(click_data->tab, url);
+}
+
+// An extension without tab or host permissions should have sensitive tab
+// metadata scrubbed when its menu item is invoked from a page context menu,
+// though it is still told which page the click happened on.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       PageContextMenuWithoutPermissionsScrubsTab) {
+  ASSERT_TRUE(LoadScrubbingExtension(MenuContext::kPage));
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickPageContextMenuItem();
+  ASSERT_TRUE(click_data);
+  ExpectPageUrl(click_data->info, url);
+  ExpectTabScrubbed(click_data->tab);
+}
+
+// An extension with the activeTab permission is granted access to the tab when
+// its menu item is invoked, so the tab should not be scrubbed.
+IN_PROC_BROWSER_TEST_F(ContextMenusTabScrubbingTest,
+                       PageContextMenuWithActiveTabDoesNotScrubTab) {
+  ASSERT_TRUE(LoadScrubbingExtension(MenuContext::kPage, {"activeTab"}));
+  const GURL url = GetTestUrl("example.test");
+  ASSERT_TRUE(NavigateToURL(GetActiveWebContents(), url));
+
+  std::optional<ClickData> click_data = ClickPageContextMenuItem();
... (truncated)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.