CVE-2026-12320
Overview
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 viaverifyUserOSAuth/OSKeyStore.canReauth(), requiring the operating-system credential before revealing or exporting a password. - `requestReauth`
- the shared
LoginHelperentry 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
requestReauthbefore 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().
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
- 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.
- Trigger first disclosure The attacker (with local access to the unlocked session) reveals a password, satisfying the Primary Password dialog once.
- Bypass the second prompt
Because
requestReauthrelied on a caller-suppliedOSReauthEnabledflag inconsistent with the disabled OS-auth setting, the subsequent reveal took a path that failed to re-show the Primary Password dialog. - Read the secret The stored password is disclosed without the expected re-authentication, yielding the plaintext credential.
Impact Assessment
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
| Function | Change | Notes |
|---|---|---|
iftoolkit/components/passwordmgr/LoginHelper.sys.mjs |
modified | |
add_taskbrowser/components/aboutlogins/tests/browser/browser_osAuthDialog.js |
modified |
Files Changed
browser/components/aboutlogins/AboutLoginsParent.sys.mjsbrowser/components/aboutlogins/tests/browser/browser_osAuthDialog.jstoolkit/components/passwordmgr/LoginHelper.sys.mjstoolkit/components/satchel/megalist/MegalistViewModel.sys.mjstoolkit/components/satchel/megalist/aggregator/datasources/LoginDataSource.sys.mjs
Audit Directions
- Caller-supplied security flagsFlag 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 pathsAudit every Password Manager disclosure entry that calls
requestReauthto confirm theisOSAuthEnabled && (!token.hasPassword || token.isLoggedIn())branch and the fall-through both enforce a fresh prompt. - Time-of-check driftLook for settings like
getOSAuthEnabled()read early and reused later, where the cached value can diverge from the live setting before the sensitive action runs.
Patch
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(
Regression Test / PoC
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(