1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5/**
6 * Contains functions shared by different Login Manager components.
7 *
8 * This JavaScript module exists in order to share code between the different
9 * XPCOM components that constitute the Login Manager, including implementations
10 * of nsILoginManager and nsILoginManagerStorage.
11 */
12
13"use strict";
14
15const EXPORTED_SYMBOLS = ["LoginHelper"];
16
17const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
18const { XPCOMUtils } = ChromeUtils.import(
19  "resource://gre/modules/XPCOMUtils.jsm"
20);
21ChromeUtils.defineModuleGetter(
22  this,
23  "OSKeyStore",
24  "resource://gre/modules/OSKeyStore.jsm"
25);
26
27/**
28 * A helper class to deal with CSV import rows.
29 */
30class ImportRowProcessor {
31  uniqueLoginIdentifiers = new Set();
32  originToRows = new Map();
33  summary = [];
34  mandatoryFields = ["origin", "password"];
35
36  /**
37   * Validates if the login data contains a GUID that was already found in a previous row in the current import.
38   * If this is the case, the summary will be updated with an error.
39   * @param {object} loginData
40   *        An vanilla object for the login without any methods.
41   * @returns {boolean} True if there is an error, false otherwise.
42   */
43  checkNonUniqueGuidError(loginData) {
44    if (loginData.guid) {
45      if (this.uniqueLoginIdentifiers.has(loginData.guid)) {
46        this.addLoginToSummary({ ...loginData }, "error");
47        return true;
48      }
49      this.uniqueLoginIdentifiers.add(loginData.guid);
50    }
51    return false;
52  }
53
54  /**
55   * Validates if the login data contains invalid fields that are mandatory like origin and password.
56   * If this is the case, the summary will be updated with an error.
57   * @param {object} loginData
58   *        An vanilla object for the login without any methods.
59   * @returns {boolean} True if there is an error, false otherwise.
60   */
61  checkMissingMandatoryFieldsError(loginData) {
62    loginData.origin = LoginHelper.getLoginOrigin(loginData.origin);
63    for (let mandatoryField of this.mandatoryFields) {
64      if (!loginData[mandatoryField]) {
65        const missingFieldRow = this.addLoginToSummary(
66          { ...loginData },
67          "error_missing_field"
68        );
69        missingFieldRow.field_name = mandatoryField;
70        return true;
71      }
72    }
73    return false;
74  }
75
76  /**
77   * Validates if there is already an existing entry with similar values.
78   * If there are similar values but not identical, a new "modified" entry will be added to the summary.
79   * If there are identical values, a new "no_change" entry will be added to the summary
80   * If either of these is the case, it will return true.
81   * @param {object} loginData
82   *        An vanilla object for the login without any methods.
83   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
84   */
85  async checkExistingEntry(loginData) {
86    if (loginData.guid) {
87      // First check for `guid` matches if it's set.
88      // `guid` matches will allow every kind of update, including reverting
89      // to older passwords which can be useful if the user wants to recover
90      // an old password.
91      let existingLogins = await Services.logins.searchLoginsAsync({
92        guid: loginData.guid,
93        origin: loginData.origin, // Ignored outside of GV.
94      });
95
96      if (existingLogins.length) {
97        log.debug("maybeImportLogins: Found existing login with GUID");
98        // There should only be one `guid` match.
99        let existingLogin = existingLogins[0].QueryInterface(
100          Ci.nsILoginMetaInfo
101        );
102
103        if (
104          loginData.username !== existingLogin.username ||
105          loginData.password !== existingLogin.password ||
106          loginData.httpRealm !== existingLogin.httpRealm ||
107          loginData.formActionOrigin !== existingLogin.formActionOrigin ||
108          `${loginData.timeCreated}` !== `${existingLogin.timeCreated}` ||
109          `${loginData.timePasswordChanged}` !==
110            `${existingLogin.timePasswordChanged}`
111        ) {
112          // Use a property bag rather than an nsILoginInfo so we don't clobber
113          // properties that the import source doesn't provide.
114          let propBag = LoginHelper.newPropertyBag(loginData);
115          this.addLoginToSummary({ ...existingLogin }, "modified", propBag);
116          return true;
117        }
118        this.addLoginToSummary({ ...existingLogin }, "no_change");
119        return true;
120      }
121    }
122    return false;
123  }
124
125  /**
126   * Validates if there is a conflict with previous rows based on the origin.
127   * We need to check the logins that we've already decided to add, to see if this is a duplicate.
128   * If this is the case, we mark this one as "no_change" in the summary and return true.
129   * @param {object} login
130   *        A login object.
131   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
132   */
133  checkConflictingOriginWithPreviousRows(login) {
134    let rowsPerOrigin = this.originToRows.get(login.origin);
135    if (rowsPerOrigin) {
136      if (
137        rowsPerOrigin.some(r =>
138          login.matches(r.login, false /* ignorePassword */)
139        )
140      ) {
141        this.addLoginToSummary(login, "no_change");
142        return true;
143      }
144      for (let row of rowsPerOrigin) {
145        let newLogin = row.login;
146        if (login.username == newLogin.username) {
147          this.addLoginToSummary(login, "no_change");
148          return true;
149        }
150      }
151    }
152    return false;
153  }
154
155  /**
156   * Validates if there is a conflict with existing logins based on the origin.
157   * If this is the case and there are some changes, we mark it as "modified" in the summary.
158   * If it matches an existing login without any extra modifications, we mark it as "no_change".
159   * For both cases we return true.
160   * @param {object} login
161   *        A login object.
162   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
163   */
164  checkConflictingWithExistingLogins(login) {
165    // While here we're passing formActionOrigin and httpRealm, they could be empty/null and get
166    // ignored in that case, leading to multiple logins for the same username.
167    let existingLogins = Services.logins.findLogins(
168      login.origin,
169      login.formActionOrigin,
170      login.httpRealm
171    );
172    // Check for an existing login that matches *including* the password.
173    // If such a login exists, we do not need to add a new login.
174    if (
175      existingLogins.some(l => login.matches(l, false /* ignorePassword */))
176    ) {
177      this.addLoginToSummary(login, "no_change");
178      return true;
179    }
180    // Now check for a login with the same username, where it may be that we have an
181    // updated password.
182    let foundMatchingLogin = false;
183    for (let existingLogin of existingLogins) {
184      if (login.username == existingLogin.username) {
185        foundMatchingLogin = true;
186        existingLogin.QueryInterface(Ci.nsILoginMetaInfo);
187        if (
188          (login.password != existingLogin.password) &
189          (login.timePasswordChanged > existingLogin.timePasswordChanged)
190        ) {
191          // if a login with the same username and different password already exists and it's older
192          // than the current one, update its password and timestamp.
193          let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
194            Ci.nsIWritablePropertyBag
195          );
196          propBag.setProperty("password", login.password);
197          propBag.setProperty("timePasswordChanged", login.timePasswordChanged);
198          this.addLoginToSummary({ ...existingLogin }, "modified", propBag);
199          return true;
200        }
201      }
202    }
203    // if the new login is an update or is older than an exiting login, don't add it.
204    if (foundMatchingLogin) {
205      this.addLoginToSummary(login, "no_change");
206      return true;
207    }
208    return false;
209  }
210
211  /**
212   * Validates if there are any invalid values using LoginHelper.checkLoginValues.
213   * If this is the case we mark it as "error" and return true.
214   * @param {object} login
215   *        A login object.
216   * @param {object} loginData
217   *        An vanilla object for the login without any methods.
218   * @returns {boolean} True if there is a validation error we return true, false otherwise.
219   */
220  checkLoginValuesError(login, loginData) {
221    try {
222      // Ensure we only send checked logins through, since the validation is optimized
223      // out from the bulk APIs below us.
224      LoginHelper.checkLoginValues(login);
225    } catch (e) {
226      this.addLoginToSummary({ ...loginData }, "error");
227      Cu.reportError(e);
228      return true;
229    }
230    return false;
231  }
232
233  /**
234   * Creates a new login from loginData.
235   * @param {object} loginData
236   *        An vanilla object for the login without any methods.
237   * @returns {object} A login object.
238   */
239  createNewLogin(loginData) {
240    let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
241      Ci.nsILoginInfo
242    );
243    login.init(
244      loginData.origin,
245      loginData.formActionOrigin,
246      loginData.httpRealm,
247      loginData.username,
248      loginData.password,
249      loginData.usernameElement || "",
250      loginData.passwordElement || ""
251    );
252
253    login.QueryInterface(Ci.nsILoginMetaInfo);
254    login.timeCreated = loginData.timeCreated;
255    login.timeLastUsed = loginData.timeLastUsed || loginData.timeCreated;
256    login.timePasswordChanged =
257      loginData.timePasswordChanged || loginData.timeCreated;
258    login.timesUsed = loginData.timesUsed || 1;
259    login.guid = loginData.guid || null;
260    return login;
261  }
262
263  /**
264   * Cleans the action and realm field of the loginData.
265   * @param {object} loginData
266   *        An vanilla object for the login without any methods.
267   */
268  cleanupActionAndRealmFields(loginData) {
269    const cleanOrigin = loginData.formActionOrigin
270      ? LoginHelper.getLoginOrigin(loginData.formActionOrigin, true)
271      : "";
272    loginData.formActionOrigin =
273      cleanOrigin || (typeof loginData.httpRealm == "string" ? null : "");
274
275    loginData.httpRealm =
276      typeof loginData.httpRealm == "string" ? loginData.httpRealm : null;
277  }
278
279  /**
280   * Adds a login to the summary.
281   * @param {object} login
282   *        A login object.
283   * @param {string} result
284   *        The result type. One of "added", "modified", "error", "error_invalid_origin", "error_invalid_password" or "no_change".
285   * @param {object} propBag
286   *        An optional parameter with the properties bag.
287   * @returns {object} The row that was added.
288   */
289  addLoginToSummary(login, result, propBag) {
290    let rows = this.originToRows.get(login.origin) || [];
291    if (rows.length === 0) {
292      this.originToRows.set(login.origin, rows);
293    }
294    const newSummaryRow = { result, login, propBag };
295    rows.push(newSummaryRow);
296    this.summary.push(newSummaryRow);
297    return newSummaryRow;
298  }
299
300  /**
301   * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor.
302   * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added".
303   * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change".
304   */
305  markLastTimePasswordChangedAsModified() {
306    const originUserToRowMap = new Map();
307    for (let currentRow of this.summary) {
308      if (
309        currentRow.result === "added" ||
310        currentRow.result === "modified" ||
311        currentRow.result === "no_change"
312      ) {
313        const originAndUser =
314          currentRow.login.origin + currentRow.login.username;
315        let lastTimeChangedRow = originUserToRowMap.get(originAndUser);
316        if (lastTimeChangedRow) {
317          if (
318            (currentRow.login.password != lastTimeChangedRow.login.password) &
319            (currentRow.login.timePasswordChanged >
320              lastTimeChangedRow.login.timePasswordChanged)
321          ) {
322            lastTimeChangedRow.result = "no_change";
323            currentRow.result = "added";
324            originUserToRowMap.set(originAndUser, currentRow);
325          }
326        } else {
327          originUserToRowMap.set(originAndUser, currentRow);
328        }
329      }
330    }
331  }
332
333  /**
334   * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor.
335   * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added".
336   * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change".
337   * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data.
338   */
339  async processLoginsAndBuildSummary() {
340    this.markLastTimePasswordChangedAsModified();
341    for (let summaryRow of this.summary) {
342      try {
343        if (summaryRow.result === "added") {
344          summaryRow.login = await Services.logins.addLogin(summaryRow.login);
345        } else if (summaryRow.result === "modified") {
346          Services.logins.modifyLogin(summaryRow.login, summaryRow.propBag);
347        }
348      } catch (e) {
349        Cu.reportError(e);
350        summaryRow.result = "error";
351      }
352    }
353    return this.summary;
354  }
355}
356
357/**
358 * Contains functions shared by different Login Manager components.
359 */
360this.LoginHelper = {
361  debug: null,
362  enabled: null,
363  storageEnabled: null,
364  formlessCaptureEnabled: null,
365  formRemovalCaptureEnabled: null,
366  generationAvailable: null,
367  generationConfidenceThreshold: null,
368  generationEnabled: null,
369  improvedPasswordRulesEnabled: null,
370  improvedPasswordRulesCollection: "password-rules",
371  includeOtherSubdomainsInLookup: null,
372  insecureAutofill: null,
373  privateBrowsingCaptureEnabled: null,
374  remoteRecipesEnabled: null,
375  remoteRecipesCollection: "password-recipes",
376  relatedRealmsEnabled: null,
377  relatedRealmsCollection: "websites-with-shared-credential-backends",
378  schemeUpgrades: null,
379  showAutoCompleteFooter: null,
380  showAutoCompleteImport: null,
381  testOnlyUserHasInteractedWithDocument: null,
382  userInputRequiredToCapture: null,
383  captureInputChanges: null,
384
385  init() {
386    // Watch for pref changes to update cached pref values.
387    Services.prefs.addObserver("signon.", () => this.updateSignonPrefs());
388    this.updateSignonPrefs();
389    Services.telemetry.setEventRecordingEnabled("pwmgr", true);
390    Services.telemetry.setEventRecordingEnabled("form_autocomplete", true);
391  },
392
393  updateSignonPrefs() {
394    this.autofillForms = Services.prefs.getBoolPref("signon.autofillForms");
395    this.autofillAutocompleteOff = Services.prefs.getBoolPref(
396      "signon.autofillForms.autocompleteOff"
397    );
398    this.captureInputChanges = Services.prefs.getBoolPref(
399      "signon.capture.inputChanges.enabled"
400    );
401    this.debug = Services.prefs.getBoolPref("signon.debug");
402    this.enabled = Services.prefs.getBoolPref("signon.rememberSignons");
403    this.storageEnabled = Services.prefs.getBoolPref(
404      "signon.storeSignons",
405      true
406    );
407    this.formlessCaptureEnabled = Services.prefs.getBoolPref(
408      "signon.formlessCapture.enabled"
409    );
410    this.formRemovalCaptureEnabled = Services.prefs.getBoolPref(
411      "signon.formRemovalCapture.enabled"
412    );
413    this.generationAvailable = Services.prefs.getBoolPref(
414      "signon.generation.available"
415    );
416    this.generationConfidenceThreshold = parseFloat(
417      Services.prefs.getStringPref("signon.generation.confidenceThreshold")
418    );
419    this.generationEnabled = Services.prefs.getBoolPref(
420      "signon.generation.enabled"
421    );
422    this.improvedPasswordRulesEnabled = Services.prefs.getBoolPref(
423      "signon.improvedPasswordRules.enabled"
424    );
425    this.insecureAutofill = Services.prefs.getBoolPref(
426      "signon.autofillForms.http"
427    );
428    this.includeOtherSubdomainsInLookup = Services.prefs.getBoolPref(
429      "signon.includeOtherSubdomainsInLookup"
430    );
431    this.passwordEditCaptureEnabled = Services.prefs.getBoolPref(
432      "signon.passwordEditCapture.enabled"
433    );
434    this.privateBrowsingCaptureEnabled = Services.prefs.getBoolPref(
435      "signon.privateBrowsingCapture.enabled"
436    );
437    this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
438    this.showAutoCompleteFooter = Services.prefs.getBoolPref(
439      "signon.showAutoCompleteFooter"
440    );
441
442    this.showAutoCompleteImport = Services.prefs.getStringPref(
443      "signon.showAutoCompleteImport",
444      ""
445    );
446
447    this.storeWhenAutocompleteOff = Services.prefs.getBoolPref(
448      "signon.storeWhenAutocompleteOff"
449    );
450
451    this.suggestImportCount = Services.prefs.getIntPref(
452      "signon.suggestImportCount",
453      0
454    );
455
456    if (
457      Services.prefs.getBoolPref(
458        "signon.testOnlyUserHasInteractedByPrefValue",
459        false
460      )
461    ) {
462      this.testOnlyUserHasInteractedWithDocument = Services.prefs.getBoolPref(
463        "signon.testOnlyUserHasInteractedWithDocument",
464        false
465      );
466      log.debug(
467        "updateSignonPrefs, using pref value for testOnlyUserHasInteractedWithDocument",
468        this.testOnlyUserHasInteractedWithDocument
469      );
470    } else {
471      this.testOnlyUserHasInteractedWithDocument = null;
472    }
473
474    this.userInputRequiredToCapture = Services.prefs.getBoolPref(
475      "signon.userInputRequiredToCapture.enabled"
476    );
477    this.usernameOnlyFormEnabled = Services.prefs.getBoolPref(
478      "signon.usernameOnlyForm.enabled"
479    );
480    this.usernameOnlyFormLookupThreshold = Services.prefs.getIntPref(
481      "signon.usernameOnlyForm.lookupThreshold"
482    );
483    this.remoteRecipesEnabled = Services.prefs.getBoolPref(
484      "signon.recipes.remoteRecipes.enabled"
485    );
486    this.relatedRealmsEnabled = Services.prefs.getBoolPref(
487      "signon.relatedRealms.enabled"
488    );
489  },
490
491  createLogger(aLogPrefix) {
492    let getMaxLogLevel = () => {
493      return this.debug ? "Debug" : "Warn";
494    };
495
496    // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
497    let consoleOptions = {
498      maxLogLevel: getMaxLogLevel(),
499      prefix: aLogPrefix,
500    };
501    let logger = console.createInstance(consoleOptions);
502
503    // Watch for pref changes and update this.debug and the maxLogLevel for created loggers
504    Services.prefs.addObserver("signon.debug", () => {
505      this.debug = Services.prefs.getBoolPref("signon.debug");
506      if (logger) {
507        logger.maxLogLevel = getMaxLogLevel();
508      }
509    });
510
511    return logger;
512  },
513
514  /**
515   * Due to the way the signons2.txt file is formatted, we need to make
516   * sure certain field values or characters do not cause the file to
517   * be parsed incorrectly.  Reject origins that we can't store correctly.
518   *
519   * @throws String with English message in case validation failed.
520   */
521  checkOriginValue(aOrigin) {
522    // Nulls are invalid, as they don't round-trip well.  Newlines are also
523    // invalid for any field stored as plaintext, and an origin made of a
524    // single dot cannot be stored in the legacy format.
525    if (
526      aOrigin == "." ||
527      aOrigin.includes("\r") ||
528      aOrigin.includes("\n") ||
529      aOrigin.includes("\0")
530    ) {
531      throw new Error("Invalid origin");
532    }
533  },
534
535  /**
536   * Due to the way the signons2.txt file was formatted, we needed to make
537   * sure certain field values or characters do not cause the file to
538   * be parsed incorrectly. These characters can cause problems in other
539   * formats/languages too so reject logins that may not be stored correctly.
540   *
541   * @throws String with English message in case validation failed.
542   */
543  checkLoginValues(aLogin) {
544    function badCharacterPresent(l, c) {
545      return (
546        (l.formActionOrigin && l.formActionOrigin.includes(c)) ||
547        (l.httpRealm && l.httpRealm.includes(c)) ||
548        l.origin.includes(c) ||
549        l.usernameField.includes(c) ||
550        l.passwordField.includes(c)
551      );
552    }
553
554    // Nulls are invalid, as they don't round-trip well.
555    // Mostly not a formatting problem, although ".\0" can be quirky.
556    if (badCharacterPresent(aLogin, "\0")) {
557      throw new Error("login values can't contain nulls");
558    }
559
560    if (!aLogin.password || typeof aLogin.password != "string") {
561      throw new Error("passwords must be non-empty strings");
562    }
563
564    // In theory these nulls should just be rolled up into the encrypted
565    // values, but nsISecretDecoderRing doesn't use nsStrings, so the
566    // nulls cause truncation. Check for them here just to avoid
567    // unexpected round-trip surprises.
568    if (aLogin.username.includes("\0") || aLogin.password.includes("\0")) {
569      throw new Error("login values can't contain nulls");
570    }
571
572    // Newlines are invalid for any field stored as plaintext.
573    if (
574      badCharacterPresent(aLogin, "\r") ||
575      badCharacterPresent(aLogin, "\n")
576    ) {
577      throw new Error("login values can't contain newlines");
578    }
579
580    // A line with just a "." can have special meaning.
581    if (aLogin.usernameField == "." || aLogin.formActionOrigin == ".") {
582      throw new Error("login values can't be periods");
583    }
584
585    // An origin with "\ \(" won't roundtrip.
586    // eg host="foo (", realm="bar" --> "foo ( (bar)"
587    // vs host="foo", realm=" (bar" --> "foo ( (bar)"
588    if (aLogin.origin.includes(" (")) {
589      throw new Error("bad parens in origin");
590    }
591  },
592
593  /**
594   * Returns a new XPCOM property bag with the provided properties.
595   *
596   * @param {Object} aProperties
597   *        Each property of this object is copied to the property bag.  This
598   *        parameter can be omitted to return an empty property bag.
599   *
600   * @return A new property bag, that is an instance of nsIWritablePropertyBag,
601   *         nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2.
602   */
603  newPropertyBag(aProperties) {
604    let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
605      Ci.nsIWritablePropertyBag
606    );
607    if (aProperties) {
608      for (let [name, value] of Object.entries(aProperties)) {
609        propertyBag.setProperty(name, value);
610      }
611    }
612    return propertyBag
613      .QueryInterface(Ci.nsIPropertyBag)
614      .QueryInterface(Ci.nsIPropertyBag2)
615      .QueryInterface(Ci.nsIWritablePropertyBag2);
616  },
617
618  /**
619   * Helper to avoid the property bags when calling
620   * Services.logins.searchLogins from JS.
621   * @deprecated Use Services.logins.searchLoginsAsync instead.
622   *
623   * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching
624   * @return {nsILoginInfo[]} - The result of calling searchLogins.
625   */
626  searchLoginsWithObject(aSearchOptions) {
627    return Services.logins.searchLogins(this.newPropertyBag(aSearchOptions));
628  },
629
630  /**
631   * @param {string} aURL
632   * @returns {string} which is the hostPort of aURL if supported by the scheme
633   *                   otherwise, returns the original aURL.
634   */
635  maybeGetHostPortForURL(aURL) {
636    try {
637      let uri = Services.io.newURI(aURL);
638      return uri.hostPort;
639    } catch (ex) {
640      // No need to warn for javascript:/data:/about:/chrome:/etc.
641    }
642    return aURL;
643  },
644
645  /**
646   * Get the parts of the URL we want for identification.
647   * Strip out things like the userPass portion and handle javascript:.
648   */
649  getLoginOrigin(uriString, allowJS = false) {
650    let realm = "";
651    try {
652      let uri = Services.io.newURI(uriString);
653
654      if (allowJS && uri.scheme == "javascript") {
655        return "javascript:";
656      }
657      // TODO: Bug 1559205 - Add support for moz-proxy
658
659      // Build this manually instead of using prePath to avoid including the userPass portion.
660      realm = uri.scheme + "://" + uri.displayHostPort;
661    } catch (e) {
662      // bug 159484 - disallow url types that don't support a hostPort.
663      // (although we handle "javascript:..." as a special case above.)
664      log.warn("Couldn't parse origin for", uriString, e);
665      realm = null;
666    }
667
668    return realm;
669  },
670
671  getFormActionOrigin(form) {
672    let uriString = form.action;
673
674    // A blank or missing action submits to where it came from.
675    if (uriString == "") {
676      // ala bug 297761
677      uriString = form.baseURI;
678    }
679
680    return this.getLoginOrigin(uriString, true);
681  },
682
683  /**
684   * @param {String} aLoginOrigin - An origin value from a stored login's
685   *                                origin or formActionOrigin properties.
686   * @param {String} aSearchOrigin - The origin that was are looking to match
687   *                                 with aLoginOrigin. This would normally come
688   *                                 from a form or page that we are considering.
689   * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin
690   *                                         from the login (aLoginOrigin) is a
691   *                                         match for the origin we're looking
692   *                                         for (aSearchOrigin).
693   */
694  isOriginMatching(
695    aLoginOrigin,
696    aSearchOrigin,
697    aOptions = {
698      schemeUpgrades: false,
699      acceptWildcardMatch: false,
700      acceptDifferentSubdomains: false,
701      acceptRelatedRealms: false,
702      relatedRealms: [],
703    }
704  ) {
705    if (aLoginOrigin == aSearchOrigin) {
706      return true;
707    }
708
709    if (!aOptions) {
710      return false;
711    }
712
713    if (aOptions.acceptWildcardMatch && aLoginOrigin == "") {
714      return true;
715    }
716
717    // We can only match logins now if either of these flags are true, so
718    // avoid doing the work of constructing URL objects if neither is true.
719    if (!aOptions.acceptDifferentSubdomains && !aOptions.schemeUpgrades) {
720      return false;
721    }
722
723    try {
724      let loginURI = Services.io.newURI(aLoginOrigin);
725      let searchURI = Services.io.newURI(aSearchOrigin);
726      let schemeMatches =
727        loginURI.scheme == "http" && searchURI.scheme == "https";
728
729      if (aOptions.acceptDifferentSubdomains) {
730        let loginBaseDomain = Services.eTLD.getBaseDomain(loginURI);
731        let searchBaseDomain = Services.eTLD.getBaseDomain(searchURI);
732        if (
733          loginBaseDomain == searchBaseDomain &&
734          (loginURI.scheme == searchURI.scheme ||
735            (aOptions.schemeUpgrades && schemeMatches))
736        ) {
737          return true;
738        }
739        if (
740          aOptions.acceptRelatedRealms &&
741          aOptions.relatedRealms.length &&
742          (loginURI.scheme == searchURI.scheme ||
743            (aOptions.schemeUpgrades && schemeMatches))
744        ) {
745          for (let relatedOrigin of aOptions.relatedRealms) {
746            if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) {
747              return true;
748            }
749          }
750        }
751      }
752
753      if (
754        aOptions.schemeUpgrades &&
755        loginURI.host == searchURI.host &&
756        schemeMatches &&
757        loginURI.port == searchURI.port
758      ) {
759        return true;
760      }
761    } catch (ex) {
762      // newURI will throw for some values e.g. chrome://FirefoxAccounts
763      // uri.host and uri.port will throw for some values e.g. javascript:
764      return false;
765    }
766
767    return false;
768  },
769
770  doLoginsMatch(
771    aLogin1,
772    aLogin2,
773    { ignorePassword = false, ignoreSchemes = false }
774  ) {
775    if (
776      aLogin1.httpRealm != aLogin2.httpRealm ||
777      aLogin1.username != aLogin2.username
778    ) {
779      return false;
780    }
781
782    if (!ignorePassword && aLogin1.password != aLogin2.password) {
783      return false;
784    }
785
786    if (ignoreSchemes) {
787      let login1HostPort = this.maybeGetHostPortForURL(aLogin1.origin);
788      let login2HostPort = this.maybeGetHostPortForURL(aLogin2.origin);
789      if (login1HostPort != login2HostPort) {
790        return false;
791      }
792
793      if (
794        aLogin1.formActionOrigin != "" &&
795        aLogin2.formActionOrigin != "" &&
796        this.maybeGetHostPortForURL(aLogin1.formActionOrigin) !=
797          this.maybeGetHostPortForURL(aLogin2.formActionOrigin)
798      ) {
799        return false;
800      }
801    } else {
802      if (aLogin1.origin != aLogin2.origin) {
803        return false;
804      }
805
806      // If either formActionOrigin is blank (but not null), then match.
807      if (
808        aLogin1.formActionOrigin != "" &&
809        aLogin2.formActionOrigin != "" &&
810        aLogin1.formActionOrigin != aLogin2.formActionOrigin
811      ) {
812        return false;
813      }
814    }
815
816    // The .usernameField and .passwordField values are ignored.
817
818    return true;
819  },
820
821  /**
822   * Creates a new login object that results by modifying the given object with
823   * the provided data.
824   *
825   * @param {nsILoginInfo} aOldStoredLogin
826   *        Existing login object to modify.
827   * @param {nsILoginInfo|nsIProperyBag} aNewLoginData
828   *        The new login values, either as an nsILoginInfo or nsIProperyBag.
829   *
830   * @return {nsILoginInfo} The newly created nsILoginInfo object.
831   *
832   * @throws {Error} With English message in case validation failed.
833   */
834  buildModifiedLogin(aOldStoredLogin, aNewLoginData) {
835    function bagHasProperty(aPropName) {
836      try {
837        aNewLoginData.getProperty(aPropName);
838        return true;
839      } catch (ex) {}
840      return false;
841    }
842
843    aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo);
844
845    let newLogin;
846    if (aNewLoginData instanceof Ci.nsILoginInfo) {
847      // Clone the existing login to get its nsILoginMetaInfo, then init it
848      // with the replacement nsILoginInfo data from the new login.
849      newLogin = aOldStoredLogin.clone();
850      newLogin.init(
851        aNewLoginData.origin,
852        aNewLoginData.formActionOrigin,
853        aNewLoginData.httpRealm,
854        aNewLoginData.username,
855        aNewLoginData.password,
856        aNewLoginData.usernameField,
857        aNewLoginData.passwordField
858      );
859      newLogin.QueryInterface(Ci.nsILoginMetaInfo);
860
861      // Automatically update metainfo when password is changed.
862      if (newLogin.password != aOldStoredLogin.password) {
863        newLogin.timePasswordChanged = Date.now();
864      }
865    } else if (aNewLoginData instanceof Ci.nsIPropertyBag) {
866      // Clone the existing login, along with all its properties.
867      newLogin = aOldStoredLogin.clone();
868      newLogin.QueryInterface(Ci.nsILoginMetaInfo);
869
870      // Automatically update metainfo when password is changed.
871      // (Done before the main property updates, lest the caller be
872      // explicitly updating both .password and .timePasswordChanged)
873      if (bagHasProperty("password")) {
874        let newPassword = aNewLoginData.getProperty("password");
875        if (newPassword != aOldStoredLogin.password) {
876          newLogin.timePasswordChanged = Date.now();
877        }
878      }
879
880      for (let prop of aNewLoginData.enumerator) {
881        switch (prop.name) {
882          // nsILoginInfo (fall through)
883          case "origin":
884          case "httpRealm":
885          case "formActionOrigin":
886          case "username":
887          case "password":
888          case "usernameField":
889          case "passwordField":
890          // nsILoginMetaInfo (fall through)
891          case "guid":
892          case "timeCreated":
893          case "timeLastUsed":
894          case "timePasswordChanged":
895          case "timesUsed":
896            newLogin[prop.name] = prop.value;
897            break;
898
899          // Fake property, allows easy incrementing.
900          case "timesUsedIncrement":
901            newLogin.timesUsed += prop.value;
902            break;
903
904          // Fail if caller requests setting an unknown property.
905          default:
906            throw new Error("Unexpected propertybag item: " + prop.name);
907        }
908      }
909    } else {
910      throw new Error("newLoginData needs an expected interface!");
911    }
912
913    // Sanity check the login
914    if (newLogin.origin == null || !newLogin.origin.length) {
915      throw new Error("Can't add a login with a null or empty origin.");
916    }
917
918    // For logins w/o a username, set to "", not null.
919    if (newLogin.username == null) {
920      throw new Error("Can't add a login with a null username.");
921    }
922
923    if (newLogin.password == null || !newLogin.password.length) {
924      throw new Error("Can't add a login with a null or empty password.");
925    }
926
927    if (newLogin.formActionOrigin || newLogin.formActionOrigin == "") {
928      // We have a form submit URL. Can't have a HTTP realm.
929      if (newLogin.httpRealm != null) {
930        throw new Error(
931          "Can't add a login with both a httpRealm and formActionOrigin."
932        );
933      }
934    } else if (newLogin.httpRealm) {
935      // We have a HTTP realm. Can't have a form submit URL.
936      if (newLogin.formActionOrigin != null) {
937        throw new Error(
938          "Can't add a login with both a httpRealm and formActionOrigin."
939        );
940      }
941    } else {
942      // Need one or the other!
943      throw new Error(
944        "Can't add a login without a httpRealm or formActionOrigin."
945      );
946    }
947
948    // Throws if there are bogus values.
949    this.checkLoginValues(newLogin);
950
951    return newLogin;
952  },
953
954  /**
955   * Remove http: logins when there is an https: login with the same username and hostPort.
956   * Sort order is preserved.
957   *
958   * @param {nsILoginInfo[]} logins
959   *        A list of logins we want to process for shadowing.
960   * @returns {nsILoginInfo[]} A subset of of the passed logins.
961   */
962  shadowHTTPLogins(logins) {
963    /**
964     * Map a (hostPort, username) to a boolean indicating whether `logins`
965     * contains an https: login for that combo.
966     */
967    let hasHTTPSByHostPortUsername = new Map();
968    for (let login of logins) {
969      let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]);
970      let hasHTTPSlogin = hasHTTPSByHostPortUsername.get(key) || false;
971      let loginURI = Services.io.newURI(login.origin);
972      hasHTTPSByHostPortUsername.set(
973        key,
974        loginURI.scheme == "https" || hasHTTPSlogin
975      );
976    }
977
978    return logins.filter(login => {
979      let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]);
980      let loginURI = Services.io.newURI(login.origin);
981      if (loginURI.scheme == "http" && hasHTTPSByHostPortUsername.get(key)) {
982        // If this is an http: login and we have an https: login for the
983        // (hostPort, username) combo then remove it.
984        return false;
985      }
986      return true;
987    });
988  },
989
990  /**
991   * Generate a unique key string from a login.
992   * @param {nsILoginInfo} login
993   * @param {string[]} uniqueKeys containing nsILoginInfo attribute names or "hostPort"
994   * @returns {string} to use as a key in a Map
995   */
996  getUniqueKeyForLogin(login, uniqueKeys) {
997    const KEY_DELIMITER = ":";
998    return uniqueKeys.reduce((prev, key) => {
999      let val = null;
1000      if (key == "hostPort") {
1001        val = Services.io.newURI(login.origin).hostPort;
1002      } else {
1003        val = login[key];
1004      }
1005
1006      return prev + KEY_DELIMITER + val;
1007    }, "");
1008  },
1009
1010  /**
1011   * Removes duplicates from a list of logins while preserving the sort order.
1012   *
1013   * @param {nsILoginInfo[]} logins
1014   *        A list of logins we want to deduplicate.
1015   * @param {string[]} [uniqueKeys = ["username", "password"]]
1016   *        A list of login attributes to use as unique keys for the deduplication.
1017   * @param {string[]} [resolveBy = ["timeLastUsed"]]
1018   *        Ordered array of keyword strings used to decide which of the
1019   *        duplicates should be used. "scheme" would prefer the login that has
1020   *        a scheme matching `preferredOrigin`'s if there are two logins with
1021   *        the same `uniqueKeys`. The default preference to distinguish two
1022   *        logins is `timeLastUsed`. If there is no preference between two
1023   *        logins, the first one found wins.
1024   * @param {string} [preferredOrigin = undefined]
1025   *        String representing the origin to use for preferring one login over
1026   *        another when they are dupes. This is used with "scheme" for
1027   *        `resolveBy` so the scheme from this origin will be preferred.
1028   * @param {string} [preferredFormActionOrigin = undefined]
1029   *        String representing the action origin to use for preferring one login over
1030   *        another when they are dupes. This is used with "actionOrigin" for
1031   *        `resolveBy` so the scheme from this action origin will be preferred.
1032   *
1033   * @returns {nsILoginInfo[]} list of unique logins.
1034   */
1035  dedupeLogins(
1036    logins,
1037    uniqueKeys = ["username", "password"],
1038    resolveBy = ["timeLastUsed"],
1039    preferredOrigin = undefined,
1040    preferredFormActionOrigin = undefined
1041  ) {
1042    if (!preferredOrigin) {
1043      if (resolveBy.includes("scheme")) {
1044        throw new Error(
1045          "dedupeLogins: `preferredOrigin` is required in order to " +
1046            "prefer schemes which match it."
1047        );
1048      }
1049      if (resolveBy.includes("subdomain")) {
1050        throw new Error(
1051          "dedupeLogins: `preferredOrigin` is required in order to " +
1052            "prefer subdomains which match it."
1053        );
1054      }
1055    }
1056
1057    let preferredOriginScheme;
1058    if (preferredOrigin) {
1059      try {
1060        preferredOriginScheme = Services.io.newURI(preferredOrigin).scheme;
1061      } catch (ex) {
1062        // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts
1063      }
1064    }
1065
1066    if (!preferredOriginScheme && resolveBy.includes("scheme")) {
1067      log.warn(
1068        "dedupeLogins: Deduping with a scheme preference but couldn't " +
1069          "get the preferred origin scheme."
1070      );
1071    }
1072
1073    // We use a Map to easily lookup logins by their unique keys.
1074    let loginsByKeys = new Map();
1075
1076    /**
1077     * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`)
1078     *                `existingLogin`.
1079     *
1080     * `resolveBy` is a sorted array so we can return true the first time `login` is preferred
1081     * over the existingLogin.
1082     */
1083    function isLoginPreferred(existingLogin, login) {
1084      if (!resolveBy || !resolveBy.length) {
1085        // If there is no preference, prefer the existing login.
1086        return false;
1087      }
1088
1089      for (let preference of resolveBy) {
1090        switch (preference) {
1091          case "actionOrigin": {
1092            if (!preferredFormActionOrigin) {
1093              break;
1094            }
1095            if (
1096              LoginHelper.isOriginMatching(
1097                existingLogin.formActionOrigin,
1098                preferredFormActionOrigin,
1099                { schemeUpgrades: LoginHelper.schemeUpgrades }
1100              ) &&
1101              !LoginHelper.isOriginMatching(
1102                login.formActionOrigin,
1103                preferredFormActionOrigin,
1104                { schemeUpgrades: LoginHelper.schemeUpgrades }
1105              )
1106            ) {
1107              return false;
1108            }
1109            break;
1110          }
1111          case "scheme": {
1112            if (!preferredOriginScheme) {
1113              break;
1114            }
1115
1116            try {
1117              // Only `origin` is currently considered
1118              let existingLoginURI = Services.io.newURI(existingLogin.origin);
1119              let loginURI = Services.io.newURI(login.origin);
1120              // If the schemes of the two logins are the same or neither match the
1121              // preferredOriginScheme then we have no preference and look at the next resolveBy.
1122              if (
1123                loginURI.scheme == existingLoginURI.scheme ||
1124                (loginURI.scheme != preferredOriginScheme &&
1125                  existingLoginURI.scheme != preferredOriginScheme)
1126              ) {
1127                break;
1128              }
1129
1130              return loginURI.scheme == preferredOriginScheme;
1131            } catch (ex) {
1132              // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts)
1133              log.debug(
1134                "dedupeLogins/shouldReplaceExisting: Error comparing schemes:",
1135                existingLogin.origin,
1136                login.origin,
1137                "preferredOrigin:",
1138                preferredOrigin,
1139                ex
1140              );
1141            }
1142            break;
1143          }
1144          case "subdomain": {
1145            // Replace the existing login only if the new login is an exact match on the host.
1146            let existingLoginURI = Services.io.newURI(existingLogin.origin);
1147            let newLoginURI = Services.io.newURI(login.origin);
1148            let preferredOriginURI = Services.io.newURI(preferredOrigin);
1149            if (
1150              existingLoginURI.hostPort != preferredOriginURI.hostPort &&
1151              newLoginURI.hostPort == preferredOriginURI.hostPort
1152            ) {
1153              return true;
1154            }
1155            if (
1156              existingLoginURI.host != preferredOriginURI.host &&
1157              newLoginURI.host == preferredOriginURI.host
1158            ) {
1159              return true;
1160            }
1161            // if the existing login host *is* a match and the new one isn't
1162            // we explicitly want to keep the existing one
1163            if (
1164              existingLoginURI.host == preferredOriginURI.host &&
1165              newLoginURI.host != preferredOriginURI.host
1166            ) {
1167              return false;
1168            }
1169            break;
1170          }
1171          case "timeLastUsed":
1172          case "timePasswordChanged": {
1173            // If we find a more recent login for the same key, replace the existing one.
1174            let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[
1175              preference
1176            ];
1177            let storedLoginDate = existingLogin.QueryInterface(
1178              Ci.nsILoginMetaInfo
1179            )[preference];
1180            if (loginDate == storedLoginDate) {
1181              break;
1182            }
1183
1184            return loginDate > storedLoginDate;
1185          }
1186          default: {
1187            throw new Error(
1188              "dedupeLogins: Invalid resolveBy preference: " + preference
1189            );
1190          }
1191        }
1192      }
1193
1194      return false;
1195    }
1196
1197    for (let login of logins) {
1198      let key = this.getUniqueKeyForLogin(login, uniqueKeys);
1199
1200      if (loginsByKeys.has(key)) {
1201        if (!isLoginPreferred(loginsByKeys.get(key), login)) {
1202          // If there is no preference for the new login, use the existing one.
1203          continue;
1204        }
1205      }
1206      loginsByKeys.set(key, login);
1207    }
1208
1209    // Return the map values in the form of an array.
1210    return [...loginsByKeys.values()];
1211  },
1212
1213  /**
1214   * Open the password manager window.
1215   *
1216   * @param {Window} window
1217   *                 the window from where we want to open the dialog
1218   *
1219   * @param {object?} args
1220   *                  params for opening the password manager
1221   * @param {string} [args.filterString=""]
1222   *                 the domain (not origin) to pass to the login manager dialog
1223   *                 to pre-filter the results
1224   * @param {string} args.entryPoint
1225   *                 The name of the entry point, used for telemetry
1226   */
1227  openPasswordManager(window, { filterString = "", entryPoint = "" } = {}) {
1228    const params = new URLSearchParams({
1229      ...(filterString && { filter: filterString }),
1230      ...(entryPoint && { entryPoint }),
1231    });
1232    const separator = params.toString() ? "?" : "";
1233    const destination = `about:logins${separator}${params}`;
1234
1235    // We assume that managementURL has a '?' already
1236    window.openTrustedLinkIn(destination, "tab");
1237  },
1238
1239  /**
1240   * Checks if a field type is password compatible.
1241   *
1242   * @param {Element} element
1243   *                  the field we want to check.
1244   * @param {Object} options
1245   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
1246   *                                         of the element.
1247   *
1248   * @returns {Boolean} true if the field can
1249   *                    be treated as a password input
1250   */
1251  isPasswordFieldType(element, { ignoreConnect = false } = {}) {
1252    if (ChromeUtils.getClassName(element) !== "HTMLInputElement") {
1253      return false;
1254    }
1255
1256    if (!element.isConnected && !ignoreConnect) {
1257      // If the element isn't connected then it isn't visible to the user so
1258      // shouldn't be considered. It must have been connected in the past.
1259      return false;
1260    }
1261
1262    if (!element.hasBeenTypePassword) {
1263      return false;
1264    }
1265
1266    // Ensure the element is of a type that could have autocomplete.
1267    // These include the types with user-editable values. If not, even if it used to be
1268    // a type=password, we can't treat it as a password input now
1269    let acInfo = element.getAutocompleteInfo();
1270    if (!acInfo) {
1271      return false;
1272    }
1273
1274    return true;
1275  },
1276
1277  /**
1278   * Checks if a field type is username compatible.
1279   *
1280   * @param {Element} element
1281   *                  the field we want to check.
1282   * @param {Object} options
1283   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
1284   *                                         of the element.
1285   *
1286   * @returns {Boolean} true if the field type is one
1287   *                    of the username types.
1288   */
1289  isUsernameFieldType(element, { ignoreConnect = false } = {}) {
1290    if (ChromeUtils.getClassName(element) !== "HTMLInputElement") {
1291      return false;
1292    }
1293
1294    if (!element.isConnected && !ignoreConnect) {
1295      // If the element isn't connected then it isn't visible to the user so
1296      // shouldn't be considered. It must have been connected in the past.
1297      return false;
1298    }
1299
1300    if (element.hasBeenTypePassword) {
1301      return false;
1302    }
1303
1304    let fieldType = element.hasAttribute("type")
1305      ? element.getAttribute("type").toLowerCase()
1306      : element.type;
1307    if (
1308      !(
1309        fieldType == "text" ||
1310        fieldType == "email" ||
1311        fieldType == "url" ||
1312        fieldType == "tel" ||
1313        fieldType == "number"
1314      )
1315    ) {
1316      return false;
1317    }
1318
1319    let acFieldName = element.getAutocompleteInfo().fieldName;
1320    if (
1321      !(
1322        acFieldName == "username" ||
1323        // Bug 1540154: Some sites use tel/email on their username fields.
1324        acFieldName == "email" ||
1325        acFieldName == "tel" ||
1326        acFieldName == "tel-national" ||
1327        acFieldName == "off" ||
1328        acFieldName == "on" ||
1329        acFieldName == ""
1330      )
1331    ) {
1332      return false;
1333    }
1334    return true;
1335  },
1336
1337  /**
1338   * Infer whether a form is a sign-in form by searching keywords
1339   * in its attributes
1340   *
1341   * @param {Element} element
1342   *                  the form we want to check.
1343   *
1344   * @returns {boolean} True if any of the rules matches
1345   */
1346  isInferredLoginForm(formElement) {
1347    // This is copied from 'loginFormAttrRegex' in NewPasswordModel.jsm
1348    const loginExpr = /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i;
1349
1350    if (this._elementAttrsMatchRegex(formElement, loginExpr)) {
1351      return true;
1352    }
1353
1354    return false;
1355  },
1356
1357  /**
1358   * Infer whether an input field is a username field by searching
1359   * 'username' keyword in its attributes
1360   *
1361   * @param {Element} element
1362   *                  the field we want to check.
1363   *
1364   * @returns {boolean} True if any of the rules matches
1365   */
1366  isInferredUsernameField(element) {
1367    const expr = /username/i;
1368
1369    let ac = element.getAutocompleteInfo()?.fieldName;
1370    if (ac && ac == "username") {
1371      return true;
1372    }
1373
1374    if (
1375      this._elementAttrsMatchRegex(element, expr) ||
1376      this._hasLabelMatchingRegex(element, expr)
1377    ) {
1378      return true;
1379    }
1380
1381    return false;
1382  },
1383
1384  /**
1385   * Search for keywords that indicates the input field is not likely a
1386   * field of a username login form.
1387   *
1388   * @param {Element} element
1389   *                  the input field we want to check.
1390   *
1391   * @returns {boolean} True if any of the rules matches
1392   */
1393  isInferredNonUsernameField(element) {
1394    const expr = /search|code/i;
1395
1396    if (
1397      this._elementAttrsMatchRegex(element, expr) ||
1398      this._hasLabelMatchingRegex(element, expr)
1399    ) {
1400      return true;
1401    }
1402
1403    return false;
1404  },
1405
1406  /**
1407   * Infer whether an input field is an email field by searching
1408   * 'email' keyword in its attributes.
1409   *
1410   * @param {Element} element
1411   *                  the field we want to check.
1412   *
1413   * @returns {boolean} True if any of the rules matches
1414   */
1415  isInferredEmailField(element) {
1416    const expr = /email/i;
1417
1418    if (element.type == "email") {
1419      return true;
1420    }
1421
1422    let ac = element.getAutocompleteInfo()?.fieldName;
1423    if (ac && ac == "email") {
1424      return true;
1425    }
1426
1427    if (
1428      this._elementAttrsMatchRegex(element, expr) ||
1429      this._hasLabelMatchingRegex(element, expr)
1430    ) {
1431      return true;
1432    }
1433
1434    return false;
1435  },
1436
1437  /**
1438   * Test whether the element has the keyword in its attributes.
1439   * The tested attributes include id, name, className, and placeholder.
1440   */
1441  _elementAttrsMatchRegex(element, regex) {
1442    if (
1443      regex.test(element.id) ||
1444      regex.test(element.name) ||
1445      regex.test(element.className)
1446    ) {
1447      return true;
1448    }
1449
1450    let placeholder = element.getAttribute("placeholder");
1451    if (placeholder && regex.test(placeholder)) {
1452      return true;
1453    }
1454    return false;
1455  },
1456
1457  /**
1458   * Test whether associated labels of the element have the keyword.
1459   * This is a simplified rule of hasLabelMatchingRegex in NewPasswordModel.jsm
1460   * Consider changing it if this is not good enough.
1461   */
1462  _hasLabelMatchingRegex(element, regex) {
1463    if (element.labels !== null && element.labels.length) {
1464      if (regex.test(element.labels[0].textContent)) {
1465        return true;
1466      }
1467    }
1468
1469    return false;
1470  },
1471
1472  /**
1473   * For each login, add the login to the password manager if a similar one
1474   * doesn't already exist. Merge it otherwise with the similar existing ones.
1475   *
1476   * @param {Object[]} loginDatas - For each login, the data that needs to be added.
1477   * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data.
1478   */
1479  async maybeImportLogins(loginDatas) {
1480    this.importing = true;
1481    try {
1482      const processor = new ImportRowProcessor();
1483      for (let rawLoginData of loginDatas) {
1484        // Do some sanitization on a clone of the loginData.
1485        let loginData = ChromeUtils.shallowClone(rawLoginData);
1486        if (processor.checkNonUniqueGuidError(loginData)) {
1487          continue;
1488        }
1489        if (processor.checkMissingMandatoryFieldsError(loginData)) {
1490          continue;
1491        }
1492        processor.cleanupActionAndRealmFields(loginData);
1493        if (await processor.checkExistingEntry(loginData)) {
1494          continue;
1495        }
1496        let login = processor.createNewLogin(loginData);
1497        if (processor.checkLoginValuesError(login, loginData)) {
1498          continue;
1499        }
1500        if (processor.checkConflictingOriginWithPreviousRows(login)) {
1501          continue;
1502        }
1503        if (processor.checkConflictingWithExistingLogins(login)) {
1504          continue;
1505        }
1506        processor.addLoginToSummary(login, "added");
1507      }
1508      return await processor.processLoginsAndBuildSummary();
1509    } finally {
1510      this.importing = false;
1511
1512      Services.obs.notifyObservers(null, "passwordmgr-reload-all");
1513    }
1514  },
1515
1516  /**
1517   * Convert an array of nsILoginInfo to vanilla JS objects suitable for
1518   * sending over IPC. Avoid using this in other cases.
1519   *
1520   * NB: All members of nsILoginInfo (not nsILoginMetaInfo) are strings.
1521   */
1522  loginsToVanillaObjects(logins) {
1523    return logins.map(this.loginToVanillaObject);
1524  },
1525
1526  /**
1527   * Same as above, but for a single login.
1528   */
1529  loginToVanillaObject(login) {
1530    let obj = {};
1531    for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) {
1532      if (typeof login[i] !== "function") {
1533        obj[i] = login[i];
1534      }
1535    }
1536    return obj;
1537  },
1538
1539  /**
1540   * Convert an object received from IPC into an nsILoginInfo (with guid).
1541   */
1542  vanillaObjectToLogin(login) {
1543    let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
1544      Ci.nsILoginInfo
1545    );
1546    formLogin.init(
1547      login.origin,
1548      login.formActionOrigin,
1549      login.httpRealm,
1550      login.username,
1551      login.password,
1552      login.usernameField,
1553      login.passwordField
1554    );
1555
1556    formLogin.QueryInterface(Ci.nsILoginMetaInfo);
1557    for (let prop of [
1558      "guid",
1559      "timeCreated",
1560      "timeLastUsed",
1561      "timePasswordChanged",
1562      "timesUsed",
1563    ]) {
1564      formLogin[prop] = login[prop];
1565    }
1566    return formLogin;
1567  },
1568
1569  /**
1570   * As above, but for an array of objects.
1571   */
1572  vanillaObjectsToLogins(vanillaObjects) {
1573    const logins = [];
1574    for (const vanillaObject of vanillaObjects) {
1575      logins.push(this.vanillaObjectToLogin(vanillaObject));
1576    }
1577    return logins;
1578  },
1579
1580  /**
1581   * Returns true if the user has a primary password set and false otherwise.
1582   */
1583  isPrimaryPasswordSet() {
1584    let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
1585      Ci.nsIPK11TokenDB
1586    );
1587    let token = tokenDB.getInternalKeyToken();
1588    return token.hasPassword;
1589  },
1590
1591  /**
1592   * Shows the Primary Password prompt if enabled, or the
1593   * OS auth dialog otherwise.
1594   * @param {Element} browser
1595   *        The <browser> that the prompt should be shown on
1596   * @param OSReauthEnabled Boolean indicating if OS reauth should be tried
1597   * @param expirationTime Optional timestamp indicating next required re-authentication
1598   * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used.
1599   * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used.
1600   */
1601  async requestReauth(
1602    browser,
1603    OSReauthEnabled,
1604    expirationTime,
1605    messageText,
1606    captionText
1607  ) {
1608    let isAuthorized = false;
1609    let telemetryEvent;
1610
1611    // This does no harm if primary password isn't set.
1612    let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
1613      Ci.nsIPK11TokenDB
1614    );
1615    let token = tokendb.getInternalKeyToken();
1616
1617    // Do we have a recent authorization?
1618    if (expirationTime && Date.now() < expirationTime) {
1619      isAuthorized = true;
1620      telemetryEvent = {
1621        object: token.hasPassword ? "master_password" : "os_auth",
1622        method: "reauthenticate",
1623        value: "success_no_prompt",
1624      };
1625      return {
1626        isAuthorized,
1627        telemetryEvent,
1628      };
1629    }
1630
1631    // Default to true if there is no primary password and OS reauth is not available
1632    if (!token.hasPassword && !OSReauthEnabled) {
1633      isAuthorized = true;
1634      telemetryEvent = {
1635        object: "os_auth",
1636        method: "reauthenticate",
1637        value: "success_disabled",
1638      };
1639      return {
1640        isAuthorized,
1641        telemetryEvent,
1642      };
1643    }
1644    // Use the OS auth dialog if there is no primary password
1645    if (!token.hasPassword && OSReauthEnabled) {
1646      let result = await OSKeyStore.ensureLoggedIn(
1647        messageText,
1648        captionText,
1649        browser.ownerGlobal,
1650        false
1651      );
1652      isAuthorized = result.authenticated;
1653      telemetryEvent = {
1654        object: "os_auth",
1655        method: "reauthenticate",
1656        value: result.auth_details,
1657        extra: result.auth_details_extra,
1658      };
1659      return {
1660        isAuthorized,
1661        telemetryEvent,
1662      };
1663    }
1664    // We'll attempt to re-auth via Primary Password, force a log-out
1665    token.checkPassword("");
1666
1667    // If a primary password prompt is already open, just exit early and return false.
1668    // The user can re-trigger it after responding to the already open dialog.
1669    if (Services.logins.uiBusy) {
1670      isAuthorized = false;
1671      return {
1672        isAuthorized,
1673        telemetryEvent,
1674      };
1675    }
1676
1677    // So there's a primary password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl).
1678    try {
1679      // Relogin and ask for the primary password.
1680      token.login(true); // 'true' means always prompt for token password. User will be prompted until
1681      // clicking 'Cancel' or entering the correct password.
1682    } catch (e) {
1683      // An exception will be thrown if the user cancels the login prompt dialog.
1684      // User is also logged out of Software Security Device.
1685    }
1686    isAuthorized = token.isLoggedIn();
1687    telemetryEvent = {
1688      object: "master_password",
1689      method: "reauthenticate",
1690      value: isAuthorized ? "success" : "fail",
1691    };
1692    return {
1693      isAuthorized,
1694      telemetryEvent,
1695    };
1696  },
1697
1698  /**
1699   * Send a notification when stored data is changed.
1700   */
1701  notifyStorageChanged(changeType, data) {
1702    if (this.importing) {
1703      return;
1704    }
1705
1706    let dataObject = data;
1707    // Can't pass a raw JS string or array though notifyObservers(). :-(
1708    if (Array.isArray(data)) {
1709      dataObject = Cc["@mozilla.org/array;1"].createInstance(
1710        Ci.nsIMutableArray
1711      );
1712      for (let i = 0; i < data.length; i++) {
1713        dataObject.appendElement(data[i]);
1714      }
1715    } else if (typeof data == "string") {
1716      dataObject = Cc["@mozilla.org/supports-string;1"].createInstance(
1717        Ci.nsISupportsString
1718      );
1719      dataObject.data = data;
1720    }
1721    Services.obs.notifyObservers(
1722      dataObject,
1723      "passwordmgr-storage-changed",
1724      changeType
1725    );
1726  },
1727
1728  isUserFacingLogin(login) {
1729    return login.origin != "chrome://FirefoxAccounts"; // FXA_PWDMGR_HOST
1730  },
1731
1732  async getAllUserFacingLogins() {
1733    try {
1734      let logins = await Services.logins.getAllLoginsAsync();
1735      return logins.filter(this.isUserFacingLogin);
1736    } catch (e) {
1737      if (e.result == Cr.NS_ERROR_ABORT) {
1738        // If the user cancels the MP prompt then return no logins.
1739        return [];
1740      }
1741      throw e;
1742    }
1743  },
1744
1745  createLoginAlreadyExistsError(guid) {
1746    // The GUID is stored in an nsISupportsString here because we cannot pass
1747    // raw JS objects within Components.Exception due to bug 743121.
1748    let guidSupportsString = Cc[
1749      "@mozilla.org/supports-string;1"
1750    ].createInstance(Ci.nsISupportsString);
1751    guidSupportsString.data = guid;
1752    return Components.Exception("This login already exists.", {
1753      data: guidSupportsString,
1754    });
1755  },
1756
1757  /**
1758   * Determine the <browser> that a prompt should be shown on.
1759   *
1760   * Some sites pop up a temporary login window, which disappears
1761   * upon submission of credentials. We want to put the notification
1762   * prompt in the opener window if this seems to be happening.
1763   *
1764   * @param {Element} browser
1765   *        The <browser> that a prompt was triggered for
1766   * @returns {Element} The <browser> that the prompt should be shown on,
1767   *                    which could be in a different window.
1768   */
1769  getBrowserForPrompt(browser) {
1770    let chromeWindow = browser.ownerGlobal;
1771    let openerBrowsingContext = browser.browsingContext.opener;
1772    let openerBrowser = openerBrowsingContext
1773      ? openerBrowsingContext.top.embedderElement
1774      : null;
1775    if (openerBrowser) {
1776      let chromeDoc = chromeWindow.document.documentElement;
1777
1778      // Check to see if the current window was opened with chrome
1779      // disabled, and if so use the opener window. But if the window
1780      // has been used to visit other pages (ie, has a history),
1781      // assume it'll stick around and *don't* use the opener.
1782      if (chromeDoc.getAttribute("chromehidden") && !browser.canGoBack) {
1783        log.debug("Using opener window for prompt.");
1784        return openerBrowser;
1785      }
1786    }
1787
1788    return browser;
1789  },
1790};
1791
1792XPCOMUtils.defineLazyPreferenceGetter(
1793  LoginHelper,
1794  "showInsecureFieldWarning",
1795  "security.insecure_field_warning.contextual.enabled"
1796);
1797
1798XPCOMUtils.defineLazyGetter(this, "log", () => {
1799  let processName =
1800    Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT
1801      ? "Main"
1802      : "Content";
1803  return LoginHelper.createLogger(`LoginHelper(${processName})`);
1804});
1805
1806LoginHelper.init();
1807