Low firefox Info Disclosure 🔧 Commit mapped

Overview

Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Impactlow
DescriptionInformation disclosure in the Password Manager component
ComponentToolkit
Bug ClassInfo Disclosure
Tracker2027572
Fix commit90865a09f546 (firefox) +69/-13
CISA KEVNot listed
CreditedAv0id
Disclosed2026-06-16

Background

Primary Password (PrP)
an optional master password (token.hasPassword) that must be entered to unlock (token.isLoggedIn()) access to stored logins.
OS auth reauth
a re-authentication step gated by LoginHelper.getOSAuthEnabled() and performed via verifyUserOSAuth / OSKeyStore.canReauth(), requiring the operating-system credential before revealing or exporting a password.
`requestReauth`
the shared LoginHelper entry point that decides whether a sensitive Password Manager action (reveal, copy, export) is authorized or must prompt.
`about:logins` / Megalist (CPM)
the Password Manager UIs whose reveal and export flows call requestReauth before disclosing a stored secret.

Root Cause Analysis

The vulnerable path is LoginHelper.requestReauth, which accepted an OSReauthEnabled boolean supplied by each caller instead of determining the OS-auth state itself. Callers derived this flag inconsistently and at different times — the about:logins export path hardcoded true, while other paths computed getOSAuthEnabled() separately — so the flag passed in could disagree with the actual current OS-auth setting. The gating expression (!token.hasPassword || token.isLoggedIn()) && OSReauthEnabled therefore mis-routed: when a Primary Password existed and was already unlocked (token.isLoggedIn() true) but OS auth was disabled, the code could fall through without re-prompting, disclosing the stored password on a subsequent reveal even though the Primary Password dialog should have been shown again. The invariant violated is that a sensitive disclosure must be governed by the real, current OS-auth setting — not a caller-provided copy that may be stale or wrong.

The fix removes the OSReauthEnabled parameter entirely and recomputes isOSAuthEnabled = this.getOSAuthEnabled() inside requestReauth, so a single authoritative source drives the decision. It also corrects the telemetry to distinguish a disabled setting (success_disabled) from an unsupported platform (success_unsupported_platform) via OSKeyStore.canReauth().

Key insight
The core mistake was trusting a security-critical OSReauthEnabled flag passed in by each caller rather than reading the authoritative setting at the decision point; the fix eliminates the parameter and has requestReauth compute getOSAuthEnabled() itself so no caller can supply an inconsistent value.

Attack Path

  1. Establish the vulnerable state On a profile where OS auth is disabled but a Primary Password is set and currently unlocked, the Password Manager should still re-prompt before each reveal.
  2. Trigger first disclosure The attacker (with local access to the unlocked session) reveals a password, satisfying the Primary Password dialog once.
  3. Bypass the second prompt Because requestReauth relied on a caller-supplied OSReauthEnabled flag inconsistent with the disabled OS-auth setting, the subsequent reveal took a path that failed to re-show the Primary Password dialog.
  4. Read the secret The stored password is disclosed without the expected re-authentication, yielding the plaintext credential.

Impact Assessment

An attacker with local access to a browser session gains disclosure of stored Password Manager credentials without the Primary Password re-prompt that should protect repeat reveals. This occurs in the parent-process Password Manager UI (about:logins / Megalist CPM) and requires the specific precondition that OS auth is disabled while a Primary Password exists and is already unlocked. Severity is low: it is a local information-disclosure logic error, not a remote or memory-safety issue.

Changed Functions

FunctionChangeNotes
if
toolkit/components/passwordmgr/LoginHelper.sys.mjs
modified
add_task
browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
modified

Files Changed

  • browser/components/aboutlogins/AboutLoginsParent.sys.mjs
  • browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
  • toolkit/components/passwordmgr/LoginHelper.sys.mjs
  • toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
  • toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs

Audit Directions

  • Caller-supplied security flags
    Flag any auth or capability gate whose deciding boolean is passed in by callers; recompute the authoritative setting at the decision point instead of trusting a copy.
  • Reveal/export/copy paths
    Audit every Password Manager disclosure entry that calls requestReauth to confirm the isOSAuthEnabled && (!token.hasPassword || token.isLoggedIn()) branch and the fall-through both enforce a fresh prompt.
  • Time-of-check drift
    Look for settings like getOSAuthEnabled() read early and reused later, where the cached value can diverge from the live setting before the sensitive action runs.
diff --git a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs
index fd1f392ed41..4ac658cacc9 100644
--- a/browser/components/aboutlogins/AboutLoginsParent.sys.mjs
+++ b/browser/components/aboutlogins/AboutLoginsParent.sys.mjs
@@ -276,7 +276,6 @@ export class AboutLoginsParent extends JSWindowActorParent {
 
     let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth(
       this.browsingContext.embedderElement,
-      isOSAuthEnabled,
       AboutLogins._authExpirationTime,
       messageText.value,
       captionText.value,
@@ -398,7 +397,6 @@ export class AboutLoginsParent extends JSWindowActorParent {
     let reason = "export_logins";
     let { isAuthorized, telemetryEvent } = await lazy.LoginHelper.requestReauth(
       this.browsingContext.embedderElement,
-      true,
       null, // Prompt regardless of a recent prompt
       messageText.value,
       captionText.value,
diff --git a/toolkit/components/passwordmgr/LoginHelper.sys.mjs b/toolkit/components/passwordmgr/LoginHelper.sys.mjs
index 8542feb5d34..91d496b46ca 100644
--- a/toolkit/components/passwordmgr/LoginHelper.sys.mjs
+++ b/toolkit/components/passwordmgr/LoginHelper.sys.mjs
@@ -1639,7 +1639,6 @@ export const LoginHelper = {
    *
    * @param {Element} browser
    *        The <browser> that the prompt should be shown on
-   * @param OSReauthEnabled Boolean indicating if OS reauth should be tried
    * @param expirationTime Optional timestamp indicating next required re-authentication
    * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used.
    * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used.
@@ -1647,7 +1646,6 @@ export const LoginHelper = {
    */
   async requestReauth(
     browser,
-    OSReauthEnabled,
     expirationTime,
     messageText,
     captionText,
@@ -1676,12 +1674,16 @@ export const LoginHelper = {
       };
     }
 
+    let isOSAuthEnabled = this.getOSAuthEnabled();
+
     // Default to true if there is no primary password and OS reauth is not available
-    if (!token.hasPassword && !OSReauthEnabled) {
+    if (!token.hasPassword && !isOSAuthEnabled) {
       isAuthorized = true;
       telemetryEvent = {
         name: "reauthenticateOsAuth",
-        value: "success_disabled",
+        value: lazy.OSKeyStore.canReauth()
+          ? "success_disabled"
+          : "success_unsupported_platform",
       };
       return {
         isAuthorized,
@@ -1689,8 +1691,8 @@ export const LoginHelper = {
       };
     }
     // Use the OS auth dialog if there is no primary password
-    // or if primary password is already unlocked.
-    if ((!token.hasPassword || token.isLoggedIn()) && OSReauthEnabled) {
+    // or if primary password is already unlocked and os auth is enabled.
+    if (isOSAuthEnabled && (!token.hasPassword || token.isLoggedIn())) {
       let result;
       try {
         isAuthorized = await this.verifyUserOSAuth(
diff --git a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
index 07f233f480c..d4af6fc6588 100644
--- a/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
+++ b/toolkit/components/satchel/megalist/MegalistViewModel.sys.mjs
@@ -222,12 +222,10 @@ export class MegalistViewModel {
       ReauthPrimaryPassword: "reauth_cpm",
     };
     const reason = reasonMap[command.id];
-    const osAuthForPw = lazy.LoginHelper.getOSAuthEnabled();
     const { isAuthorized } = await lazy.LoginHelper.requestReauth(
       lazy.BrowserWindowTracker.getTopWindow({
         allowFromInactiveWorkspace: true,
       }).gBrowser,
-      osAuthForPw,
       this.#authExpirationTime,
       command.OSAuthPromptMessage,
       command.OSAuthCaptionMessage,
diff --git a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
index bc1600fd838..cef10aaa586 100644
--- a/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
+++ b/toolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
@@ -561,12 +561,9 @@ export class LoginDataSource extends DataSourceBase {
       allowFromInactiveWorkspace: true,
     }).browsingContext;
 
-    const isOSAuthEnabled = LoginHelper.getOSAuthEnabled();
-
     const reason = "export_cpm";
     let { isAuthorized, telemetryEvent } = await LoginHelper.requestReauth(
       browsingContext,
-      isOSAuthEnabled,
       null, // Prompt regardless of a recent prompt
       this.#exportPasswordsStrings.OSReauthMessage,
       this.#exportPasswordsStrings.OSAuthDialogCaption,
diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
index 10e3ea794f0..07e027ffa92 100644
--- a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
+++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
@@ -234,6 +234,67 @@ add_task(async function test_os_auth_and_prp() {
   LoginTestUtils.primaryPassword.disable();
 });
 
+// When OS auth is disabled and a primary password exists but is already
+// unlocked, the primary password dialog should be shown.
+add_task(
+  async function test_prp_shown_when_os_auth_disabled_and_prp_unlocked() {
+    const osAuthWasEnabled = LoginHelper.getOSAuthEnabled();
+    LoginHelper.setOSAuthEnabled(false);
+
+    await BrowserTestUtils.openNewForegroundTab({
+      gBrowser,
+      url: "about:logins",
+    });
+
+    // Enable PrP after opening the tab so it doesn't prompt on load.
+    LoginTestUtils.primaryPassword.enable();
+    info("PrP has been enabled");
+
+    // First reveal: token is locked so PrP dialog must appear.
+    let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let loginItem = content.document.querySelector("login-item");
+      loginItem.shadowRoot.querySelector(".reveal-password-checkbox").click();
+    });
+    await mpDialogShown;
+    info("PrP dialog shown and authenticated");
+
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let revealCheckbox = content.document
+        .querySelector("login-item")
+        .shadowRoot.querySelector(".reveal-password-checkbox");
+      Assert.ok(revealCheckbox.checked, "password revealed after PrP auth");
+      revealCheckbox.click();
+      Assert.ok(!revealCheckbox.checked, "password hidden again");
+    });
+
+    // PrP is now unlocked and OS auth is disabled. The second reveal should
+    // prompt for PrP again.
+    mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let loginItem = content.document.querySelector("login-item");
+      info("Clicking reveal, expecting PrP dialog (OS auth is disabled)");
+      loginItem.shadowRoot.querySelector(".reveal-password-checkbox").click();
+    });
+    await mpDialogShown;
+    info("PrP dialog shown again as expected when OS auth is disabled");
+
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let revealCheckbox = content.document
+        .querySelector("login-item")
+        .shadowRoot.querySelector(".reveal-password-checkbox");
+      Assert.ok(
+        revealCheckbox.checked,
+        "reveal checkbox should be checked after PrP auth"
+      );
+    });
+
+    BrowserTestUtils.removeTab(gBrowser.selectedTab);
+    LoginTestUtils.primaryPassword.disable();
+    LoginHelper.setOSAuthEnabled(osAuthWasEnabled);
+  }
+);
+
 add_task(async function test_osAuth_shown_on_copy_password() {
   if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
     Assert.ok(
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
index 10e3ea794f0..07e027ffa92 100644
--- a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
+++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
@@ -234,6 +234,67 @@ add_task(async function test_os_auth_and_prp() {
   LoginTestUtils.primaryPassword.disable();
 });
 
+// When OS auth is disabled and a primary password exists but is already
+// unlocked, the primary password dialog should be shown.
+add_task(
+  async function test_prp_shown_when_os_auth_disabled_and_prp_unlocked() {
+    const osAuthWasEnabled = LoginHelper.getOSAuthEnabled();
+    LoginHelper.setOSAuthEnabled(false);
+
+    await BrowserTestUtils.openNewForegroundTab({
+      gBrowser,
+      url: "about:logins",
+    });
+
+    // Enable PrP after opening the tab so it doesn't prompt on load.
+    LoginTestUtils.primaryPassword.enable();
+    info("PrP has been enabled");
+
+    // First reveal: token is locked so PrP dialog must appear.
+    let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let loginItem = content.document.querySelector("login-item");
+      loginItem.shadowRoot.querySelector(".reveal-password-checkbox").click();
+    });
+    await mpDialogShown;
+    info("PrP dialog shown and authenticated");
+
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let revealCheckbox = content.document
+        .querySelector("login-item")
+        .shadowRoot.querySelector(".reveal-password-checkbox");
+      Assert.ok(revealCheckbox.checked, "password revealed after PrP auth");
+      revealCheckbox.click();
+      Assert.ok(!revealCheckbox.checked, "password hidden again");
+    });
+
+    // PrP is now unlocked and OS auth is disabled. The second reveal should
+    // prompt for PrP again.
+    mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let loginItem = content.document.querySelector("login-item");
+      info("Clicking reveal, expecting PrP dialog (OS auth is disabled)");
+      loginItem.shadowRoot.querySelector(".reveal-password-checkbox").click();
+    });
+    await mpDialogShown;
+    info("PrP dialog shown again as expected when OS auth is disabled");
+
+    await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+      let revealCheckbox = content.document
+        .querySelector("login-item")
+        .shadowRoot.querySelector(".reveal-password-checkbox");
+      Assert.ok(
+        revealCheckbox.checked,
+        "reveal checkbox should be checked after PrP auth"
+      );
+    });
+
+    BrowserTestUtils.removeTab(gBrowser.selectedTab);
+    LoginTestUtils.primaryPassword.disable();
+    LoginHelper.setOSAuthEnabled(osAuthWasEnabled);
+  }
+);
+
 add_task(async function test_osAuth_shown_on_copy_password() {
   if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
     Assert.ok(
Loading diff…