Low firefox Logic Error 🔧 Commit mapped

Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionSpoofing issue in WebExtensions
ComponentCore
Bug ClassLogic Error
Tracker1940116
Fix commit711d6de50989 (firefox) +300/-5
CISA KEVNot listed
CreditedKanaru Sato
Disclosed2026-05-19

Changed Functions

FunctionChangeNotes
if
browser/components/extensions/ExtensionPopups.sys.mjs
modified

Files Changed

  • browser/components/extensions/ExtensionPopups.sys.mjs
  • browser/components/extensions/test/browser/browser.toml
  • browser/components/extensions/test/browser/browser_ext_popup_after_close.js
diff --git a/browser/components/extensions/ExtensionPopups.sys.mjs b/browser/components/extensions/ExtensionPopups.sys.mjs
index 1cd35175507..d29e7af45d9 100644
--- a/browser/components/extensions/ExtensionPopups.sys.mjs
+++ b/browser/components/extensions/ExtensionPopups.sys.mjs
@@ -4,17 +4,19 @@
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-const lazy = {};
+import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
 
-ChromeUtils.defineESModuleGetters(lazy, {
+const lazy = XPCOMUtils.declareLazy({
   CustomizableUI:
     "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
   ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
   setTimeout: "resource://gre/modules/Timer.sys.mjs",
-});
 
-import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs";
-import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+  // Delay defaults to 500 ms via modules/libpref/init/all.js, for all builds:
+  delayBeforeEnablingButtons: { pref: "security.notification_enable_delay" },
+});
 
 var { DefaultWeakMap, promiseEvent } = ExtensionUtils;
 
@@ -38,6 +40,33 @@ function promisePopupShown(popup) {
   });
 }
 
+function addPanelHidingHandler(panel) {
+  function handleClick(event) {
+    if (
+      event.target.closest(
+        "panel:not(#unified-extensions-panel),#notifications-toolbar"
+      )
+    ) {
+      event.preventDefault();
+      event.stopImmediatePropagation();
+      Services.console.logStringMessage(
+        "Ignored click shortly after extension popup was closed"
+      );
+    }
+  }
+  panel.addEventListener(
+    "popuphiding",
+    () => {
+      const window = panel.ownerGlobal;
+      window.addEventListener("click", handleClick, true);
+      window.setTimeout(() => {
+        window.removeEventListener("click", handleClick, true);
+      }, lazy.delayBeforeEnablingButtons);
+    },
+    { once: true, capture: true }
+  );
+}
+
 const REMOTE_PANEL_ID = "webextension-remote-preload-panel";
 
 export class BasePopup {
@@ -480,6 +509,7 @@ export class PanelPopup extends BasePopup {
       },
       { once: true }
     );
+    addPanelHidingHandler(panel);
 
     super(extension, panel, popupURL, browserStyle);
   }
@@ -588,6 +618,7 @@ export class ViewPopup extends BasePopup {
       once: true,
       capture: true,
     });
+    addPanelHidingHandler(this.panel);
     if (this.extension.remote) {
       this.panel.setAttribute("remote", "true");
     }
diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml
index 9cf6b0a42a5..da13ad9c0ea 100644
--- a/browser/components/extensions/test/browser/browser.toml
+++ b/browser/components/extensions/test/browser/browser.toml
@@ -370,6 +370,8 @@ https_first_disabled = true
 
 ["browser_ext_persistent_storage_permission_indication.js"]
 
+["browser_ext_popup_after_close.js"]
+
 ["browser_ext_popup_api_injection.js"]
 
 ["browser_ext_popup_background.js"]
diff --git a/browser/components/extensions/test/browser/browser_ext_popup_after_close.js b/browser/components/extensions/test/browser/browser_ext_popup_after_close.js
new file mode 100644
index 00000000000..bcc2d3ccc30
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_popup_after_close.js
@@ -0,0 +1,262 @@
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// Default value of security.notification_enable_delay is 500.
+// To avoid unnecessary delays in the test, we choose a short (non-zero) delay.
+const delayBeforeEnablingButtons = 10;
+const MSG_NO_CLICK = "Ignored click shortly after extension popup was closed";
+
+let gPopupExtension;
+
+add_setup(async () => {
+  registerCleanupFunction(() => gPopupExtension.unload());
+  gPopupExtension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      page_action: {
+        default_popup: "popup.html",
+        show_matches: ["<all_urls>"],
+      },
+      browser_action: {
+        default_popup: "popup.html",
+      },
+    },
+    files: {
+      "popup.html": "Extension popup here",
+    },
+  });
+  await gPopupExtension.startup();
+  let tab = await BrowserTestUtils.openNewForegroundTab(
+    gBrowser,
+    "https://example.com/",
+    true,
+    true
+  );
+  gBrowser.selectedTab = tab;
+
+  registerCleanupFunction(async () => {
+    // Clean up anything left behind by showNotificationPanel().
+    await PermissionTestUtils.remove(
+      tab.linkedBrowser.currentURI,
+      "desktop-notification"
+    );
+    BrowserTestUtils.removeTab(tab);
+  });
+
+  await SpecialPowers.pushPrefEnv({
+    set: [["security.notification_enable_delay", delayBeforeEnablingButtons]],
+  });
+});
+
+async function waitForDelayElapsed() {
+  info(`Waiting for delay ${delayBeforeEnablingButtons}ms to be elapsed`);
+  // Waiting for an "arbitrary" time is unavoidable because we need to verify
+  // the effectiveness of a time-based delay. The timeout is chosen to be small
+  // (so the test does not take too long) and the actions around the delay are
+  // deterministic (to minimize the odds of intermittent failures).
+  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+  await new Promise(r => setTimeout(r, delayBeforeEnablingButtons));
+}
+
+async function showNotificationPanel() {
+  const shownPromise = BrowserTestUtils.waitForEvent(
+    PopupNotifications.panel,
+    "popupshown"
+  );
+
+  // Remove previously stored perm if any, to make sure that the
+  // Notification.requestPermission call does not resolve immediately.
+  await PermissionTestUtils.remove(
+    gBrowser.selectedBrowser.currentURI,
+    "desktop-notification"
+  );
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+    // Notification.requestPermission() requires user activation.
+    content.document.notifyUserGestureActivation();
+    content.Notification.requestPermission(); // Fire and forget.
+  });
+  info("Waiting for Notification panel to appear");
+  await shownPromise;
+  return PopupNotifications.panel;
+}
+
+function clickNotificationPanel(panel) {
+  is(panel.state, "open", "Sanity check: notification panel is open");
+  const but = panel.querySelector("button.popup-notification-primary-button");
+  ok(but, "Found button to click in notification panel");
+  EventUtils.synthesizeMouseAtCenter(but, {}, window);
+}
+
+function clickNotificationInToolbar(notifInToolbar) {
+  ok(
+    notifInToolbar.closest("#notifications-toolbar"),
+    "Sanity check: notification is inside toolbar"
+  );
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml
index 9cf6b0a42a5..da13ad9c0ea 100644
--- a/browser/components/extensions/test/browser/browser.toml
+++ b/browser/components/extensions/test/browser/browser.toml
@@ -370,6 +370,8 @@ https_first_disabled = true
 
 ["browser_ext_persistent_storage_permission_indication.js"]
 
+["browser_ext_popup_after_close.js"]
+
 ["browser_ext_popup_api_injection.js"]
 
 ["browser_ext_popup_background.js"]
diff --git a/browser/components/extensions/test/browser/browser_ext_popup_after_close.js b/browser/components/extensions/test/browser/browser_ext_popup_after_close.js
new file mode 100644
index 00000000000..bcc2d3ccc30
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ext_popup_after_close.js
@@ -0,0 +1,262 @@
+"use strict";
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+  "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+// Default value of security.notification_enable_delay is 500.
+// To avoid unnecessary delays in the test, we choose a short (non-zero) delay.
+const delayBeforeEnablingButtons = 10;
+const MSG_NO_CLICK = "Ignored click shortly after extension popup was closed";
+
+let gPopupExtension;
+
+add_setup(async () => {
+  registerCleanupFunction(() => gPopupExtension.unload());
+  gPopupExtension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      page_action: {
+        default_popup: "popup.html",
+        show_matches: ["<all_urls>"],
+      },
+      browser_action: {
+        default_popup: "popup.html",
+      },
+    },
+    files: {
+      "popup.html": "Extension popup here",
+    },
+  });
+  await gPopupExtension.startup();
+  let tab = await BrowserTestUtils.openNewForegroundTab(
+    gBrowser,
+    "https://example.com/",
+    true,
+    true
+  );
+  gBrowser.selectedTab = tab;
+
+  registerCleanupFunction(async () => {
+    // Clean up anything left behind by showNotificationPanel().
+    await PermissionTestUtils.remove(
+      tab.linkedBrowser.currentURI,
+      "desktop-notification"
+    );
+    BrowserTestUtils.removeTab(tab);
+  });
+
+  await SpecialPowers.pushPrefEnv({
+    set: [["security.notification_enable_delay", delayBeforeEnablingButtons]],
+  });
+});
+
+async function waitForDelayElapsed() {
+  info(`Waiting for delay ${delayBeforeEnablingButtons}ms to be elapsed`);
+  // Waiting for an "arbitrary" time is unavoidable because we need to verify
+  // the effectiveness of a time-based delay. The timeout is chosen to be small
+  // (so the test does not take too long) and the actions around the delay are
+  // deterministic (to minimize the odds of intermittent failures).
+  // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+  await new Promise(r => setTimeout(r, delayBeforeEnablingButtons));
+}
+
+async function showNotificationPanel() {
+  const shownPromise = BrowserTestUtils.waitForEvent(
+    PopupNotifications.panel,
+    "popupshown"
+  );
+
+  // Remove previously stored perm if any, to make sure that the
+  // Notification.requestPermission call does not resolve immediately.
+  await PermissionTestUtils.remove(
+    gBrowser.selectedBrowser.currentURI,
+    "desktop-notification"
+  );
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+    // Notification.requestPermission() requires user activation.
+    content.document.notifyUserGestureActivation();
+    content.Notification.requestPermission(); // Fire and forget.
+  });
+  info("Waiting for Notification panel to appear");
+  await shownPromise;
+  return PopupNotifications.panel;
+}
+
+function clickNotificationPanel(panel) {
+  is(panel.state, "open", "Sanity check: notification panel is open");
+  const but = panel.querySelector("button.popup-notification-primary-button");
+  ok(but, "Found button to click in notification panel");
+  EventUtils.synthesizeMouseAtCenter(but, {}, window);
+}
+
+function clickNotificationInToolbar(notifInToolbar) {
+  ok(
+    notifInToolbar.closest("#notifications-toolbar"),
+    "Sanity check: notification is inside toolbar"
+  );
+  const but = notifInToolbar.closeButton;
+  ok(but, "Found button to click in notification in toolbar");
+  EventUtils.synthesizeMouseAtCenter(but, {}, window);
+}
+
+// Verify that clicks are temporarily ignored.
+// triggerRealClick should try to click on a button.
+async function verifyClickImmediatelyAfterPopupClose({
+  triggerRealClick,
+  promiseFinalClickResult,
+}) {
+  let { messages: m1 } = await AddonTestUtils.promiseConsoleOutput(() => {
+    // NOTE: This is the very first thing that runs immediately after the popup
+    // is closed. The lack of other async delay ensures that we can pick a
+    // short delayBeforeEnablingButtons (security.notification_enable_delay)
+    // value for fast yet deterministic tests.
+    triggerRealClick();
+    triggerRealClick();
+    triggerRealClick();
+  });
+
+  is(
+    m1.filter(m => m.message.includes(MSG_NO_CLICK)).length,
+    3,
+    "Click should be ignored while the delay is in effect"
+  );
+
+  await waitForDelayElapsed();
+  let finalClickResult = promiseFinalClickResult();
+  let { messages: m2 } = await AddonTestUtils.promiseConsoleOutput(() => {
+    triggerRealClick();
+  });
+  is(
+    m2.filter(m => m.message.includes(MSG_NO_CLICK)).length,
+    0,
+    "Click should be processed as usual after delay"
+  );
+  await finalClickResult;
+  info("Final click was effective");
+}
+
+add_task(async function test_panel_click_after_browserAction_close() {
+  let otherPanel = await showNotificationPanel();
+
+  let popupOpened = awaitExtensionPanel(gPopupExtension);
+  await clickBrowserAction(gPopupExtension);
+  await popupOpened;
+  info("Browser action panel opened");
+  await closeBrowserAction(gPopupExtension);
+  info("Browser action panel closed");
+
+  await verifyClickImmediatelyAfterPopupClose({
+    triggerRealClick: () => clickNotificationPanel(otherPanel),
+    promiseFinalClickResult() {
+      return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden");
+    },
+  });
+});
+
+// Verify that the common logic also works for page actions. We do not need to
+// enumerate every case, but as a sanity check do it at least once.
+add_task(async function test_panel_click_after_pageAction_close() {
+  let otherPanel = await showNotificationPanel();
+
+  let popupOpened = awaitExtensionPanel(gPopupExtension);
+  await clickPageAction(gPopupExtension);
+  await popupOpened;
+  info("pageAction panel opened");
+  await closePageAction(gPopupExtension);
+  info("pageAction panel closed");
+
+  await verifyClickImmediatelyAfterPopupClose({
+    triggerRealClick: () => clickNotificationPanel(otherPanel),
+    promiseFinalClickResult() {
+      return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden");
+    },
+  });
+});
+
+// Verify that we temporarily ignore clicks inside the notification toolbar
+// (#notifications-toolbar) when needed. There are many ways to trigger this,
+// we test it via the popup blocker.
+add_task(async function test_toolbar_click_after_browserAction_close() {
+  await SpecialPowers.pushPrefEnv({
+    set: [["dom.disable_open_during_load", true]],
+  });
+
+  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+    // We want window.open() to be rejected.
+    content.document.clearUserGestureActivation();
+    let win = content.wrappedJSObject.window.open();
+    Assert.ok(!win, "window.open() should be blocked by popup blocker");
+  });
+  let notifInToolbar = await TestUtils.waitForCondition(() => {
+    let notificationBox = gBrowser.getNotificationBox();
+    return notificationBox.getNotificationWithValue("popup-blocked");
+  });
+
+  let popupOpened = awaitExtensionPanel(gPopupExtension);
+  await clickBrowserAction(gPopupExtension);
+  await popupOpened;
+  info("Browser action panel opened");
+  await closeBrowserAction(gPopupExtension);
+  info("Browser action panel closed");
+
+  await verifyClickImmediatelyAfterPopupClose({
+    triggerRealClick: () => clickNotificationInToolbar(notifInToolbar),
+    promiseFinalClickResult() {
+      return TestUtils.waitForCondition(() => !notifInToolbar.isConnected);
+    },
+  });
+  await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_click_inside_extensions_panel_is_unaffected() {
+  await SpecialPowers.pushPrefEnv({
+    // Use the default delay instead of a short delay, to make sure that even
+    // if the extensions panel initialization is slow, that we'd catch
+    // unexpectedly ignored clicks, if any.
+    set: [["security.notification_enable_delay", 500]],
+  });
+
+  let popupOpened = awaitExtensionPanel(gPopupExtension);
+  await clickBrowserAction(gPopupExtension);
+  await popupOpened;
+  info("Browser action panel opened");
+  await closeBrowserAction(gPopupExtension);
+  info("Browser action panel closed");
+
+  let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => {
+    const viewShown = BrowserTestUtils.waitForEvent(
+      gUnifiedExtensions.panel.querySelector("#unified-extensions-view"),
+      "ViewShown"
+    );
+    EventUtils.synthesizeMouseAtCenter(gUnifiedExtensions.button, {}, window);
+    await viewShown;
+    info("Extensions panel is shown, now clicking on extension button");
+    const { node } = getBrowserActionWidget(gPopupExtension).forWindow(window);
+    const but = node.querySelector(".unified-extensions-item-action-button");
+
+    let popupOpenedAgain = awaitExtensionPanel(gPopupExtension);
+    EventUtils.synthesizeMouseAtCenter(but, {}, window);
+    let popupBrowser = await popupOpenedAgain;
+
+    // Since we have an open panel anyway, let's check what happens when we try
+    // to click inside. Although our implementation does not special-case
+    // extension popups, it appears that the click is not intercepted.
+    info("Extension popup was opened, now clicking inside");
+    await BrowserTestUtils.synthesizeMouseAtCenter("body", {}, popupBrowser);
+
+    await closeBrowserAction(gPopupExtension);
+  });
+  is(
+    messages.filter(m => m.message.includes(MSG_NO_CLICK)).length,
+    0,
+    "None of the clicks in the extensions panel should be ignored"
+  );
+
+  await SpecialPowers.popPrefEnv();
+});
Loading diff…