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