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.remoteRecipesEnabled = Services.prefs.getBoolPref( 481 "signon.recipes.remoteRecipes.enabled" 482 ); 483 this.relatedRealmsEnabled = Services.prefs.getBoolPref( 484 "signon.relatedRealms.enabled" 485 ); 486 }, 487 488 createLogger(aLogPrefix) { 489 let getMaxLogLevel = () => { 490 return this.debug ? "Debug" : "Warn"; 491 }; 492 493 // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref. 494 let consoleOptions = { 495 maxLogLevel: getMaxLogLevel(), 496 prefix: aLogPrefix, 497 }; 498 let logger = console.createInstance(consoleOptions); 499 500 // Watch for pref changes and update this.debug and the maxLogLevel for created loggers 501 Services.prefs.addObserver("signon.debug", () => { 502 this.debug = Services.prefs.getBoolPref("signon.debug"); 503 if (logger) { 504 logger.maxLogLevel = getMaxLogLevel(); 505 } 506 }); 507 508 return logger; 509 }, 510 511 /** 512 * Due to the way the signons2.txt file is formatted, we need to make 513 * sure certain field values or characters do not cause the file to 514 * be parsed incorrectly. Reject origins that we can't store correctly. 515 * 516 * @throws String with English message in case validation failed. 517 */ 518 checkOriginValue(aOrigin) { 519 // Nulls are invalid, as they don't round-trip well. Newlines are also 520 // invalid for any field stored as plaintext, and an origin made of a 521 // single dot cannot be stored in the legacy format. 522 if ( 523 aOrigin == "." || 524 aOrigin.includes("\r") || 525 aOrigin.includes("\n") || 526 aOrigin.includes("\0") 527 ) { 528 throw new Error("Invalid origin"); 529 } 530 }, 531 532 /** 533 * Due to the way the signons2.txt file was formatted, we needed to make 534 * sure certain field values or characters do not cause the file to 535 * be parsed incorrectly. These characters can cause problems in other 536 * formats/languages too so reject logins that may not be stored correctly. 537 * 538 * @throws String with English message in case validation failed. 539 */ 540 checkLoginValues(aLogin) { 541 function badCharacterPresent(l, c) { 542 return ( 543 (l.formActionOrigin && l.formActionOrigin.includes(c)) || 544 (l.httpRealm && l.httpRealm.includes(c)) || 545 l.origin.includes(c) || 546 l.usernameField.includes(c) || 547 l.passwordField.includes(c) 548 ); 549 } 550 551 // Nulls are invalid, as they don't round-trip well. 552 // Mostly not a formatting problem, although ".\0" can be quirky. 553 if (badCharacterPresent(aLogin, "\0")) { 554 throw new Error("login values can't contain nulls"); 555 } 556 557 if (!aLogin.password || typeof aLogin.password != "string") { 558 throw new Error("passwords must be non-empty strings"); 559 } 560 561 // In theory these nulls should just be rolled up into the encrypted 562 // values, but nsISecretDecoderRing doesn't use nsStrings, so the 563 // nulls cause truncation. Check for them here just to avoid 564 // unexpected round-trip surprises. 565 if (aLogin.username.includes("\0") || aLogin.password.includes("\0")) { 566 throw new Error("login values can't contain nulls"); 567 } 568 569 // Newlines are invalid for any field stored as plaintext. 570 if ( 571 badCharacterPresent(aLogin, "\r") || 572 badCharacterPresent(aLogin, "\n") 573 ) { 574 throw new Error("login values can't contain newlines"); 575 } 576 577 // A line with just a "." can have special meaning. 578 if (aLogin.usernameField == "." || aLogin.formActionOrigin == ".") { 579 throw new Error("login values can't be periods"); 580 } 581 582 // An origin with "\ \(" won't roundtrip. 583 // eg host="foo (", realm="bar" --> "foo ( (bar)" 584 // vs host="foo", realm=" (bar" --> "foo ( (bar)" 585 if (aLogin.origin.includes(" (")) { 586 throw new Error("bad parens in origin"); 587 } 588 }, 589 590 /** 591 * Returns a new XPCOM property bag with the provided properties. 592 * 593 * @param {Object} aProperties 594 * Each property of this object is copied to the property bag. This 595 * parameter can be omitted to return an empty property bag. 596 * 597 * @return A new property bag, that is an instance of nsIWritablePropertyBag, 598 * nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2. 599 */ 600 newPropertyBag(aProperties) { 601 let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance( 602 Ci.nsIWritablePropertyBag 603 ); 604 if (aProperties) { 605 for (let [name, value] of Object.entries(aProperties)) { 606 propertyBag.setProperty(name, value); 607 } 608 } 609 return propertyBag 610 .QueryInterface(Ci.nsIPropertyBag) 611 .QueryInterface(Ci.nsIPropertyBag2) 612 .QueryInterface(Ci.nsIWritablePropertyBag2); 613 }, 614 615 /** 616 * Helper to avoid the property bags when calling 617 * Services.logins.searchLogins from JS. 618 * @deprecated Use Services.logins.searchLoginsAsync instead. 619 * 620 * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching 621 * @return {nsILoginInfo[]} - The result of calling searchLogins. 622 */ 623 searchLoginsWithObject(aSearchOptions) { 624 return Services.logins.searchLogins(this.newPropertyBag(aSearchOptions)); 625 }, 626 627 /** 628 * @param {string} aURL 629 * @returns {string} which is the hostPort of aURL if supported by the scheme 630 * otherwise, returns the original aURL. 631 */ 632 maybeGetHostPortForURL(aURL) { 633 try { 634 let uri = Services.io.newURI(aURL); 635 return uri.hostPort; 636 } catch (ex) { 637 // No need to warn for javascript:/data:/about:/chrome:/etc. 638 } 639 return aURL; 640 }, 641 642 /** 643 * Get the parts of the URL we want for identification. 644 * Strip out things like the userPass portion and handle javascript:. 645 */ 646 getLoginOrigin(uriString, allowJS = false) { 647 let realm = ""; 648 try { 649 let uri = Services.io.newURI(uriString); 650 651 if (allowJS && uri.scheme == "javascript") { 652 return "javascript:"; 653 } 654 // TODO: Bug 1559205 - Add support for moz-proxy 655 656 // Build this manually instead of using prePath to avoid including the userPass portion. 657 realm = uri.scheme + "://" + uri.displayHostPort; 658 } catch (e) { 659 // bug 159484 - disallow url types that don't support a hostPort. 660 // (although we handle "javascript:..." as a special case above.) 661 log.warn("Couldn't parse origin for", uriString, e); 662 realm = null; 663 } 664 665 return realm; 666 }, 667 668 getFormActionOrigin(form) { 669 let uriString = form.action; 670 671 // A blank or missing action submits to where it came from. 672 if (uriString == "") { 673 // ala bug 297761 674 uriString = form.baseURI; 675 } 676 677 return this.getLoginOrigin(uriString, true); 678 }, 679 680 /** 681 * @param {String} aLoginOrigin - An origin value from a stored login's 682 * origin or formActionOrigin properties. 683 * @param {String} aSearchOrigin - The origin that was are looking to match 684 * with aLoginOrigin. This would normally come 685 * from a form or page that we are considering. 686 * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin 687 * from the login (aLoginOrigin) is a 688 * match for the origin we're looking 689 * for (aSearchOrigin). 690 */ 691 isOriginMatching( 692 aLoginOrigin, 693 aSearchOrigin, 694 aOptions = { 695 schemeUpgrades: false, 696 acceptWildcardMatch: false, 697 acceptDifferentSubdomains: false, 698 acceptRelatedRealms: false, 699 relatedRealms: [], 700 } 701 ) { 702 if (aLoginOrigin == aSearchOrigin) { 703 return true; 704 } 705 706 if (!aOptions) { 707 return false; 708 } 709 710 if (aOptions.acceptWildcardMatch && aLoginOrigin == "") { 711 return true; 712 } 713 714 // We can only match logins now if either of these flags are true, so 715 // avoid doing the work of constructing URL objects if neither is true. 716 if (!aOptions.acceptDifferentSubdomains && !aOptions.schemeUpgrades) { 717 return false; 718 } 719 720 try { 721 let loginURI = Services.io.newURI(aLoginOrigin); 722 let searchURI = Services.io.newURI(aSearchOrigin); 723 let schemeMatches = 724 loginURI.scheme == "http" && searchURI.scheme == "https"; 725 726 if (aOptions.acceptDifferentSubdomains) { 727 let loginBaseDomain = Services.eTLD.getBaseDomain(loginURI); 728 let searchBaseDomain = Services.eTLD.getBaseDomain(searchURI); 729 if ( 730 loginBaseDomain == searchBaseDomain && 731 (loginURI.scheme == searchURI.scheme || 732 (aOptions.schemeUpgrades && schemeMatches)) 733 ) { 734 return true; 735 } 736 if ( 737 aOptions.acceptRelatedRealms && 738 aOptions.relatedRealms.length && 739 (loginURI.scheme == searchURI.scheme || 740 (aOptions.schemeUpgrades && schemeMatches)) 741 ) { 742 for (let relatedOrigin of aOptions.relatedRealms) { 743 if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) { 744 return true; 745 } 746 } 747 } 748 } 749 750 if ( 751 aOptions.schemeUpgrades && 752 loginURI.host == searchURI.host && 753 schemeMatches && 754 loginURI.port == searchURI.port 755 ) { 756 return true; 757 } 758 } catch (ex) { 759 // newURI will throw for some values e.g. chrome://FirefoxAccounts 760 // uri.host and uri.port will throw for some values e.g. javascript: 761 return false; 762 } 763 764 return false; 765 }, 766 767 doLoginsMatch( 768 aLogin1, 769 aLogin2, 770 { ignorePassword = false, ignoreSchemes = false } 771 ) { 772 if ( 773 aLogin1.httpRealm != aLogin2.httpRealm || 774 aLogin1.username != aLogin2.username 775 ) { 776 return false; 777 } 778 779 if (!ignorePassword && aLogin1.password != aLogin2.password) { 780 return false; 781 } 782 783 if (ignoreSchemes) { 784 let login1HostPort = this.maybeGetHostPortForURL(aLogin1.origin); 785 let login2HostPort = this.maybeGetHostPortForURL(aLogin2.origin); 786 if (login1HostPort != login2HostPort) { 787 return false; 788 } 789 790 if ( 791 aLogin1.formActionOrigin != "" && 792 aLogin2.formActionOrigin != "" && 793 this.maybeGetHostPortForURL(aLogin1.formActionOrigin) != 794 this.maybeGetHostPortForURL(aLogin2.formActionOrigin) 795 ) { 796 return false; 797 } 798 } else { 799 if (aLogin1.origin != aLogin2.origin) { 800 return false; 801 } 802 803 // If either formActionOrigin is blank (but not null), then match. 804 if ( 805 aLogin1.formActionOrigin != "" && 806 aLogin2.formActionOrigin != "" && 807 aLogin1.formActionOrigin != aLogin2.formActionOrigin 808 ) { 809 return false; 810 } 811 } 812 813 // The .usernameField and .passwordField values are ignored. 814 815 return true; 816 }, 817 818 /** 819 * Creates a new login object that results by modifying the given object with 820 * the provided data. 821 * 822 * @param {nsILoginInfo} aOldStoredLogin 823 * Existing login object to modify. 824 * @param {nsILoginInfo|nsIProperyBag} aNewLoginData 825 * The new login values, either as an nsILoginInfo or nsIProperyBag. 826 * 827 * @return {nsILoginInfo} The newly created nsILoginInfo object. 828 * 829 * @throws {Error} With English message in case validation failed. 830 */ 831 buildModifiedLogin(aOldStoredLogin, aNewLoginData) { 832 function bagHasProperty(aPropName) { 833 try { 834 aNewLoginData.getProperty(aPropName); 835 return true; 836 } catch (ex) {} 837 return false; 838 } 839 840 aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo); 841 842 let newLogin; 843 if (aNewLoginData instanceof Ci.nsILoginInfo) { 844 // Clone the existing login to get its nsILoginMetaInfo, then init it 845 // with the replacement nsILoginInfo data from the new login. 846 newLogin = aOldStoredLogin.clone(); 847 newLogin.init( 848 aNewLoginData.origin, 849 aNewLoginData.formActionOrigin, 850 aNewLoginData.httpRealm, 851 aNewLoginData.username, 852 aNewLoginData.password, 853 aNewLoginData.usernameField, 854 aNewLoginData.passwordField 855 ); 856 newLogin.QueryInterface(Ci.nsILoginMetaInfo); 857 858 // Automatically update metainfo when password is changed. 859 if (newLogin.password != aOldStoredLogin.password) { 860 newLogin.timePasswordChanged = Date.now(); 861 } 862 } else if (aNewLoginData instanceof Ci.nsIPropertyBag) { 863 // Clone the existing login, along with all its properties. 864 newLogin = aOldStoredLogin.clone(); 865 newLogin.QueryInterface(Ci.nsILoginMetaInfo); 866 867 // Automatically update metainfo when password is changed. 868 // (Done before the main property updates, lest the caller be 869 // explicitly updating both .password and .timePasswordChanged) 870 if (bagHasProperty("password")) { 871 let newPassword = aNewLoginData.getProperty("password"); 872 if (newPassword != aOldStoredLogin.password) { 873 newLogin.timePasswordChanged = Date.now(); 874 } 875 } 876 877 for (let prop of aNewLoginData.enumerator) { 878 switch (prop.name) { 879 // nsILoginInfo (fall through) 880 case "origin": 881 case "httpRealm": 882 case "formActionOrigin": 883 case "username": 884 case "password": 885 case "usernameField": 886 case "passwordField": 887 // nsILoginMetaInfo (fall through) 888 case "guid": 889 case "timeCreated": 890 case "timeLastUsed": 891 case "timePasswordChanged": 892 case "timesUsed": 893 newLogin[prop.name] = prop.value; 894 break; 895 896 // Fake property, allows easy incrementing. 897 case "timesUsedIncrement": 898 newLogin.timesUsed += prop.value; 899 break; 900 901 // Fail if caller requests setting an unknown property. 902 default: 903 throw new Error("Unexpected propertybag item: " + prop.name); 904 } 905 } 906 } else { 907 throw new Error("newLoginData needs an expected interface!"); 908 } 909 910 // Sanity check the login 911 if (newLogin.origin == null || !newLogin.origin.length) { 912 throw new Error("Can't add a login with a null or empty origin."); 913 } 914 915 // For logins w/o a username, set to "", not null. 916 if (newLogin.username == null) { 917 throw new Error("Can't add a login with a null username."); 918 } 919 920 if (newLogin.password == null || !newLogin.password.length) { 921 throw new Error("Can't add a login with a null or empty password."); 922 } 923 924 if (newLogin.formActionOrigin || newLogin.formActionOrigin == "") { 925 // We have a form submit URL. Can't have a HTTP realm. 926 if (newLogin.httpRealm != null) { 927 throw new Error( 928 "Can't add a login with both a httpRealm and formActionOrigin." 929 ); 930 } 931 } else if (newLogin.httpRealm) { 932 // We have a HTTP realm. Can't have a form submit URL. 933 if (newLogin.formActionOrigin != null) { 934 throw new Error( 935 "Can't add a login with both a httpRealm and formActionOrigin." 936 ); 937 } 938 } else { 939 // Need one or the other! 940 throw new Error( 941 "Can't add a login without a httpRealm or formActionOrigin." 942 ); 943 } 944 945 // Throws if there are bogus values. 946 this.checkLoginValues(newLogin); 947 948 return newLogin; 949 }, 950 951 /** 952 * Remove http: logins when there is an https: login with the same username and hostPort. 953 * Sort order is preserved. 954 * 955 * @param {nsILoginInfo[]} logins 956 * A list of logins we want to process for shadowing. 957 * @returns {nsILoginInfo[]} A subset of of the passed logins. 958 */ 959 shadowHTTPLogins(logins) { 960 /** 961 * Map a (hostPort, username) to a boolean indicating whether `logins` 962 * contains an https: login for that combo. 963 */ 964 let hasHTTPSByHostPortUsername = new Map(); 965 for (let login of logins) { 966 let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); 967 let hasHTTPSlogin = hasHTTPSByHostPortUsername.get(key) || false; 968 let loginURI = Services.io.newURI(login.origin); 969 hasHTTPSByHostPortUsername.set( 970 key, 971 loginURI.scheme == "https" || hasHTTPSlogin 972 ); 973 } 974 975 return logins.filter(login => { 976 let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]); 977 let loginURI = Services.io.newURI(login.origin); 978 if (loginURI.scheme == "http" && hasHTTPSByHostPortUsername.get(key)) { 979 // If this is an http: login and we have an https: login for the 980 // (hostPort, username) combo then remove it. 981 return false; 982 } 983 return true; 984 }); 985 }, 986 987 /** 988 * Generate a unique key string from a login. 989 * @param {nsILoginInfo} login 990 * @param {string[]} uniqueKeys containing nsILoginInfo attribute names or "hostPort" 991 * @returns {string} to use as a key in a Map 992 */ 993 getUniqueKeyForLogin(login, uniqueKeys) { 994 const KEY_DELIMITER = ":"; 995 return uniqueKeys.reduce((prev, key) => { 996 let val = null; 997 if (key == "hostPort") { 998 val = Services.io.newURI(login.origin).hostPort; 999 } else { 1000 val = login[key]; 1001 } 1002 1003 return prev + KEY_DELIMITER + val; 1004 }, ""); 1005 }, 1006 1007 /** 1008 * Removes duplicates from a list of logins while preserving the sort order. 1009 * 1010 * @param {nsILoginInfo[]} logins 1011 * A list of logins we want to deduplicate. 1012 * @param {string[]} [uniqueKeys = ["username", "password"]] 1013 * A list of login attributes to use as unique keys for the deduplication. 1014 * @param {string[]} [resolveBy = ["timeLastUsed"]] 1015 * Ordered array of keyword strings used to decide which of the 1016 * duplicates should be used. "scheme" would prefer the login that has 1017 * a scheme matching `preferredOrigin`'s if there are two logins with 1018 * the same `uniqueKeys`. The default preference to distinguish two 1019 * logins is `timeLastUsed`. If there is no preference between two 1020 * logins, the first one found wins. 1021 * @param {string} [preferredOrigin = undefined] 1022 * String representing the origin to use for preferring one login over 1023 * another when they are dupes. This is used with "scheme" for 1024 * `resolveBy` so the scheme from this origin will be preferred. 1025 * @param {string} [preferredFormActionOrigin = undefined] 1026 * String representing the action origin to use for preferring one login over 1027 * another when they are dupes. This is used with "actionOrigin" for 1028 * `resolveBy` so the scheme from this action origin will be preferred. 1029 * 1030 * @returns {nsILoginInfo[]} list of unique logins. 1031 */ 1032 dedupeLogins( 1033 logins, 1034 uniqueKeys = ["username", "password"], 1035 resolveBy = ["timeLastUsed"], 1036 preferredOrigin = undefined, 1037 preferredFormActionOrigin = undefined 1038 ) { 1039 if (!preferredOrigin) { 1040 if (resolveBy.includes("scheme")) { 1041 throw new Error( 1042 "dedupeLogins: `preferredOrigin` is required in order to " + 1043 "prefer schemes which match it." 1044 ); 1045 } 1046 if (resolveBy.includes("subdomain")) { 1047 throw new Error( 1048 "dedupeLogins: `preferredOrigin` is required in order to " + 1049 "prefer subdomains which match it." 1050 ); 1051 } 1052 } 1053 1054 let preferredOriginScheme; 1055 if (preferredOrigin) { 1056 try { 1057 preferredOriginScheme = Services.io.newURI(preferredOrigin).scheme; 1058 } catch (ex) { 1059 // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts 1060 } 1061 } 1062 1063 if (!preferredOriginScheme && resolveBy.includes("scheme")) { 1064 log.warn( 1065 "dedupeLogins: Deduping with a scheme preference but couldn't " + 1066 "get the preferred origin scheme." 1067 ); 1068 } 1069 1070 // We use a Map to easily lookup logins by their unique keys. 1071 let loginsByKeys = new Map(); 1072 1073 /** 1074 * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`) 1075 * `existingLogin`. 1076 * 1077 * `resolveBy` is a sorted array so we can return true the first time `login` is preferred 1078 * over the existingLogin. 1079 */ 1080 function isLoginPreferred(existingLogin, login) { 1081 if (!resolveBy || !resolveBy.length) { 1082 // If there is no preference, prefer the existing login. 1083 return false; 1084 } 1085 1086 for (let preference of resolveBy) { 1087 switch (preference) { 1088 case "actionOrigin": { 1089 if (!preferredFormActionOrigin) { 1090 break; 1091 } 1092 if ( 1093 LoginHelper.isOriginMatching( 1094 existingLogin.formActionOrigin, 1095 preferredFormActionOrigin, 1096 { schemeUpgrades: LoginHelper.schemeUpgrades } 1097 ) && 1098 !LoginHelper.isOriginMatching( 1099 login.formActionOrigin, 1100 preferredFormActionOrigin, 1101 { schemeUpgrades: LoginHelper.schemeUpgrades } 1102 ) 1103 ) { 1104 return false; 1105 } 1106 break; 1107 } 1108 case "scheme": { 1109 if (!preferredOriginScheme) { 1110 break; 1111 } 1112 1113 try { 1114 // Only `origin` is currently considered 1115 let existingLoginURI = Services.io.newURI(existingLogin.origin); 1116 let loginURI = Services.io.newURI(login.origin); 1117 // If the schemes of the two logins are the same or neither match the 1118 // preferredOriginScheme then we have no preference and look at the next resolveBy. 1119 if ( 1120 loginURI.scheme == existingLoginURI.scheme || 1121 (loginURI.scheme != preferredOriginScheme && 1122 existingLoginURI.scheme != preferredOriginScheme) 1123 ) { 1124 break; 1125 } 1126 1127 return loginURI.scheme == preferredOriginScheme; 1128 } catch (ex) { 1129 // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts) 1130 log.debug( 1131 "dedupeLogins/shouldReplaceExisting: Error comparing schemes:", 1132 existingLogin.origin, 1133 login.origin, 1134 "preferredOrigin:", 1135 preferredOrigin, 1136 ex 1137 ); 1138 } 1139 break; 1140 } 1141 case "subdomain": { 1142 // Replace the existing login only if the new login is an exact match on the host. 1143 let existingLoginURI = Services.io.newURI(existingLogin.origin); 1144 let newLoginURI = Services.io.newURI(login.origin); 1145 let preferredOriginURI = Services.io.newURI(preferredOrigin); 1146 if ( 1147 existingLoginURI.hostPort != preferredOriginURI.hostPort && 1148 newLoginURI.hostPort == preferredOriginURI.hostPort 1149 ) { 1150 return true; 1151 } 1152 if ( 1153 existingLoginURI.host != preferredOriginURI.host && 1154 newLoginURI.host == preferredOriginURI.host 1155 ) { 1156 return true; 1157 } 1158 // if the existing login host *is* a match and the new one isn't 1159 // we explicitly want to keep the existing one 1160 if ( 1161 existingLoginURI.host == preferredOriginURI.host && 1162 newLoginURI.host != preferredOriginURI.host 1163 ) { 1164 return false; 1165 } 1166 break; 1167 } 1168 case "timeLastUsed": 1169 case "timePasswordChanged": { 1170 // If we find a more recent login for the same key, replace the existing one. 1171 let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[ 1172 preference 1173 ]; 1174 let storedLoginDate = existingLogin.QueryInterface( 1175 Ci.nsILoginMetaInfo 1176 )[preference]; 1177 if (loginDate == storedLoginDate) { 1178 break; 1179 } 1180 1181 return loginDate > storedLoginDate; 1182 } 1183 default: { 1184 throw new Error( 1185 "dedupeLogins: Invalid resolveBy preference: " + preference 1186 ); 1187 } 1188 } 1189 } 1190 1191 return false; 1192 } 1193 1194 for (let login of logins) { 1195 let key = this.getUniqueKeyForLogin(login, uniqueKeys); 1196 1197 if (loginsByKeys.has(key)) { 1198 if (!isLoginPreferred(loginsByKeys.get(key), login)) { 1199 // If there is no preference for the new login, use the existing one. 1200 continue; 1201 } 1202 } 1203 loginsByKeys.set(key, login); 1204 } 1205 1206 // Return the map values in the form of an array. 1207 return [...loginsByKeys.values()]; 1208 }, 1209 1210 /** 1211 * Open the password manager window. 1212 * 1213 * @param {Window} window 1214 * the window from where we want to open the dialog 1215 * 1216 * @param {object?} args 1217 * params for opening the password manager 1218 * @param {string} [args.filterString=""] 1219 * the domain (not origin) to pass to the login manager dialog 1220 * to pre-filter the results 1221 * @param {string} args.entryPoint 1222 * The name of the entry point, used for telemetry 1223 */ 1224 openPasswordManager(window, { filterString = "", entryPoint = "" } = {}) { 1225 const params = new URLSearchParams({ 1226 ...(filterString && { filter: filterString }), 1227 ...(entryPoint && { entryPoint }), 1228 }); 1229 const separator = params.toString() ? "?" : ""; 1230 const destination = `about:logins${separator}${params}`; 1231 1232 // We assume that managementURL has a '?' already 1233 window.openTrustedLinkIn(destination, "tab"); 1234 }, 1235 1236 /** 1237 * Checks if a field type is password compatible. 1238 * 1239 * @param {Element} element 1240 * the field we want to check. 1241 * @param {Object} options 1242 * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected 1243 * of the element. 1244 * 1245 * @returns {Boolean} true if the field can 1246 * be treated as a password input 1247 */ 1248 isPasswordFieldType(element, { ignoreConnect = false } = {}) { 1249 if (ChromeUtils.getClassName(element) !== "HTMLInputElement") { 1250 return false; 1251 } 1252 1253 if (!element.isConnected && !ignoreConnect) { 1254 // If the element isn't connected then it isn't visible to the user so 1255 // shouldn't be considered. It must have been connected in the past. 1256 return false; 1257 } 1258 1259 if (!element.hasBeenTypePassword) { 1260 return false; 1261 } 1262 1263 // Ensure the element is of a type that could have autocomplete. 1264 // These include the types with user-editable values. If not, even if it used to be 1265 // a type=password, we can't treat it as a password input now 1266 let acInfo = element.getAutocompleteInfo(); 1267 if (!acInfo) { 1268 return false; 1269 } 1270 1271 return true; 1272 }, 1273 1274 /** 1275 * Checks if a field type is username compatible. 1276 * 1277 * @param {Element} element 1278 * the field we want to check. 1279 * @param {Object} options 1280 * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected 1281 * of the element. 1282 * 1283 * @returns {Boolean} true if the field type is one 1284 * of the username types. 1285 */ 1286 isUsernameFieldType(element, { ignoreConnect = false } = {}) { 1287 if (ChromeUtils.getClassName(element) !== "HTMLInputElement") { 1288 return false; 1289 } 1290 1291 if (!element.isConnected && !ignoreConnect) { 1292 // If the element isn't connected then it isn't visible to the user so 1293 // shouldn't be considered. It must have been connected in the past. 1294 return false; 1295 } 1296 1297 if (element.hasBeenTypePassword) { 1298 return false; 1299 } 1300 1301 let fieldType = element.hasAttribute("type") 1302 ? element.getAttribute("type").toLowerCase() 1303 : element.type; 1304 if ( 1305 !( 1306 fieldType == "text" || 1307 fieldType == "email" || 1308 fieldType == "url" || 1309 fieldType == "tel" || 1310 fieldType == "number" 1311 ) 1312 ) { 1313 return false; 1314 } 1315 1316 let acFieldName = element.getAutocompleteInfo().fieldName; 1317 if ( 1318 !( 1319 acFieldName == "username" || 1320 // Bug 1540154: Some sites use tel/email on their username fields. 1321 acFieldName == "email" || 1322 acFieldName == "tel" || 1323 acFieldName == "tel-national" || 1324 acFieldName == "off" || 1325 acFieldName == "on" || 1326 acFieldName == "" 1327 ) 1328 ) { 1329 return false; 1330 } 1331 return true; 1332 }, 1333 1334 /** 1335 * Infer whether a form is a sign-in form by searching keywords 1336 * in its attributes 1337 * 1338 * @param {Element} element 1339 * the form we want to check. 1340 * 1341 * @returns {boolean} True if any of the rules matches 1342 */ 1343 isInferredLoginForm(formElement) { 1344 // This is copied from 'loginFormAttrRegex' in NewPasswordModel.jsm 1345 const loginExpr = /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i; 1346 1347 if (this._elementAttrsMatchRegex(formElement, loginExpr)) { 1348 return true; 1349 } 1350 1351 return false; 1352 }, 1353 1354 /** 1355 * Infer whether an input field is a username field by searching 1356 * 'username' keyword in its attributes 1357 * 1358 * @param {Element} element 1359 * the field we want to check. 1360 * 1361 * @returns {boolean} True if any of the rules matches 1362 */ 1363 isInferredUsernameField(element) { 1364 const expr = /username/i; 1365 1366 let ac = element.getAutocompleteInfo()?.fieldName; 1367 if (ac && ac == "username") { 1368 return true; 1369 } 1370 1371 if ( 1372 this._elementAttrsMatchRegex(element, expr) || 1373 this._hasLabelMatchingRegex(element, expr) 1374 ) { 1375 return true; 1376 } 1377 1378 return false; 1379 }, 1380 1381 /** 1382 * Infer whether an input field is an email field by searching 1383 * 'email' keyword in its attributes. 1384 * 1385 * @param {Element} element 1386 * the field we want to check. 1387 * 1388 * @returns {boolean} True if any of the rules matches 1389 */ 1390 isInferredEmailField(element) { 1391 const expr = /email/i; 1392 1393 if (element.type == "email") { 1394 return true; 1395 } 1396 1397 let ac = element.getAutocompleteInfo()?.fieldName; 1398 if (ac && ac == "email") { 1399 return true; 1400 } 1401 1402 if ( 1403 this._elementAttrsMatchRegex(element, expr) || 1404 this._hasLabelMatchingRegex(element, expr) 1405 ) { 1406 return true; 1407 } 1408 1409 return false; 1410 }, 1411 1412 /** 1413 * Test whether the element has the keyword in its attributes. 1414 * The tested attributes include id, name, className, and placeholder. 1415 */ 1416 _elementAttrsMatchRegex(element, regex) { 1417 if ( 1418 regex.test(element.id) || 1419 regex.test(element.name) || 1420 regex.test(element.className) 1421 ) { 1422 return true; 1423 } 1424 1425 let placeholder = element.getAttribute("placeholder"); 1426 if (placeholder && regex.test(placeholder)) { 1427 return true; 1428 } 1429 return false; 1430 }, 1431 1432 /** 1433 * Test whether associated labels of the element have the keyword. 1434 * This is a simplified rule of hasLabelMatchingRegex in NewPasswordModel.jsm 1435 * Consider changing it if this is not good enough. 1436 */ 1437 _hasLabelMatchingRegex(element, regex) { 1438 if (element.labels !== null && element.labels.length) { 1439 if (regex.test(element.labels[0].textContent)) { 1440 return true; 1441 } 1442 } 1443 1444 return false; 1445 }, 1446 1447 /** 1448 * For each login, add the login to the password manager if a similar one 1449 * doesn't already exist. Merge it otherwise with the similar existing ones. 1450 * 1451 * @param {Object[]} loginDatas - For each login, the data that needs to be added. 1452 * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data. 1453 */ 1454 async maybeImportLogins(loginDatas) { 1455 const processor = new ImportRowProcessor(); 1456 for (let rawLoginData of loginDatas) { 1457 // Do some sanitization on a clone of the loginData. 1458 let loginData = ChromeUtils.shallowClone(rawLoginData); 1459 if (processor.checkNonUniqueGuidError(loginData)) { 1460 continue; 1461 } 1462 if (processor.checkMissingMandatoryFieldsError(loginData)) { 1463 continue; 1464 } 1465 processor.cleanupActionAndRealmFields(loginData); 1466 if (await processor.checkExistingEntry(loginData)) { 1467 continue; 1468 } 1469 let login = processor.createNewLogin(loginData); 1470 if (processor.checkLoginValuesError(login, loginData)) { 1471 continue; 1472 } 1473 if (processor.checkConflictingOriginWithPreviousRows(login)) { 1474 continue; 1475 } 1476 if (processor.checkConflictingWithExistingLogins(login)) { 1477 continue; 1478 } 1479 processor.addLoginToSummary(login, "added"); 1480 } 1481 return processor.processLoginsAndBuildSummary(); 1482 }, 1483 1484 /** 1485 * Convert an array of nsILoginInfo to vanilla JS objects suitable for 1486 * sending over IPC. Avoid using this in other cases. 1487 * 1488 * NB: All members of nsILoginInfo (not nsILoginMetaInfo) are strings. 1489 */ 1490 loginsToVanillaObjects(logins) { 1491 return logins.map(this.loginToVanillaObject); 1492 }, 1493 1494 /** 1495 * Same as above, but for a single login. 1496 */ 1497 loginToVanillaObject(login) { 1498 let obj = {}; 1499 for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) { 1500 if (typeof login[i] !== "function") { 1501 obj[i] = login[i]; 1502 } 1503 } 1504 return obj; 1505 }, 1506 1507 /** 1508 * Convert an object received from IPC into an nsILoginInfo (with guid). 1509 */ 1510 vanillaObjectToLogin(login) { 1511 let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( 1512 Ci.nsILoginInfo 1513 ); 1514 formLogin.init( 1515 login.origin, 1516 login.formActionOrigin, 1517 login.httpRealm, 1518 login.username, 1519 login.password, 1520 login.usernameField, 1521 login.passwordField 1522 ); 1523 1524 formLogin.QueryInterface(Ci.nsILoginMetaInfo); 1525 for (let prop of [ 1526 "guid", 1527 "timeCreated", 1528 "timeLastUsed", 1529 "timePasswordChanged", 1530 "timesUsed", 1531 ]) { 1532 formLogin[prop] = login[prop]; 1533 } 1534 return formLogin; 1535 }, 1536 1537 /** 1538 * As above, but for an array of objects. 1539 */ 1540 vanillaObjectsToLogins(vanillaObjects) { 1541 const logins = []; 1542 for (const vanillaObject of vanillaObjects) { 1543 logins.push(this.vanillaObjectToLogin(vanillaObject)); 1544 } 1545 return logins; 1546 }, 1547 1548 /** 1549 * Returns true if the user has a master password set and false otherwise. 1550 */ 1551 isMasterPasswordSet() { 1552 let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService( 1553 Ci.nsIPK11TokenDB 1554 ); 1555 let token = tokenDB.getInternalKeyToken(); 1556 return token.hasPassword; 1557 }, 1558 1559 /** 1560 * Shows the Master Password prompt if enabled, or the 1561 * OS auth dialog otherwise. 1562 * @param {Element} browser 1563 * The <browser> that the prompt should be shown on 1564 * @param OSReauthEnabled Boolean indicating if OS reauth should be tried 1565 * @param expirationTime Optional timestamp indicating next required re-authentication 1566 * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used. 1567 * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used. 1568 */ 1569 async requestReauth( 1570 browser, 1571 OSReauthEnabled, 1572 expirationTime, 1573 messageText, 1574 captionText 1575 ) { 1576 let isAuthorized = false; 1577 let telemetryEvent; 1578 1579 // This does no harm if master password isn't set. 1580 let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance( 1581 Ci.nsIPK11TokenDB 1582 ); 1583 let token = tokendb.getInternalKeyToken(); 1584 1585 // Do we have a recent authorization? 1586 if (expirationTime && Date.now() < expirationTime) { 1587 isAuthorized = true; 1588 telemetryEvent = { 1589 object: token.hasPassword ? "master_password" : "os_auth", 1590 method: "reauthenticate", 1591 value: "success_no_prompt", 1592 }; 1593 return { 1594 isAuthorized, 1595 telemetryEvent, 1596 }; 1597 } 1598 1599 // Default to true if there is no master password and OS reauth is not available 1600 if (!token.hasPassword && !OSReauthEnabled) { 1601 isAuthorized = true; 1602 telemetryEvent = { 1603 object: "os_auth", 1604 method: "reauthenticate", 1605 value: "success_disabled", 1606 }; 1607 return { 1608 isAuthorized, 1609 telemetryEvent, 1610 }; 1611 } 1612 // Use the OS auth dialog if there is no master password 1613 if (!token.hasPassword && OSReauthEnabled) { 1614 let result = await OSKeyStore.ensureLoggedIn( 1615 messageText, 1616 captionText, 1617 browser.ownerGlobal, 1618 false 1619 ); 1620 isAuthorized = result.authenticated; 1621 telemetryEvent = { 1622 object: "os_auth", 1623 method: "reauthenticate", 1624 value: result.auth_details, 1625 extra: result.auth_details_extra, 1626 }; 1627 return { 1628 isAuthorized, 1629 telemetryEvent, 1630 }; 1631 } 1632 // We'll attempt to re-auth via Master Password, force a log-out 1633 token.checkPassword(""); 1634 1635 // If a master password prompt is already open, just exit early and return false. 1636 // The user can re-trigger it after responding to the already open dialog. 1637 if (Services.logins.uiBusy) { 1638 isAuthorized = false; 1639 return { 1640 isAuthorized, 1641 telemetryEvent, 1642 }; 1643 } 1644 1645 // So there's a master password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl). 1646 try { 1647 // Relogin and ask for the master password. 1648 token.login(true); // 'true' means always prompt for token password. User will be prompted until 1649 // clicking 'Cancel' or entering the correct password. 1650 } catch (e) { 1651 // An exception will be thrown if the user cancels the login prompt dialog. 1652 // User is also logged out of Software Security Device. 1653 } 1654 isAuthorized = token.isLoggedIn(); 1655 telemetryEvent = { 1656 object: "master_password", 1657 method: "reauthenticate", 1658 value: isAuthorized ? "success" : "fail", 1659 }; 1660 return { 1661 isAuthorized, 1662 telemetryEvent, 1663 }; 1664 }, 1665 1666 /** 1667 * Send a notification when stored data is changed. 1668 */ 1669 notifyStorageChanged(changeType, data) { 1670 let dataObject = data; 1671 // Can't pass a raw JS string or array though notifyObservers(). :-( 1672 if (Array.isArray(data)) { 1673 dataObject = Cc["@mozilla.org/array;1"].createInstance( 1674 Ci.nsIMutableArray 1675 ); 1676 for (let i = 0; i < data.length; i++) { 1677 dataObject.appendElement(data[i]); 1678 } 1679 } else if (typeof data == "string") { 1680 dataObject = Cc["@mozilla.org/supports-string;1"].createInstance( 1681 Ci.nsISupportsString 1682 ); 1683 dataObject.data = data; 1684 } 1685 Services.obs.notifyObservers( 1686 dataObject, 1687 "passwordmgr-storage-changed", 1688 changeType 1689 ); 1690 }, 1691 1692 isUserFacingLogin(login) { 1693 return login.origin != "chrome://FirefoxAccounts"; // FXA_PWDMGR_HOST 1694 }, 1695 1696 async getAllUserFacingLogins() { 1697 try { 1698 let logins = await Services.logins.getAllLoginsAsync(); 1699 return logins.filter(this.isUserFacingLogin); 1700 } catch (e) { 1701 if (e.result == Cr.NS_ERROR_ABORT) { 1702 // If the user cancels the MP prompt then return no logins. 1703 return []; 1704 } 1705 throw e; 1706 } 1707 }, 1708 1709 createLoginAlreadyExistsError(guid) { 1710 // The GUID is stored in an nsISupportsString here because we cannot pass 1711 // raw JS objects within Components.Exception due to bug 743121. 1712 let guidSupportsString = Cc[ 1713 "@mozilla.org/supports-string;1" 1714 ].createInstance(Ci.nsISupportsString); 1715 guidSupportsString.data = guid; 1716 return Components.Exception("This login already exists.", { 1717 data: guidSupportsString, 1718 }); 1719 }, 1720 1721 /** 1722 * Determine the <browser> that a prompt should be shown on. 1723 * 1724 * Some sites pop up a temporary login window, which disappears 1725 * upon submission of credentials. We want to put the notification 1726 * prompt in the opener window if this seems to be happening. 1727 * 1728 * @param {Element} browser 1729 * The <browser> that a prompt was triggered for 1730 * @returns {Element} The <browser> that the prompt should be shown on, 1731 * which could be in a different window. 1732 */ 1733 getBrowserForPrompt(browser) { 1734 let chromeWindow = browser.ownerGlobal; 1735 let openerBrowsingContext = browser.browsingContext.opener; 1736 let openerBrowser = openerBrowsingContext 1737 ? openerBrowsingContext.top.embedderElement 1738 : null; 1739 if (openerBrowser) { 1740 let chromeDoc = chromeWindow.document.documentElement; 1741 1742 // Check to see if the current window was opened with chrome 1743 // disabled, and if so use the opener window. But if the window 1744 // has been used to visit other pages (ie, has a history), 1745 // assume it'll stick around and *don't* use the opener. 1746 if (chromeDoc.getAttribute("chromehidden") && !browser.canGoBack) { 1747 log.debug("Using opener window for prompt."); 1748 return openerBrowser; 1749 } 1750 } 1751 1752 return browser; 1753 }, 1754}; 1755 1756XPCOMUtils.defineLazyPreferenceGetter( 1757 LoginHelper, 1758 "showInsecureFieldWarning", 1759 "security.insecure_field_warning.contextual.enabled" 1760); 1761 1762XPCOMUtils.defineLazyGetter(this, "log", () => { 1763 let processName = 1764 Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT 1765 ? "Main" 1766 : "Content"; 1767 return LoginHelper.createLogger(`LoginHelper(${processName})`); 1768}); 1769 1770LoginHelper.init(); 1771