diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 19:33:14 +0000 |
commit | 36d22d82aa202bb199967e9512281e9a53db42c9 (patch) | |
tree | 105e8c98ddea1c1e4784a60a5a6410fa416be2de /toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs | |
parent | Initial commit. (diff) | |
download | firefox-esr-upstream.tar.xz firefox-esr-upstream.zip |
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs')
-rw-r--r-- | toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs | 1115 |
1 files changed, 1115 insertions, 0 deletions
diff --git a/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs b/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs new file mode 100644 index 0000000000..6d66b17d63 --- /dev/null +++ b/toolkit/components/passwordmgr/LoginManagerAuthPrompter.sys.mjs @@ -0,0 +1,1115 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * 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/. */ + +import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { PromptUtils } from "resource://gre/modules/PromptUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gPrompterService", + "@mozilla.org/login-manager/prompter;1", + Ci.nsILoginManagerPrompter +); + +/* eslint-disable block-scoped-var, no-var */ + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +/** + * A helper module to prevent modal auth prompt abuse. + */ +const PromptAbuseHelper = { + getBaseDomainOrFallback(hostname) { + try { + return Services.eTLD.getBaseDomainFromHost(hostname); + } catch (e) { + return hostname; + } + }, + + incrementPromptAbuseCounter(baseDomain, browser) { + if (!browser) { + return; + } + + if (!browser.authPromptAbuseCounter) { + browser.authPromptAbuseCounter = {}; + } + + if (!browser.authPromptAbuseCounter[baseDomain]) { + browser.authPromptAbuseCounter[baseDomain] = 0; + } + + browser.authPromptAbuseCounter[baseDomain] += 1; + }, + + resetPromptAbuseCounter(baseDomain, browser) { + if (!browser || !browser.authPromptAbuseCounter) { + return; + } + + browser.authPromptAbuseCounter[baseDomain] = 0; + }, + + hasReachedAbuseLimit(baseDomain, browser) { + if (!browser || !browser.authPromptAbuseCounter) { + return false; + } + + let abuseCounter = browser.authPromptAbuseCounter[baseDomain]; + // Allow for setting -1 to turn the feature off. + if (this.abuseLimit < 0) { + return false; + } + return !!abuseCounter && abuseCounter >= this.abuseLimit; + }, +}; + +XPCOMUtils.defineLazyPreferenceGetter( + PromptAbuseHelper, + "abuseLimit", + "prompts.authentication_dialog_abuse_limit" +); + +/** + * Implements nsIPromptFactory + * + * Invoked by [toolkit/components/prompts/src/Prompter.jsm] + */ +export function LoginManagerAuthPromptFactory() { + Services.obs.addObserver(this, "passwordmgr-crypto-login", true); +} + +LoginManagerAuthPromptFactory.prototype = { + classID: Components.ID("{749e62f4-60ae-4569-a8a2-de78b649660e}"), + QueryInterface: ChromeUtils.generateQI([ + "nsIPromptFactory", + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // Tracks pending auth prompts per top level browser and hash key. + // browser -> hashkey -> prompt + // This enables us to consolidate auth prompts with the same browser and + // hashkey (level, origin, realm). + _pendingPrompts: new WeakMap(), + _pendingSavePrompts: new WeakMap(), + // We use a separate bucket for when we don't have a browser. + // _noBrowser -> hashkey -> prompt + _noBrowser: {}, + // Promise used to defer prompts if the password manager isn't ready when + // they're called. + _uiBusyPromise: null, + _uiBusyResolve: null, + + observe(subject, topic, data) { + this.log(`Observed topic: ${topic}.`); + if (topic == "passwordmgr-crypto-login") { + // Show the deferred prompters. + this._uiBusyResolve?.(); + } + }, + + getPrompt(aWindow, aIID) { + var prompt = new LoginManagerAuthPrompter().QueryInterface(aIID); + prompt.init(aWindow, this); + return prompt; + }, + + getPendingPrompt(browser, hashKey) { + // If there is already a matching auth prompt which has no browser + // associated we can reuse it. This way we avoid showing tab level prompts + // when there is already a pending window prompt. + let pendingNoBrowserPrompt = this._pendingPrompts + .get(this._noBrowser) + ?.get(hashKey); + if (pendingNoBrowserPrompt) { + return pendingNoBrowserPrompt; + } + return this._pendingPrompts.get(browser)?.get(hashKey); + }, + + _dismissPendingSavePrompt(browser) { + this._pendingSavePrompts.get(browser)?.dismiss(); + this._pendingSavePrompts.delete(browser); + }, + + _setPendingSavePrompt(browser, prompt) { + this._pendingSavePrompts.set(browser, prompt); + }, + + _setPendingPrompt(prompt, hashKey) { + let browser = prompt.prompter.browser || this._noBrowser; + let hashToPrompt = this._pendingPrompts.get(browser); + if (!hashToPrompt) { + hashToPrompt = new Map(); + this._pendingPrompts.set(browser, hashToPrompt); + } + hashToPrompt.set(hashKey, prompt); + }, + + _removePendingPrompt(prompt, hashKey) { + let browser = prompt.prompter.browser || this._noBrowser; + let hashToPrompt = this._pendingPrompts.get(browser); + if (!hashToPrompt) { + return; + } + hashToPrompt.delete(hashKey); + if (!hashToPrompt.size) { + this._pendingPrompts.delete(browser); + } + }, + + async _waitForLoginsUI(prompt) { + await this._uiBusyPromise; + + let [origin, httpRealm] = prompt.prompter._getAuthTarget( + prompt.channel, + prompt.authInfo + ); + + // No UI to wait for. + if (!Services.logins.uiBusy) { + return; + } + + let hasLogins = Services.logins.countLogins(origin, null, httpRealm) > 0; + if ( + !hasLogins && + lazy.LoginHelper.schemeUpgrades && + origin.startsWith("https://") + ) { + let httpOrigin = origin.replace(/^https:\/\//, "http://"); + hasLogins = Services.logins.countLogins(httpOrigin, null, httpRealm) > 0; + } + // We don't depend on saved logins. + if (!hasLogins) { + return; + } + + this.log("Waiting for primary password UI."); + + this._uiBusyPromise = new Promise(resolve => { + this._uiBusyResolve = resolve; + }); + await this._uiBusyPromise; + }, + + async _doAsyncPrompt(prompt, hashKey) { + this._setPendingPrompt(prompt, hashKey); + + // UI might be busy due to the primary password dialog. Wait for it to close. + await this._waitForLoginsUI(prompt); + + let ok = false; + let promptAborted = false; + try { + this.log(`Performing the prompt for ${hashKey}.`); + ok = await prompt.prompter.promptAuthInternal( + prompt.channel, + prompt.level, + prompt.authInfo + ); + } catch (e) { + if ( + e instanceof Components.Exception && + e.result == Cr.NS_ERROR_NOT_AVAILABLE + ) { + this.log("Bypassed, UI is not available in this context."); + // Prompts throw NS_ERROR_NOT_AVAILABLE if they're aborted. + promptAborted = true; + } else { + console.error("LoginManagerAuthPrompter: _doAsyncPrompt " + e + "\n"); + } + } + + this._removePendingPrompt(prompt, hashKey); + + // Handle callbacks + for (var consumer of prompt.consumers) { + if (!consumer.callback) { + // Not having a callback means that consumer didn't provide it + // or canceled the notification + continue; + } + + this.log(`Calling back to callback: ${consumer.callback} ok: ${ok}.`); + try { + if (ok) { + consumer.callback.onAuthAvailable(consumer.context, prompt.authInfo); + } else { + consumer.callback.onAuthCancelled(consumer.context, !promptAborted); + } + } catch (e) { + /* Throw away exceptions caused by callback */ + } + } + }, +}; // end of LoginManagerAuthPromptFactory implementation + +XPCOMUtils.defineLazyGetter( + LoginManagerAuthPromptFactory.prototype, + "log", + () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerAuthPromptFactory"); + return logger.log.bind(logger); + } +); + +/* ==================== LoginManagerAuthPrompter ==================== */ + +/** + * Implements interfaces for prompting the user to enter/save/change auth info. + * + * nsIAuthPrompt: Used by SeaMonkey, Thunderbird, but not Firefox. + * + * nsIAuthPrompt2: Is invoked by a channel for protocol-based authentication + * (eg HTTP Authenticate, FTP login). + * + * nsILoginManagerAuthPrompter: Used by consumers to indicate which tab/window a + * prompt should appear on. + */ +export function LoginManagerAuthPrompter() {} + +LoginManagerAuthPrompter.prototype = { + classID: Components.ID("{8aa66d77-1bbb-45a6-991e-b8f47751c291}"), + QueryInterface: ChromeUtils.generateQI([ + "nsIAuthPrompt", + "nsIAuthPrompt2", + "nsILoginManagerAuthPrompter", + ]), + + _factory: null, + _chromeWindow: null, + _browser: null, + + __strBundle: null, // String bundle for L10N + get _strBundle() { + if (!this.__strBundle) { + this.__strBundle = Services.strings.createBundle( + "chrome://passwordmgr/locale/passwordmgr.properties" + ); + if (!this.__strBundle) { + throw new Error("String bundle for Login Manager not present!"); + } + } + + return this.__strBundle; + }, + + __ellipsis: null, + get _ellipsis() { + if (!this.__ellipsis) { + this.__ellipsis = "\u2026"; + try { + this.__ellipsis = Services.prefs.getComplexValue( + "intl.ellipsis", + Ci.nsIPrefLocalizedString + ).data; + } catch (e) {} + } + return this.__ellipsis; + }, + + // Whether we are in private browsing mode + get _inPrivateBrowsing() { + if (this._chromeWindow) { + return PrivateBrowsingUtils.isWindowPrivate(this._chromeWindow); + } + // If we don't that we're in private browsing mode if the caller did + // not provide a window. The callers which really care about this + // will indeed pass down a window to us, and for those who don't, + // we can just assume that we don't want to save the entered login + // information. + this.log("We have no chromeWindow so assume we're in a private context."); + return true; + }, + + get _allowRememberLogin() { + if (!this._inPrivateBrowsing) { + return true; + } + return lazy.LoginHelper.privateBrowsingCaptureEnabled; + }, + + /* ---------- nsIAuthPrompt prompts ---------- */ + + /** + * Wrapper around the prompt service prompt. Saving random fields here + * doesn't really make sense and therefore isn't implemented. + */ + prompt( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aDefaultText, + aResult + ) { + if (aSavePassword != Ci.nsIAuthPrompt.SAVE_PASSWORD_NEVER) { + throw new Components.Exception( + "prompt only supports SAVE_PASSWORD_NEVER", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + if (aDefaultText) { + aResult.value = aDefaultText; + } + + return Services.prompt.prompt( + this._chromeWindow, + aDialogTitle, + aText, + aResult, + null, + {} + ); + }, + + /** + * Looks up a username and password in the database. Will prompt the user + * with a dialog, even if a username and password are found. + */ + async asyncPromptUsernameAndPassword( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aUsername, + aPassword + ) { + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) { + throw new Components.Exception( + "asyncPromptUsernameAndPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + let foundLogins = null; + let canRememberLogin = false; + var selectedLogin = null; + var [origin, realm] = this._getRealmInfo(aPasswordRealm); + + // If origin is null, we can't save this login. + if (origin) { + if (this._allowRememberLogin) { + canRememberLogin = + aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY && + Services.logins.getLoginSavingEnabled(origin); + } + + // Look for existing logins. + foundLogins = Services.logins.findLogins(origin, null, realm); + + // XXX Like the original code, we can't deal with multiple + // account selection. (bug 227632) + if (foundLogins.length) { + selectedLogin = foundLogins[0]; + + // If the caller provided a username, try to use it. If they + // provided only a password, this will try to find a password-only + // login (or return null if none exists). + if (aUsername.value) { + selectedLogin = this._repickSelectedLogin( + foundLogins, + aUsername.value + ); + } + + if (selectedLogin) { + aUsername.value = selectedLogin.username; + // If the caller provided a password, prefer it. + if (!aPassword.value) { + aPassword.value = selectedLogin.password; + } + } + } + } + + let autofilled = !!aPassword.value; + var ok = Services.prompt.promptUsernameAndPassword( + this._chromeWindow, + aDialogTitle, + aText, + aUsername, + aPassword + ); + + if (!ok || !canRememberLogin) { + return ok; + } + + if (!aPassword.value) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, aUsername.value); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = new LoginInfo( + origin, + null, + realm, + aUsername.value, + aPassword.value + ); + if (!selectedLogin) { + // add as new + this.log(`New login seen for: ${realm}.`); + await Services.logins.addLoginAsync(newLogin); + } else if (aPassword.value != selectedLogin.password) { + // update password + this.log(`Updating password for ${realm}.`); + this._updateLogin(selectedLogin, newLogin); + } else { + this.log("Login unchanged, no further action needed."); + Services.logins.recordPasswordUse( + selectedLogin, + this._inPrivateBrowsing, + "prompt_login", + autofilled + ); + } + + return ok; + }, + + /** + * If a password is found in the database for the password realm, it is + * returned straight away without displaying a dialog. + * + * If a password is not found in the database, the user will be prompted + * with a dialog with a text field and ok/cancel buttons. If the user + * allows it, then the password will be saved in the database. + */ + async asyncPromptPassword( + aDialogTitle, + aText, + aPasswordRealm, + aSavePassword, + aPassword + ) { + if (aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_FOR_SESSION) { + throw new Components.Exception( + "promptPassword doesn't support SAVE_PASSWORD_FOR_SESSION", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + var [origin, realm, username] = this._getRealmInfo(aPasswordRealm); + + username = decodeURIComponent(username); + + let canRememberLogin = false; + // If origin is null, we can't save this login. + if (origin && !this._inPrivateBrowsing) { + canRememberLogin = + aSavePassword == Ci.nsIAuthPrompt.SAVE_PASSWORD_PERMANENTLY && + Services.logins.getLoginSavingEnabled(origin); + if (!aPassword.value) { + // Look for existing logins. + var foundLogins = Services.logins.findLogins(origin, null, realm); + + // XXX Like the original code, we can't deal with multiple + // account selection (bug 227632). We can deal with finding the + // account based on the supplied username - but in this case we'll + // just return the first match. + for (var i = 0; i < foundLogins.length; ++i) { + if (foundLogins[i].username == username) { + aPassword.value = foundLogins[i].password; + // wallet returned straight away, so this mimics that code + return true; + } + } + } + } + + var ok = Services.prompt.promptPassword( + this._chromeWindow, + aDialogTitle, + aText, + aPassword + ); + + if (ok && canRememberLogin && aPassword.value) { + let newLogin = new LoginInfo( + origin, + null, + realm, + username, + aPassword.value + ); + + this.log(`New login seen for ${realm}.`); + + await Services.logins.addLoginAsync(newLogin); + } + + return ok; + }, + + /* ---------- nsIAuthPrompt helpers ---------- */ + + /** + * Given aRealmString, such as "http://user@example.com/foo", returns an + * array of: + * - the formatted origin + * - the realm (origin + path) + * - the username, if present + * + * If aRealmString is in the format produced by NS_GetAuthKey for HTTP[S] + * channels, e.g. "example.com:80 (httprealm)", null is returned for all + * arguments to let callers know the login can't be saved because we don't + * know whether it's http or https. + */ + _getRealmInfo(aRealmString) { + var httpRealm = /^.+ \(.+\)$/; + if (httpRealm.test(aRealmString)) { + return [null, null, null]; + } + + var uri = Services.io.newURI(aRealmString); + var pathname = ""; + + if (uri.pathQueryRef != "/") { + pathname = uri.pathQueryRef; + } + + var formattedOrigin = this._getFormattedOrigin(uri); + + return [formattedOrigin, formattedOrigin + pathname, uri.username]; + }, + + async promptAuthInternal(aChannel, aLevel, aAuthInfo) { + var selectedLogin = null; + var epicfail = false; + var canAutologin = false; + var foundLogins; + let autofilled = false; + + try { + // If the user submits a login but it fails, we need to remove the + // notification prompt that was displayed. Conveniently, the user will + // be prompted for authentication again, which brings us here. + this._factory._dismissPendingSavePrompt(this._browser); + + var [origin, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo); + + // Looks for existing logins to prefill the prompt with. + foundLogins = await Services.logins.searchLoginsAsync({ + origin, + httpRealm, + schemeUpgrades: lazy.LoginHelper.schemeUpgrades, + }); + this.log(`Found ${foundLogins.length} matching logins.`); + let resolveBy = ["scheme", "timePasswordChanged"]; + foundLogins = lazy.LoginHelper.dedupeLogins( + foundLogins, + ["username"], + resolveBy, + origin + ); + this.log(`${foundLogins.length} matching logins remain after deduping.`); + + // XXX Can't select from multiple accounts yet. (bug 227632) + if (foundLogins.length) { + selectedLogin = foundLogins[0]; + this._SetAuthInfo( + aAuthInfo, + selectedLogin.username, + selectedLogin.password + ); + autofilled = true; + + // Allow automatic proxy login + if ( + aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY && + !(aAuthInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) && + Services.prefs.getBoolPref("signon.autologin.proxy") && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this.log("Autologin enabled, skipping auth prompt."); + canAutologin = true; + } + } + + var canRememberLogin = Services.logins.getLoginSavingEnabled(origin); + if (!this._allowRememberLogin) { + canRememberLogin = false; + } + } catch (e) { + // Ignore any errors and display the prompt anyway. + epicfail = true; + console.error( + "LoginManagerAuthPrompter: Epic fail in promptAuth: " + e + "\n" + ); + } + + var ok = canAutologin; + let browser = this._browser; + let baseDomain; + + // We might not have a browser or browser.currentURI.host could fail + // (e.g. on about:blank). Fall back to the subresource hostname in that case. + try { + let topLevelHost = browser.currentURI.host; + baseDomain = PromptAbuseHelper.getBaseDomainOrFallback(topLevelHost); + } catch (e) { + baseDomain = PromptAbuseHelper.getBaseDomainOrFallback(origin); + } + + if (!ok) { + if (PromptAbuseHelper.hasReachedAbuseLimit(baseDomain, browser)) { + this.log("Blocking auth dialog, due to exceeding dialog bloat limit."); + return false; + } + + // Set up a counter for ensuring that the basic auth prompt can not + // be abused for DOS-style attacks. With this counter, each eTLD+1 + // per browser will get a limited number of times a user can + // cancel the prompt until we stop showing it. + PromptAbuseHelper.incrementPromptAbuseCounter(baseDomain, browser); + + if (this._chromeWindow) { + PromptUtils.fireDialogEvent( + this._chromeWindow, + "DOMWillOpenModalDialog", + this._browser + ); + } + + ok = await Services.prompt.asyncPromptAuth( + this._browser?.browsingContext, + LoginManagerAuthPrompter.promptAuthModalType, + aChannel, + aLevel, + aAuthInfo + ); + } + + let [username, password] = this._GetAuthInfo(aAuthInfo); + + // Reset the counter state if the user replied to a prompt and actually + // tried to login (vs. simply clicking any button to get out). + if (ok && (username || password)) { + PromptAbuseHelper.resetPromptAbuseCounter(baseDomain, browser); + } + + if (!ok || !canRememberLogin || epicfail) { + return ok; + } + + try { + if (!password) { + this.log("No password entered, so won't offer to save."); + return ok; + } + + // XXX We can't prompt with multiple logins yet (bug 227632), so + // the entered login might correspond to an existing login + // other than the one we originally selected. + selectedLogin = this._repickSelectedLogin(foundLogins, username); + + // If we didn't find an existing login, or if the username + // changed, save as a new login. + let newLogin = new LoginInfo(origin, null, httpRealm, username, password); + if (!selectedLogin) { + this.log(`New login seen for origin: ${origin}.`); + + let promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + let savePrompt = lazy.gPrompterService.promptToSavePassword( + promptBrowser, + newLogin + ); + this._factory._setPendingSavePrompt(promptBrowser, savePrompt); + } else if (password != selectedLogin.password) { + this.log(`Updating password for origin: ${origin}.`); + + let promptBrowser = lazy.LoginHelper.getBrowserForPrompt(browser); + let savePrompt = lazy.gPrompterService.promptToChangePassword( + promptBrowser, + selectedLogin, + newLogin + ); + this._factory._setPendingSavePrompt(promptBrowser, savePrompt); + } else { + this.log("Login unchanged, no further action needed."); + Services.logins.recordPasswordUse( + selectedLogin, + this._inPrivateBrowsing, + "auth_login", + autofilled + ); + } + } catch (e) { + console.error("LoginManagerAuthPrompter: Fail2 in promptAuth: " + e); + } + + return ok; + }, + + /* ---------- nsIAuthPrompt2 prompts ---------- */ + + /** + * Implementation of nsIAuthPrompt2. + * + * @param {nsIChannel} aChannel + * @param {int} aLevel + * @param {nsIAuthInformation} aAuthInfo + */ + promptAuth(aChannel, aLevel, aAuthInfo) { + let closed = false; + let result = false; + this.promptAuthInternal(aChannel, aLevel, aAuthInfo) + .then(ok => (result = ok)) + .finally(() => (closed = true)); + Services.tm.spinEventLoopUntilOrQuit( + "LoginManagerAuthPrompter.jsm:promptAuth", + () => closed + ); + return result; + }, + + asyncPromptAuth(aChannel, aCallback, aContext, aLevel, aAuthInfo) { + var cancelable = null; + + try { + // If the user submits a login but it fails, we need to remove the + // notification prompt that was displayed. Conveniently, the user will + // be prompted for authentication again, which brings us here. + this._factory._dismissPendingSavePrompt(this._browser); + + cancelable = this._newAsyncPromptConsumer(aCallback, aContext); + + let [origin, httpRealm] = this._getAuthTarget(aChannel, aAuthInfo); + + let hashKey = aLevel + "|" + origin + "|" + httpRealm; + let pendingPrompt = this._factory.getPendingPrompt( + this._browser, + hashKey + ); + if (pendingPrompt) { + this.log( + `Prompt bound to an existing one in the queue, callback: ${aCallback}.` + ); + pendingPrompt.consumers.push(cancelable); + return cancelable; + } + + this.log(`Adding new async prompt, callback: ${aCallback}.`); + let asyncPrompt = { + consumers: [cancelable], + channel: aChannel, + authInfo: aAuthInfo, + level: aLevel, + prompter: this, + }; + + this._factory._doAsyncPrompt(asyncPrompt, hashKey); + } catch (e) { + console.error( + "LoginManagerAuthPrompter: " + + "asyncPromptAuth: " + + e + + "\nFalling back to promptAuth\n" + ); + // Fail the prompt operation to let the consumer fall back + // to synchronous promptAuth method + throw e; + } + + return cancelable; + }, + + /* ---------- nsILoginManagerAuthPrompter prompts ---------- */ + + init(aWindow = null, aFactory = null) { + if (!aWindow) { + // There may be no applicable window e.g. in a Sandbox or JSM. + this._chromeWindow = null; + this._browser = null; + } else if (aWindow.isChromeWindow) { + this._chromeWindow = aWindow; + // needs to be set explicitly using setBrowser + this._browser = null; + } else { + let { win, browser } = this._getChromeWindow(aWindow); + this._chromeWindow = win; + this._browser = browser; + } + this._factory = aFactory || null; + }, + + set browser(aBrowser) { + this._browser = aBrowser; + }, + + get browser() { + return this._browser; + }, + + /* ---------- Internal Methods ---------- */ + + _updateLogin(login, aNewLogin) { + var now = Date.now(); + var propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag + ); + propBag.setProperty("formActionOrigin", aNewLogin.formActionOrigin); + propBag.setProperty("origin", aNewLogin.origin); + propBag.setProperty("password", aNewLogin.password); + propBag.setProperty("username", aNewLogin.username); + // Explicitly set the password change time here (even though it would + // be changed automatically), to ensure that it's exactly the same + // value as timeLastUsed. + propBag.setProperty("timePasswordChanged", now); + propBag.setProperty("timeLastUsed", now); + propBag.setProperty("timesUsedIncrement", 1); + // Note that we don't call `recordPasswordUse` so we won't potentially record + // both a use and a save/update. See bug 1640096. + Services.logins.modifyLogin(login, propBag); + }, + + /** + * Given a content DOM window, returns the chrome window and browser it's in. + */ + _getChromeWindow(aWindow) { + let browser = aWindow.docShell.chromeEventHandler; + if (!browser) { + return null; + } + + let chromeWin = browser.ownerGlobal; + if (!chromeWin) { + return null; + } + + return { win: chromeWin, browser }; + }, + + /** + * The user might enter a login that isn't the one we prefilled, but + * is the same as some other existing login. So, pick a login with a + * matching username, or return null. + */ + _repickSelectedLogin(foundLogins, username) { + for (var i = 0; i < foundLogins.length; i++) { + if (foundLogins[i].username == username) { + return foundLogins[i]; + } + } + return null; + }, + + /** + * Can be called as: + * _getLocalizedString("key1"); + * _getLocalizedString("key2", ["arg1"]); + * _getLocalizedString("key3", ["arg1", "arg2"]); + * (etc) + * + * Returns the localized string for the specified key, + * formatted if required. + * + */ + _getLocalizedString(key, formatArgs) { + if (formatArgs) { + return this._strBundle.formatStringFromName(key, formatArgs); + } + return this._strBundle.GetStringFromName(key); + }, + + /** + * Sanitizes the specified username, by stripping quotes and truncating if + * it's too long. This helps prevent an evil site from messing with the + * "save password?" prompt too much. + */ + _sanitizeUsername(username) { + if (username.length > 30) { + username = username.substring(0, 30); + username += this._ellipsis; + } + return username.replace(/['"]/g, ""); + }, + + /** + * The aURI parameter may either be a string uri, or an nsIURI instance. + * + * Returns the origin to use in a nsILoginInfo object (for example, + * "http://example.com"). + */ + _getFormattedOrigin(aURI) { + let uri; + if (aURI instanceof Ci.nsIURI) { + uri = aURI; + } else { + uri = Services.io.newURI(aURI); + } + + return uri.scheme + "://" + uri.displayHostPort; + }, + + /** + * Converts a login's origin field (a URL) to a short string for + * prompting purposes. Eg, "http://foo.com" --> "foo.com", or + * "ftp://www.site.co.uk" --> "site.co.uk". + */ + _getShortDisplayHost(aURIString) { + var displayHost; + + var idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + try { + var uri = Services.io.newURI(aURIString); + var baseDomain = Services.eTLD.getBaseDomain(uri); + displayHost = idnService.convertToDisplayIDN(baseDomain, {}); + } catch (e) { + this.log(`Couldn't process supplied URIString ${aURIString}.`); + } + + if (!displayHost) { + displayHost = aURIString; + } + + return displayHost; + }, + + /** + * Returns the origin and realm for which authentication is being + * requested, in the format expected to be used with nsILoginInfo. + */ + _getAuthTarget(aChannel, aAuthInfo) { + var origin, realm; + + // If our proxy is demanding authentication, don't use the + // channel's actual destination. + if (aAuthInfo.flags & Ci.nsIAuthInformation.AUTH_PROXY) { + this.log("getAuthTarget is for proxy auth."); + if (!(aChannel instanceof Ci.nsIProxiedChannel)) { + throw new Error("proxy auth needs nsIProxiedChannel"); + } + + var info = aChannel.proxyInfo; + if (!info) { + throw new Error("proxy auth needs nsIProxyInfo"); + } + + // Proxies don't have a scheme, but we'll use "moz-proxy://" + // so that it's more obvious what the login is for. + var idnService = Cc["@mozilla.org/network/idn-service;1"].getService( + Ci.nsIIDNService + ); + origin = + "moz-proxy://" + + idnService.convertUTF8toACE(info.host) + + ":" + + info.port; + realm = aAuthInfo.realm; + if (!realm) { + realm = origin; + } + + return [origin, realm]; + } + + origin = this._getFormattedOrigin(aChannel.URI); + + // If a HTTP WWW-Authenticate header specified a realm, that value + // will be available here. If it wasn't set or wasn't HTTP, we'll use + // the formatted origin instead. + realm = aAuthInfo.realm; + if (!realm) { + realm = origin; + } + + return [origin, realm]; + }, + + /** + * Returns [username, password] as extracted from aAuthInfo (which + * holds this info after having prompted the user). + * + * If the authentication was for a Windows domain, we'll prepend the + * return username with the domain. (eg, "domain\user") + */ + _GetAuthInfo(aAuthInfo) { + var username, password; + + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN && aAuthInfo.domain) { + username = aAuthInfo.domain + "\\" + aAuthInfo.username; + } else { + username = aAuthInfo.username; + } + + password = aAuthInfo.password; + + return [username, password]; + }, + + /** + * Given a username (possibly in DOMAIN\user form) and password, parses the + * domain out of the username if necessary and sets domain, username and + * password on the auth information object. + */ + _SetAuthInfo(aAuthInfo, username, password) { + var flags = aAuthInfo.flags; + if (flags & Ci.nsIAuthInformation.NEED_DOMAIN) { + // Domain is separated from username by a backslash + var idx = username.indexOf("\\"); + if (idx == -1) { + aAuthInfo.username = username; + } else { + aAuthInfo.domain = username.substring(0, idx); + aAuthInfo.username = username.substring(idx + 1); + } + } else { + aAuthInfo.username = username; + } + aAuthInfo.password = password; + }, + + _newAsyncPromptConsumer(aCallback, aContext) { + return { + QueryInterface: ChromeUtils.generateQI(["nsICancelable"]), + callback: aCallback, + context: aContext, + cancel() { + this.callback.onAuthCancelled(this.context, false); + this.callback = null; + this.context = null; + }, + }; + }, +}; // end of LoginManagerAuthPrompter implementation + +XPCOMUtils.defineLazyGetter(LoginManagerAuthPrompter.prototype, "log", () => { + let logger = lazy.LoginHelper.createLogger("LoginManagerAuthPrompter"); + return logger.log.bind(logger); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + LoginManagerAuthPrompter, + "promptAuthModalType", + "prompts.modalType.httpAuth", + Services.prompt.MODAL_TYPE_WINDOW +); |