logins/login.rs
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// N.B. if you're making a documentation change here, you might also want to make it in:
6//
7// * The API docs in ../ios/Logins/LoginRecord.swift
8// * The API docs in ../android/src/main/java/mozilla/appservices/logins/ServerPassword.kt
9// * The android-components docs at
10// https://github.com/mozilla-mobile/android-components/tree/master/components/service/sync-logins
11//
12// We'll figure out a more scalable approach to maintaining all those docs at some point...
13
14//! # Login Structs
15//!
16//! This module defines a number of core structs for Logins. They are:
17//! * [`LoginEntry`] A login entry by the user. This includes the username/password, the site it
18//! was submitted to, etc. [`LoginEntry`] does not store data specific to a DB record.
19//! * [`Login`] - A [`LoginEntry`] plus DB record information. This includes the GUID and metadata
20//! like time_last_used.
21//! * [`EncryptedLogin`] -- A Login above with the username/password data encrypted.
22//! * [`LoginCandidate`] -- A [`Login`] without the username/password, for callers who want to
23//! filter on the cleartext fields before asking for the encryption key.
24//! * [`LoginFields`], [`SecureLoginFields`], [`LoginMeta`] -- These group the common fields in the
25//! structs above.
26//!
27//! Why so many structs for similar data? Consider some common use cases in a hypothetical browser
28//! (currently no browsers act exactly like this, although Fenix/android-components comes close):
29//!
30//! - User visits a page with a login form.
31//! - We inform the user if there are saved logins that can be autofilled. We use the
32//! `LoginDb.get_by_base_domain()` which returns a `Vec<EncryptedLogin>`. We don't decrypt the
33//! logins because we want to avoid requiring the encryption key at this point, which would
34//! force the user to authenticate. Note: this is aspirational at this point, no actual
35//! implementations follow this flow. Still, we want application-services to support it.
36//! - If the user chooses to autofill, we decrypt the logins into a `Vec<Login>`. We need to
37//! decrypt at this point to display the username and autofill the password if they select one.
38//! - When the user selects a login, we can use the already decrypted data from `Login` to fill
39//! in the form.
40//! - User chooses to save a login for autofilling later.
41//! - We present the user with a dialog that:
42//! - Displays a header that differentiates between different types of save: adding a new
43//! login, updating an existing login, filling in a blank username, etc.
44//! - Allows the user to tweak the username, in case we failed to detect the form field
45//! correctly. This may affect which header should be shown.
46//! - Here we use `find_login_to_update()` which returns an `Option<Login>`. Returning a login
47//! that has decrypted data avoids forcing the consumer code to decrypt the username again.
48//!
49//! # Login
50//! This has the complete set of data about a login. Very closely related is the
51//! "sync payload", defined in sync/payload.rs, which handles all aspects of the JSON serialization.
52//! It contains the following fields:
53//! - `meta`: A [`LoginMeta`] struct.
54//! - fields: A [`LoginFields`] struct.
55//! - sec_fields: A [`SecureLoginFields`] struct.
56//!
57//! # LoginEntry
58//! The struct used to add or update logins. This has the plain-text version of the fields that are
59//! stored encrypted, so almost all uses of an LoginEntry struct will also require the
60//! encryption key to be known and passed in. [LoginDB] methods that save data typically input
61//! [LoginEntry] instances. This allows the DB code to handle dupe-checking issues like
62//! determining which login record should be updated for a newly submitted [LoginEntry].
63//! It contains the following fields:
64//! - fields: A [`LoginFields`] struct.
65//! - sec_fields: A [`SecureLoginFields`] struct.
66//!
67//! # EncryptedLogin
68//! Encrypted version of [`Login`]. [LoginDB] methods that return data typically return [EncryptedLogin]
69//! this allows deferring decryption, and therefore user authentication, until the secure data is needed.
70//! It contains the following fields
71//! - `meta`: A [`LoginMeta`] struct.
72//! - `fields`: A [`LoginFields`] struct.
73//! - `sec_fields`: The secure fields as an encrypted string
74//!
75//! # SecureLoginFields
76//! The struct used to hold the fields which are stored encrypted. It contains:
77//! - username: A string.
78//! - password: A string.
79//!
80//! # LoginFields
81//!
82//! The core set of fields, use by both [`Login`] and [`LoginEntry`]
83//! It contains the following fields:
84//!
85//! - `origin`: The origin at which this login can be used, as a string.
86//!
87//! The login should only be used on sites that match this origin (for whatever definition
88//! of "matches" makes sense at the application level, e.g. eTLD+1 matching).
89//! This field is required, must be a valid origin in punycode format, and must not be
90//! set to the empty string.
91//!
92//! Examples of valid `origin` values include:
93//! - "https://site.com"
94//! - "http://site.com:1234"
95//! - "ftp://ftp.site.com"
96//! - "moz-proxy://127.0.0.1:8888"
97//! - "chrome://MyLegacyExtension"
98//! - "file://"
99//! - "https://\[::1\]"
100//!
101//! If invalid data is received in this field (either from the application, or via sync)
102//! then the logins store will attempt to coerce it into valid data by:
103//! - truncating full URLs to just their origin component, if it is not an opaque origin
104//! - converting values with non-ascii characters into punycode
105//!
106//! **XXX TODO:**
107//! - Add a field with the original unicode versions of the URLs instead of punycode?
108//!
109//! - `sec_fields`: The `username` and `password` for the site, stored as a encrypted JSON
110//! representation of an `SecureLoginFields`.
111//!
112//! This field is required and usually encrypted. There are two different value types:
113//! - Plaintext empty string: Used for deleted records
114//! - Encrypted value: The credentials associated with the login.
115//!
116//! - `http_realm`: The challenge string for HTTP Basic authentication, if any.
117//!
118//! If present, the login should only be used in response to a HTTP Basic Auth
119//! challenge that specifies a matching realm. For legacy reasons this string may not
120//! contain null bytes, carriage returns or newlines.
121//!
122//! If this field is set to the empty string, this indicates a wildcard match on realm.
123//!
124//! This field must not be present if `form_action_origin` is set, since they indicate different types
125//! of login (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin`
126//! must be present.
127//!
128//! - `form_action_origin`: The target origin of forms in which this login can be used, if any, as a string.
129//!
130//! If present, the login should only be used in forms whose target submission URL matches this origin.
131//! This field must be a valid origin or one of the following special cases:
132//! - An empty string, which is a wildcard match for any origin.
133//! - The single character ".", which is equivalent to the empty string
134//! - The string "javascript:", which matches any form with javascript target URL.
135//!
136//! This field must not be present if `http_realm` is set, since they indicate different types of login
137//! (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin` must be present.
138//!
139//! If invalid data is received in this field (either from the application, or via sync) then the
140//! logins store will attempt to coerce it into valid data by:
141//! - truncating full URLs to just their origin component
142//! - converting origins with non-ascii characters into punycode
143//! - replacing invalid values with null if a valid 'http_realm' field is present
144//!
145//! - `username_field`: The name of the form field into which the 'username' should be filled, if any.
146//!
147//! This value is stored if provided by the application, but does not imply any restrictions on
148//! how the login may be used in practice. For legacy reasons this string may not contain null
149//! bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
150//!
151//! If invalid data is received in this field (either from the application, or via sync)
152//! then the logins store will attempt to coerce it into valid data by:
153//! - setting to the empty string if 'form_action_origin' is not present
154//!
155//! - `password_field`: The name of the form field into which the 'password' should be filled, if any.
156//!
157//! This value is stored if provided by the application, but does not imply any restrictions on
158//! how the login may be used in practice. For legacy reasons this string may not contain null
159//! bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
160//!
161//! If invalid data is received in this field (either from the application, or via sync)
162//! then the logins store will attempt to coerce it into valid data by:
163//! - setting to the empty string if 'form_action_origin' is not present
164//!
165//! # LoginMeta
166//!
167//! This contains data relating to the login database record -- both on the local instance and
168//! synced to other browsers.
169//! It contains the following fields:
170//! - `id`: A unique string identifier for this record.
171//!
172//! Consumers may assume that `id` contains only "safe" ASCII characters but should otherwise
173//! treat this it as an opaque identifier. These are generated as needed.
174//!
175//! - `timesUsed`: A lower bound on the number of times the password from this record has been used, as an integer.
176//!
177//! Applications should use the `touch()` method of the logins store to indicate when a password
178//! has been used, and should ensure that they only count uses of the actual `password` field
179//! (so for example, copying the `password` field to the clipboard should count as a "use", but
180//! copying just the `username` field should not).
181//!
182//! This number may not record uses that occurred on other devices, since some legacy
183//! sync clients do not record this information. It may be zero for records obtained
184//! via sync that have never been used locally.
185//!
186//! When merging duplicate records, the two usage counts are summed.
187//!
188//! This field is managed internally by the logins store by default and does not need to
189//! be set explicitly, although any application-provided value will be preserved when creating
190//! a new record.
191//!
192//! If invalid data is received in this field (either from the application, or via sync)
193//! then the logins store will attempt to coerce it into valid data by:
194//! - replacing missing or negative values with 0
195//!
196//! **XXX TODO:**
197//! - test that we prevent this counter from moving backwards.
198//! - test fixups of missing or negative values
199//! - test that we correctly merge dupes
200//!
201//! - `time_created`: An upper bound on the time of creation of this login, in integer milliseconds from the unix epoch.
202//!
203//! This is an upper bound because some legacy sync clients do not record this information.
204//!
205//! Note that this field is typically a timestamp taken from the local machine clock, so it
206//! may be wildly inaccurate if the client does not have an accurate clock.
207//!
208//! This field is managed internally by the logins store by default and does not need to
209//! be set explicitly, although any application-provided value will be preserved when creating
210//! a new record.
211//!
212//! When merging duplicate records, the smallest non-zero value is taken.
213//!
214//! If invalid data is received in this field (either from the application, or via sync)
215//! then the logins store will attempt to coerce it into valid data by:
216//! - replacing missing or negative values with the current time
217//! - replacing values outside the range a JS `Date` can represent with 0
218//!
219//! **XXX TODO:**
220//! - test that we prevent this timestamp from moving backwards.
221//! - test that we correctly merge dupes
222//!
223//! - `time_last_used`: A lower bound on the time of last use of this login, in integer milliseconds from the unix epoch.
224//!
225//! This is a lower bound because some legacy sync clients do not record this information;
226//! in that case newer clients set `timeLastUsed` when they use the record for the first time.
227//!
228//! Note that this field is typically a timestamp taken from the local machine clock, so it
229//! may be wildly inaccurate if the client does not have an accurate clock.
230//!
231//! This field is managed internally by the logins store by default and does not need to
232//! be set explicitly, although any application-provided value will be preserved when creating
233//! a new record.
234//!
235//! When merging duplicate records, the largest non-zero value is taken.
236//!
237//! If invalid data is received in this field (either from the application, or via sync)
238//! then the logins store will attempt to coerce it into valid data by:
239//! - removing negative values
240//! - replacing values outside the range a JS `Date` can represent with 0
241//!
242//! **XXX TODO:**
243//! - test that we prevent this timestamp from moving backwards.
244//! - test that we correctly merge dupes
245//!
246//! - `time_password_changed`: A lower bound on the time that the `password` field was last changed, in integer
247//! milliseconds from the unix epoch.
248//!
249//! Changes to other fields (such as `username`) are not reflected in this timestamp.
250//! This is a lower bound because some legacy sync clients do not record this information;
251//! in that case newer clients set `time_password_changed` when they change the `password` field.
252//!
253//! Note that this field is typically a timestamp taken from the local machine clock, so it
254//! may be wildly inaccurate if the client does not have an accurate clock.
255//!
256//! This field is managed internally by the logins store by default and does not need to
257//! be set explicitly, although any application-provided value will be preserved when creating
258//! a new record.
259//!
260//! When merging duplicate records, the largest non-zero value is taken.
261//!
262//! If invalid data is received in this field (either from the application, or via sync)
263//! then the logins store will attempt to coerce it into valid data by:
264//! - removing negative values
265//! - replacing values outside the range a JS `Date` can represent with 0
266//!
267//! **XXX TODO:**
268//! - test that we prevent this timestamp from moving backwards.
269//! - test that we don't set this for changes to other fields.
270//! - test that we correctly merge dupes
271//!
272//!
273//! In order to deal with data from legacy clients in a robust way, it is necessary to be able to build
274//! and manipulate all these `Login` structs that contain invalid data. The non-encrypted structs
275//! implement the `ValidateAndFixup` trait, providing the following methods which can be used by
276//! callers to ensure that they're only working with valid records:
277//!
278//! - `Login::check_valid()`: Checks validity of a login record, returning `()` if it is valid
279//! or an error if it is not.
280//!
281//! - `Login::fixup()`: Returns either the existing login if it is valid, a clone with invalid fields
282//! fixed up if it was safe to do so, or an error if the login is irreparably invalid.
283
284use crate::{encryption::EncryptorDecryptor, error::*, util::sanitize_timestamp};
285use rusqlite::Row;
286use serde_derive::*;
287use sync_guid::Guid;
288use url::Url;
289
290// The Desktop FxA session-credentials pseudo-login. Firefox stores its account
291// credentials as a login under this origin; it must never be synced. This
292// mirrors the exclusion the JS `PasswordEngine` does via
293// `Utils.getSyncCredentialsHosts()`. Only relevant on Desktop (mobile never has
294// such a login), but it's harmless to filter everywhere.
295pub(crate) const FXA_CREDENTIALS_ORIGIN: &str = "chrome://FirefoxAccounts";
296
297// LoginEntry fields that are stored in cleartext
298#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
299pub struct LoginFields {
300 pub origin: String,
301 pub form_action_origin: Option<String>,
302 pub http_realm: Option<String>,
303 pub username_field: String,
304 pub password_field: String,
305}
306
307/// LoginEntry fields that are stored encrypted
308#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
309pub struct SecureLoginFields {
310 // - Username cannot be null, use the empty string instead
311 // - Password can't be empty or null (enforced in the ValidateAndFixup code)
312 //
313 // This matches the desktop behavior:
314 // https://searchfox.org/mozilla-central/rev/d3683dbb252506400c71256ef3994cdbdfb71ada/toolkit/components/passwordmgr/LoginManager.jsm#260-267
315
316 // Because we store the json version of this in the DB, and that's the only place the json
317 // is used, we rename the fields to short names, just to reduce the overhead in the DB.
318 #[serde(rename = "u")]
319 pub username: String,
320 #[serde(rename = "p")]
321 pub password: String,
322}
323
324impl SecureLoginFields {
325 pub fn encrypt(&self, encdec: &dyn EncryptorDecryptor, login_id: &str) -> Result<String> {
326 let string = serde_json::to_string(&self)?;
327 let cipherbytes = encdec
328 .encrypt(string.as_bytes().into())
329 .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting {login_id})")))?;
330 let ciphertext = std::str::from_utf8(&cipherbytes).map_err(|e| {
331 Error::EncryptionFailed(format!("{e} (encrypting {login_id}: data not utf8)"))
332 })?;
333 Ok(ciphertext.to_owned())
334 }
335
336 pub fn decrypt(
337 ciphertext: &str,
338 encdec: &dyn EncryptorDecryptor,
339 login_id: &str,
340 ) -> Result<Self> {
341 let jsonbytes = encdec.decrypt(ciphertext.as_bytes().into()).map_err(|e| {
342 Error::DecryptionFailed(format!(
343 "{e} (decrypting {login_id}, ciphertext length: {})",
344 ciphertext.len(),
345 ))
346 })?;
347 let json =
348 std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
349 Ok(serde_json::from_str(json)?)
350 }
351}
352
353/// Login data specific to database records
354#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
355pub struct LoginMeta {
356 pub id: String,
357 pub time_created: i64,
358 pub time_password_changed: i64,
359 pub time_last_used: i64,
360 pub times_used: i64,
361 pub time_last_breach_alert_dismissed: Option<i64>,
362}
363
364impl LoginMeta {
365 /// Clamp every timestamp into `[0, now]`.
366 ///
367 /// This runs on both read and write - so that a corrupt timestamp does not break.
368 pub(crate) fn sanitize_timestamps(self) -> Self {
369 Self {
370 time_created: sanitize_timestamp(self.time_created),
371 time_password_changed: sanitize_timestamp(self.time_password_changed),
372 time_last_used: sanitize_timestamp(self.time_last_used),
373 time_last_breach_alert_dismissed: self
374 .time_last_breach_alert_dismissed
375 .map(sanitize_timestamp),
376 ..self
377 }
378 }
379}
380
381/// A login together with meta fields, handed over to the store API; ie a login persisted
382/// elsewhere, useful for migrations
383pub struct LoginEntryWithMeta {
384 pub entry: LoginEntry,
385 pub meta: LoginMeta,
386}
387
388/// A bulk insert result entry, returned by `add_many` and `add_many_with_records`
389/// Please note that although the success case is much larger than the error case, this is
390/// negligible in real life, as we expect a very small success/error ratio.
391#[allow(clippy::large_enum_variant)]
392pub enum BulkResultEntry {
393 Success { login: Login },
394 Error { message: String },
395}
396
397/// A login handed over to the store API; ie a login not yet persisted
398#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
399pub struct LoginEntry {
400 // login fields
401 pub origin: String,
402 pub form_action_origin: Option<String>,
403 pub http_realm: Option<String>,
404 pub username_field: String,
405 pub password_field: String,
406
407 // secure fields
408 pub username: String,
409 pub password: String,
410}
411
412#[cfg(feature = "perform_additional_origin_fixups")]
413mod origin_fixup {
414 fn looks_like_bare_ipv4(s: &str) -> bool {
415 let parts: Vec<&str> = s.split('.').collect();
416 parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok())
417 }
418
419 // Returns true if `s` looks like a bare domain name (e.g. `example.com`):
420 // at least two dot-separated labels, each label only ASCII alphanumeric or hyphens.
421 fn looks_like_bare_domain(s: &str) -> bool {
422 let parts: Vec<&str> = s.split('.').collect();
423 parts.len() >= 2
424 && parts
425 .iter()
426 .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
427 }
428
429 // Returns true if `s` looks like a single hostname label (no dots),
430 // e.g. addon-generated origins like "example".
431 fn looks_like_bare_label(s: &str) -> bool {
432 !s.is_empty()
433 && !s.contains('.')
434 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
435 }
436
437 // Attempts to repair origins that fail URL parsing:
438 // - bare https: / https:/ / https:// → https://moz.pwmngr.fixed
439 // - http://ftp.<IPv4>[:port] → ftp://<IPv4>[:port] (FireFTP quirk)
440 // - ftp.<IPv4>[:port] without a scheme → ftp://<IPv4>[:port]
441 // - ftp.<domain> without a scheme → ftp://ftp.<domain>
442 // - bare IPv4 address or bare domain → moz-pwmngr-fixed://<host>
443 // - bare label (e.g. example) → moz-pwmngr-fixed://<label>
444 pub fn perform_additional_origin_fixup(origin: &str) -> Option<String> {
445 // Bare https: with missing or incomplete authority.
446 if matches!(origin, "https:" | "https:/" | "https://") {
447 return Some("https://moz.pwmngr.fixed".to_string());
448 }
449
450 // http://ftp.<IP>[:port] → ftp://<IP>[:port]
451 if let Some(rest) = origin.strip_prefix("http://ftp.") {
452 let host = rest.split(':').next().unwrap_or(rest);
453 if looks_like_bare_ipv4(host) {
454 return Some(format!("ftp://{rest}"));
455 }
456 }
457
458 // ftp.<IPv4 or bare domain> without a scheme
459 if let Some(rest) = origin.strip_prefix("ftp.") {
460 if looks_like_bare_ipv4(rest) {
461 // ftp.<IP> → ftp://<IP> (strips ftp. prefix; ftp://ftp.<IP> would fail URL parsing)
462 return Some(format!("ftp://{rest}"));
463 } else if looks_like_bare_domain(rest) {
464 // ftp.<domain> → ftp://ftp.<domain>
465 return Some(format!("ftp://{origin}"));
466 }
467 }
468
469 // bare domain, IPv4 address, or single-label hostname → moz-pwmngr-fixed://
470 if looks_like_bare_domain(origin) || looks_like_bare_label(origin) {
471 return Some(format!("moz-pwmngr-fixed://{origin}"));
472 }
473
474 None
475 }
476}
477
478impl LoginEntry {
479 pub fn new(fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
480 Self {
481 origin: fields.origin,
482 form_action_origin: fields.form_action_origin,
483 http_realm: fields.http_realm,
484 username_field: fields.username_field,
485 password_field: fields.password_field,
486
487 username: sec_fields.username,
488 password: sec_fields.password,
489 }
490 }
491
492 /// Shared core logic for origin-like fields: parses `origin` as a URL and
493 /// normalizes it to origin-only form. Returns `Ok(None)` if the input is
494 /// already a valid, normalized origin, `Ok(Some(fixed))` if it needed
495 /// normalization, or `Err` if the input cannot be parsed as a URL.
496 fn parse_and_normalize_origin(origin: &str) -> Result<Option<String>> {
497 match Url::parse(origin) {
498 Ok(mut u) => {
499 // Presumably this is a faster path than always setting?
500 if u.path() != "/"
501 || u.fragment().is_some()
502 || u.query().is_some()
503 || u.username() != "/"
504 || u.password().is_some()
505 {
506 // Not identical - we only want the origin part, so kill
507 // any other parts which may exist.
508 // But first special case `file://` URLs which always
509 // resolve to `file://`
510 if u.scheme() == "file" {
511 return Ok(if origin == "file://" {
512 None
513 } else {
514 Some("file://".into())
515 });
516 }
517 u.set_path("");
518 u.set_fragment(None);
519 u.set_query(None);
520 let _ = u.set_username("");
521 let _ = u.set_password(None);
522 let mut href = String::from(u);
523 // We always store without the trailing "/" which Urls have.
524 if href.ends_with('/') {
525 href.pop().expect("url must have a length");
526 }
527 if origin != href {
528 // Needs to be fixed up.
529 return Ok(Some(href));
530 }
531 }
532 Ok(None)
533 }
534 Err(e) => {
535 breadcrumb!(
536 "Error parsing login origin: {e:?} ({})",
537 error_support::redact_url(origin)
538 );
539 Err(InvalidLogin::IllegalOrigin {
540 reason: e.to_string(),
541 }
542 .into())
543 }
544 }
545 }
546
547 /// Validation and fixups for a login `origin`.
548 ///
549 /// When the `perform_additional_origin_fixups` feature is enabled, some
550 /// origins that fail URL parsing (bare domains, FireFTP quirks, etc.)
551 /// are repaired into parseable URLs.
552 pub fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
553 match Self::parse_and_normalize_origin(origin) {
554 Ok(result) => Ok(result),
555 Err(e) => {
556 #[cfg(feature = "perform_additional_origin_fixups")]
557 if let Some(fixed) = origin_fixup::perform_additional_origin_fixup(origin) {
558 if Url::parse(&fixed).is_ok() {
559 return Ok(Some(fixed));
560 }
561 }
562 Err(e)
563 }
564 }
565 }
566
567 /// Validation and normalizations for a login `form_action_origin`.
568 ///
569 /// When the `ignore_form_action_origin_validation_errors` feature is
570 /// enabled, unparseable values are accepted as-is (returning `Ok(None)`
571 /// so callers keep the original string), allowing non-URL values such
572 /// as "email" or "UserCode" that exist in some Desktop databases to be
573 /// saved regardless.
574 pub fn validate_and_normalize_form_action_origin(
575 form_action_origin: &str,
576 ) -> Result<Option<String>> {
577 match Self::parse_and_normalize_origin(form_action_origin) {
578 Ok(result) => Ok(result),
579 #[cfg(feature = "ignore_form_action_origin_validation_errors")]
580 Err(_) => Ok(None),
581 #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
582 Err(e) => Err(e),
583 }
584 }
585}
586
587/// A login handed over from the store API, which has been persisted and contains persistence
588/// information such as id and time stamps
589#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
590pub struct Login {
591 // meta fields
592 pub id: String,
593 pub time_created: i64,
594 pub time_password_changed: i64,
595 pub time_last_used: i64,
596 pub times_used: i64,
597 // breach alerts
598 pub time_last_breach_alert_dismissed: Option<i64>,
599
600 // login fields
601 pub origin: String,
602 pub form_action_origin: Option<String>,
603 pub http_realm: Option<String>,
604 pub username_field: String,
605 pub password_field: String,
606
607 // secure fields
608 pub username: String,
609 pub password: String,
610}
611
612impl Login {
613 pub fn new(meta: LoginMeta, fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
614 Self {
615 id: meta.id,
616 time_created: meta.time_created,
617 time_password_changed: meta.time_password_changed,
618 time_last_used: meta.time_last_used,
619 times_used: meta.times_used,
620 time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,
621
622 origin: fields.origin,
623 form_action_origin: fields.form_action_origin,
624 http_realm: fields.http_realm,
625 username_field: fields.username_field,
626 password_field: fields.password_field,
627
628 username: sec_fields.username,
629 password: sec_fields.password,
630 }
631 }
632
633 #[inline]
634 pub fn guid(&self) -> Guid {
635 Guid::from_string(self.id.clone())
636 }
637
638 pub fn entry(&self) -> LoginEntry {
639 LoginEntry {
640 origin: self.origin.clone(),
641 form_action_origin: self.form_action_origin.clone(),
642 http_realm: self.http_realm.clone(),
643 username_field: self.username_field.clone(),
644 password_field: self.password_field.clone(),
645
646 username: self.username.clone(),
647 password: self.password.clone(),
648 }
649 }
650
651 pub fn encrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<EncryptedLogin> {
652 let sec_fields = SecureLoginFields {
653 username: self.username,
654 password: self.password,
655 }
656 .encrypt(encdec, &self.id)?;
657 Ok(EncryptedLogin {
658 meta: LoginMeta {
659 id: self.id,
660 time_created: self.time_created,
661 time_password_changed: self.time_password_changed,
662 time_last_used: self.time_last_used,
663 times_used: self.times_used,
664 time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed,
665 },
666 fields: LoginFields {
667 origin: self.origin,
668 form_action_origin: self.form_action_origin,
669 http_realm: self.http_realm,
670 username_field: self.username_field,
671 password_field: self.password_field,
672 },
673 sec_fields,
674 })
675 }
676}
677
678/// A login stored in the database
679#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
680pub struct EncryptedLogin {
681 pub meta: LoginMeta,
682 pub fields: LoginFields,
683 pub sec_fields: String,
684}
685
686impl EncryptedLogin {
687 #[inline]
688 pub fn guid(&self) -> Guid {
689 Guid::from_string(self.meta.id.clone())
690 }
691
692 // TODO: Remove this: https://github.com/mozilla/application-services/issues/4185
693 #[inline]
694 pub fn guid_str(&self) -> &str {
695 &self.meta.id
696 }
697
698 pub fn decrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<Login> {
699 let sec_fields = self.decrypt_fields(encdec)?;
700 Ok(Login::new(self.meta, self.fields, sec_fields))
701 }
702
703 pub fn decrypt_fields(&self, encdec: &dyn EncryptorDecryptor) -> Result<SecureLoginFields> {
704 SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)
705 }
706
707 pub(crate) fn from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
708 let login = EncryptedLogin {
709 meta: LoginMeta {
710 id: row.get("guid")?,
711 time_created: row.get("timeCreated")?,
712 // Might be null
713 time_last_used: row
714 .get::<_, Option<i64>>("timeLastUsed")?
715 .unwrap_or_default(),
716
717 time_password_changed: row.get("timePasswordChanged")?,
718 times_used: row.get("timesUsed")?,
719
720 time_last_breach_alert_dismissed: row
721 .get::<_, Option<i64>>("timeLastBreachAlertDismissed")?,
722 },
723 fields: LoginFields {
724 origin: row.get("origin")?,
725 http_realm: row.get("httpRealm")?,
726
727 form_action_origin: row.get("formActionOrigin")?,
728
729 username_field: string_or_default(row, "usernameField")?,
730 password_field: string_or_default(row, "passwordField")?,
731 },
732 sec_fields: row.get("secFields")?,
733 };
734 // XXX - we used to perform a fixup here, but that seems heavy-handed
735 // and difficult - we now only do that on add/insert when we have the
736 // encryption key.
737 //
738 // Timestamps are the exception: they need no key, and a corrupt one read back out of
739 // the database breaks consumers hard enough to be worth fixing on every read (bug
740 // 2066257).
741 Ok(EncryptedLogin {
742 meta: login.meta.sanitize_timestamps(),
743 ..login
744 })
745 }
746}
747
748/// A login stored in the database, minus the encrypted fields.
749///
750/// Getting one of these never needs the encryption key, so callers which only match on the
751/// cleartext fields (eg, `origin`) can do so without forcing the user to authenticate. Once
752/// they know which logins they want, `LoginStore::get_many()` decrypts just those.
753#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
754pub struct LoginCandidate {
755 // meta fields
756 pub id: String,
757 pub time_created: i64,
758 pub time_password_changed: i64,
759 pub time_last_used: i64,
760 pub times_used: i64,
761
762 // breach alerts
763 pub time_last_breach_alert_dismissed: Option<i64>,
764
765 // login fields
766 pub origin: String,
767 pub form_action_origin: Option<String>,
768 pub http_realm: Option<String>,
769 pub username_field: String,
770 pub password_field: String,
771}
772
773impl From<EncryptedLogin> for LoginCandidate {
774 fn from(login: EncryptedLogin) -> Self {
775 // Note the `sec_fields` are simply dropped - we never look at the key.
776 let EncryptedLogin { meta, fields, .. } = login;
777 Self {
778 id: meta.id,
779 time_created: meta.time_created,
780 time_password_changed: meta.time_password_changed,
781 time_last_used: meta.time_last_used,
782 times_used: meta.times_used,
783 time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,
784
785 origin: fields.origin,
786 form_action_origin: fields.form_action_origin,
787 http_realm: fields.http_realm,
788 username_field: fields.username_field,
789 password_field: fields.password_field,
790 }
791 }
792}
793
794fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
795 Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
796}
797
798pub trait ValidateAndFixup {
799 // Our validate and fixup functions.
800 fn check_valid(&self) -> Result<()>
801 where
802 Self: Sized,
803 {
804 self.validate_and_fixup(false)?;
805 Ok(())
806 }
807
808 fn fixup(self) -> Result<Self>
809 where
810 Self: Sized,
811 {
812 match self.maybe_fixup()? {
813 None => Ok(self),
814 Some(login) => Ok(login),
815 }
816 }
817
818 fn maybe_fixup(&self) -> Result<Option<Self>>
819 where
820 Self: Sized,
821 {
822 self.validate_and_fixup(true)
823 }
824
825 // validates, and optionally fixes, a struct. If fixup is false and there is a validation
826 // issue, an `Err` is returned. If fixup is true and a problem was fixed, and `Ok(Some<Self>)`
827 // is returned with the fixed version. If there was no validation problem, `Ok(None)` is
828 // returned.
829 fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>>
830 where
831 Self: Sized;
832}
833
834impl ValidateAndFixup for LoginEntry {
835 fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
836 // XXX TODO: we've definitely got more validation and fixups to add here!
837
838 let mut maybe_fixed = None;
839
840 /// A little helper to magic a Some(self.clone()) into existence when needed.
841 macro_rules! get_fixed_or_throw {
842 ($err:expr) => {
843 // This is a block expression returning a local variable,
844 // entirely so we can give it an explicit type declaration.
845 {
846 if !fixup {
847 return Err($err.into());
848 }
849 warn!("Fixing login record {:?}", $err);
850 let fixed: Result<&mut Self> =
851 Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
852 fixed
853 }
854 };
855 }
856
857 if self.origin.is_empty() {
858 return Err(InvalidLogin::EmptyOrigin.into());
859 }
860
861 if self.form_action_origin.is_some() && self.http_realm.is_some() {
862 get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
863 }
864
865 if self.form_action_origin.is_none() && self.http_realm.is_none() {
866 return Err(InvalidLogin::NoTarget.into());
867 }
868
869 let form_action_origin = self.form_action_origin.clone().unwrap_or_default();
870 let http_realm = maybe_fixed
871 .as_ref()
872 .unwrap_or(self)
873 .http_realm
874 .clone()
875 .unwrap_or_default();
876
877 let field_data = [
878 ("form_action_origin", &form_action_origin),
879 ("http_realm", &http_realm),
880 ("origin", &self.origin),
881 ("username_field", &self.username_field),
882 ("password_field", &self.password_field),
883 ];
884
885 for (field_name, field_value) in &field_data {
886 // Nuls are invalid.
887 if field_value.contains('\0') {
888 return Err(InvalidLogin::IllegalFieldValue {
889 field_info: format!("`{}` contains Nul", field_name),
890 }
891 .into());
892 }
893
894 // Newlines are invalid in Desktop for all the fields here.
895 if field_value.contains('\n') || field_value.contains('\r') {
896 return Err(InvalidLogin::IllegalFieldValue {
897 field_info: format!("`{}` contains newline", field_name),
898 }
899 .into());
900 }
901 }
902
903 // Desktop doesn't like fields with the below patterns
904 if self.username_field == "." {
905 return Err(InvalidLogin::IllegalFieldValue {
906 field_info: "`username_field` is a period".into(),
907 }
908 .into());
909 }
910
911 // Check we can parse the origin, then use the normalized version of it.
912 if let Some(fixed) = Self::validate_and_fixup_origin(&self.origin)? {
913 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
914 field_info: "Origin is not normalized".into()
915 })?
916 .origin = fixed;
917 }
918
919 match &maybe_fixed.as_ref().unwrap_or(self).form_action_origin {
920 None => {
921 if !self.username_field.is_empty() {
922 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
923 field_info: "username_field must be empty when form_action_origin is null"
924 .into()
925 })?
926 .username_field
927 .clear();
928 }
929 if !self.password_field.is_empty() {
930 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
931 field_info: "password_field must be empty when form_action_origin is null"
932 .into()
933 })?
934 .password_field
935 .clear();
936 }
937 }
938 Some(href) => {
939 // "", ".", and "javascript:" are special cases documented at the top of this file.
940 if href == "." {
941 // A bit of a special case - if we are being asked to fixup, we replace
942 // "." with an empty string - but if not fixing up we don't complain.
943 if fixup {
944 maybe_fixed
945 .get_or_insert_with(|| self.clone())
946 .form_action_origin = Some("".into());
947 }
948 } else if !href.is_empty() && href != "javascript:" {
949 match Self::validate_and_normalize_form_action_origin(href) {
950 Ok(Some(fixed)) => {
951 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
952 field_info: "form_action_origin is not normalized".into()
953 })?
954 .form_action_origin = Some(fixed);
955 }
956 Ok(None) => {}
957 Err(e) => return Err(e),
958 }
959 }
960 }
961 }
962
963 // secure fields
964 //
965 // \r\n chars are valid in desktop for some reason, so we allow them here too.
966 if self.username.contains('\0') {
967 return Err(InvalidLogin::IllegalFieldValue {
968 field_info: "`username` contains Nul".into(),
969 }
970 .into());
971 }
972 // The `allow_empty_passwords` feature flag is used on desktop during the migration phase
973 // to allow existing logins with empty passwords to be imported.
974 #[cfg(not(feature = "allow_empty_passwords"))]
975 if self.password.is_empty() {
976 return Err(InvalidLogin::EmptyPassword.into());
977 }
978 if self.password.contains('\0') {
979 return Err(InvalidLogin::IllegalFieldValue {
980 field_info: "`password` contains Nul".into(),
981 }
982 .into());
983 }
984
985 Ok(maybe_fixed)
986 }
987}
988
989#[cfg(test)]
990pub mod test_utils {
991 use super::*;
992 use crate::encryption::test_utils::encrypt_struct;
993
994 // Factory function to make a new login
995 //
996 // It uses the guid to create a unique origin/form_action_origin
997 pub fn enc_login(id: &str, password: &str) -> EncryptedLogin {
998 let sec_fields = SecureLoginFields {
999 username: "user".to_string(),
1000 password: password.to_string(),
1001 };
1002 EncryptedLogin {
1003 meta: LoginMeta {
1004 id: id.to_string(),
1005 ..Default::default()
1006 },
1007 fields: LoginFields {
1008 form_action_origin: Some(format!("https://{}.example.com", id)),
1009 origin: format!("https://{}.example.com", id),
1010 ..Default::default()
1011 },
1012 // TODO: fixme
1013 sec_fields: encrypt_struct(&sec_fields),
1014 }
1015 }
1016}
1017
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021
1022 #[test]
1023 fn test_url_fixups() -> Result<()> {
1024 // Start with URLs which are all valid and already normalized.
1025 for input in &[
1026 // The list of valid origins documented at the top of this file.
1027 "https://site.com",
1028 "http://site.com:1234",
1029 "ftp://ftp.site.com",
1030 "moz-proxy://127.0.0.1:8888",
1031 "chrome://MyLegacyExtension",
1032 "file://",
1033 "https://[::1]",
1034 ] {
1035 assert_eq!(LoginEntry::validate_and_fixup_origin(input)?, None);
1036 }
1037
1038 // And URLs which get normalized.
1039 for (input, output) in &[
1040 ("https://site.com/", "https://site.com"),
1041 ("http://site.com:1234/", "http://site.com:1234"),
1042 ("http://example.com/foo?query=wtf#bar", "http://example.com"),
1043 ("http://example.com/foo#bar", "http://example.com"),
1044 (
1045 "http://username:password@example.com/",
1046 "http://example.com",
1047 ),
1048 ("http://😍.com/", "http://xn--r28h.com"),
1049 ("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
1050 // All `file://` URLs normalize to exactly `file://`. See #2384 for
1051 // why we might consider changing that later.
1052 ("file:///", "file://"),
1053 ("file://foo/bar", "file://"),
1054 ("file://foo/bar/", "file://"),
1055 ("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
1056 (
1057 "moz-proxy://127.0.0.1:8888/foo",
1058 "moz-proxy://127.0.0.1:8888",
1059 ),
1060 ("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
1061 (
1062 "chrome://MyLegacyExtension/foo",
1063 "chrome://MyLegacyExtension",
1064 ),
1065 ] {
1066 assert_eq!(
1067 LoginEntry::validate_and_fixup_origin(input)?,
1068 Some((*output).into())
1069 );
1070 }
1071
1072 // Finally, look at some invalid logins
1073 {
1074 let input = &".";
1075 assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
1076 }
1077 // With perform_additional_origin_fixups, bare domains/labels get a moz-pwmngr-fixed:// scheme
1078 #[cfg(not(feature = "perform_additional_origin_fixups"))]
1079 for input in &["example.com", "example"] {
1080 assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
1081 }
1082 #[cfg(feature = "perform_additional_origin_fixups")]
1083 {
1084 assert_eq!(
1085 LoginEntry::validate_and_fixup_origin("example.com")?,
1086 Some("moz-pwmngr-fixed://example.com".into())
1087 );
1088 assert_eq!(
1089 LoginEntry::validate_and_fixup_origin("example")?,
1090 Some("moz-pwmngr-fixed://example".into())
1091 );
1092 }
1093
1094 Ok(())
1095 }
1096
1097 #[cfg(feature = "perform_additional_origin_fixups")]
1098 #[test]
1099 fn test_additional_origin_fixups() -> Result<()> {
1100 // Origins that are already valid should not be changed
1101 for input in &[
1102 "https://example.com",
1103 "http://example.com:8080",
1104 "ftp://ftp.example.com",
1105 "moz-pwmngr-fixed://example.com",
1106 "moz-pwmngr-fixed://foo.bar",
1107 ] {
1108 assert_eq!(
1109 LoginEntry::validate_and_fixup_origin(input)?,
1110 None,
1111 "expected no change for: {input}"
1112 );
1113 }
1114
1115 // bare https: with incomplete authority (e.g. corrupted or addon-generated entry)
1116 for input in &["https:", "https:/", "https://"] {
1117 assert_eq!(
1118 LoginEntry::validate_and_fixup_origin(input)?,
1119 Some("https://moz.pwmngr.fixed".into()),
1120 "input: {input}"
1121 );
1122 }
1123
1124 // http://ftp.<IP>[:port] — FireFTP stored origins like this instead of ftp://
1125 assert_eq!(
1126 LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4")?,
1127 Some("ftp://1.2.3.4".into())
1128 );
1129 assert_eq!(
1130 LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4:21")?,
1131 Some("ftp://1.2.3.4:21".into())
1132 );
1133
1134 // ftp.<IPv4> without a scheme — FireFTP IP variant (ftp. prefix stripped;
1135 // ftp://ftp.<IP> would fail URL parsing due to the url crate's IPv4 detection)
1136 assert_eq!(
1137 LoginEntry::validate_and_fixup_origin("ftp.1.2.3.4")?,
1138 Some("ftp://1.2.3.4".into())
1139 );
1140 // ftp.<domain> without a scheme — FireFTP domain variant
1141 assert_eq!(
1142 LoginEntry::validate_and_fixup_origin("ftp.example.com")?,
1143 Some("ftp://ftp.example.com".into())
1144 );
1145
1146 // bare IPv4 address — addon-generated or manually entered
1147 assert_eq!(
1148 LoginEntry::validate_and_fixup_origin("1.2.3.4")?,
1149 Some("moz-pwmngr-fixed://1.2.3.4".into())
1150 );
1151
1152 // bare domain without a scheme — addon-generated origins (e.g. PassHash, gManager)
1153 for (input, output) in &[
1154 ("example.com", "moz-pwmngr-fixed://example.com"),
1155 ("sub.example.com", "moz-pwmngr-fixed://sub.example.com"),
1156 ("foo.bar", "moz-pwmngr-fixed://foo.bar"),
1157 ] {
1158 assert_eq!(
1159 LoginEntry::validate_and_fixup_origin(input)?,
1160 Some((*output).into()),
1161 "input: {input}"
1162 );
1163 }
1164
1165 // bare single-label hostname — addon-generated origins
1166 assert_eq!(
1167 LoginEntry::validate_and_fixup_origin("example")?,
1168 Some("moz-pwmngr-fixed://example".into())
1169 );
1170
1171 // things that cannot be fixed even with the feature on
1172 assert!(LoginEntry::validate_and_fixup_origin(".").is_err());
1173
1174 Ok(())
1175 }
1176
1177 #[test]
1178 fn test_form_action_origin_normalizes_valid_urls() -> Result<()> {
1179 // Already-normalized origins pass through.
1180 assert_eq!(
1181 LoginEntry::validate_and_normalize_form_action_origin("https://example.com")?,
1182 None
1183 );
1184 // Full URLs get normalized to origin-only form, same as for `origin`.
1185 assert_eq!(
1186 LoginEntry::validate_and_normalize_form_action_origin("https://example.com/foo?x=1")?,
1187 Some("https://example.com".into())
1188 );
1189 Ok(())
1190 }
1191
1192 // The `perform_additional_origin_fixups` feature is intentionally scoped
1193 // to the `origin` field. Inputs that it would repair for `origin` must
1194 // NOT be repaired here.
1195 #[cfg(feature = "perform_additional_origin_fixups")]
1196 #[test]
1197 fn test_form_action_origin_skips_additional_fixups() {
1198 for input in &[
1199 "example.com",
1200 "example",
1201 "1.2.3.4",
1202 "https:",
1203 "ftp.example.com",
1204 ] {
1205 let result = LoginEntry::validate_and_normalize_form_action_origin(input);
1206 // The result depends on the other feature flag, but in no case
1207 // should it be the moz-pwmngr-fixed:// / repaired form returned
1208 // by `validate_and_fixup_origin`.
1209 #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1210 assert_eq!(result.unwrap(), None, "input: {input}");
1211 #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1212 assert!(result.is_err(), "input: {input}");
1213 }
1214 }
1215
1216 #[test]
1217 #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1218 fn test_form_action_origin_rejects_invalid() {
1219 assert!(LoginEntry::validate_and_normalize_form_action_origin("email").is_err());
1220 }
1221
1222 #[test]
1223 #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1224 fn test_form_action_origin_accepts_invalid_with_feature() {
1225 // With the feature on, unparseable values return Ok(None) — meaning
1226 // "no fixup needed", so callers keep the original string as-is.
1227 assert_eq!(
1228 LoginEntry::validate_and_normalize_form_action_origin("email").unwrap(),
1229 None
1230 );
1231 }
1232
1233 #[test]
1234 fn test_check_valid() {
1235 #[derive(Debug, Clone)]
1236 struct TestCase {
1237 login: LoginEntry,
1238 should_err: bool,
1239 expected_err: &'static str,
1240 }
1241
1242 let valid_login = LoginEntry {
1243 origin: "https://www.example.com".into(),
1244 http_realm: Some("https://www.example.com".into()),
1245 username: "test".into(),
1246 password: "test".into(),
1247 ..Default::default()
1248 };
1249
1250 let login_with_empty_origin = LoginEntry {
1251 origin: "".into(),
1252 http_realm: Some("https://www.example.com".into()),
1253 username: "test".into(),
1254 password: "test".into(),
1255 ..Default::default()
1256 };
1257
1258 let login_with_empty_password = LoginEntry {
1259 origin: "https://www.example.com".into(),
1260 http_realm: Some("https://www.example.com".into()),
1261 username: "test".into(),
1262 password: "".into(),
1263 ..Default::default()
1264 };
1265
1266 let login_with_form_submit_and_http_realm = LoginEntry {
1267 origin: "https://www.example.com".into(),
1268 http_realm: Some("https://www.example.com".into()),
1269 form_action_origin: Some("https://www.example.com".into()),
1270 username: "".into(),
1271 password: "test".into(),
1272 ..Default::default()
1273 };
1274
1275 let login_without_form_submit_or_http_realm = LoginEntry {
1276 origin: "https://www.example.com".into(),
1277 username: "".into(),
1278 password: "test".into(),
1279 ..Default::default()
1280 };
1281
1282 let login_with_legacy_form_submit_and_http_realm = LoginEntry {
1283 origin: "https://www.example.com".into(),
1284 form_action_origin: Some("".into()),
1285 username: "".into(),
1286 password: "test".into(),
1287 ..Default::default()
1288 };
1289
1290 let login_with_null_http_realm = LoginEntry {
1291 origin: "https://www.example.com".into(),
1292 http_realm: Some("https://www.example.\0com".into()),
1293 username: "test".into(),
1294 password: "test".into(),
1295 ..Default::default()
1296 };
1297
1298 let login_with_null_username = LoginEntry {
1299 origin: "https://www.example.com".into(),
1300 http_realm: Some("https://www.example.com".into()),
1301 username: "\0".into(),
1302 password: "test".into(),
1303 ..Default::default()
1304 };
1305
1306 let login_with_null_password = LoginEntry {
1307 origin: "https://www.example.com".into(),
1308 http_realm: Some("https://www.example.com".into()),
1309 username: "username".into(),
1310 password: "test\0".into(),
1311 ..Default::default()
1312 };
1313
1314 let login_with_newline_origin = LoginEntry {
1315 origin: "\rhttps://www.example.com".into(),
1316 http_realm: Some("https://www.example.com".into()),
1317 username: "test".into(),
1318 password: "test".into(),
1319 ..Default::default()
1320 };
1321
1322 let login_with_newline_username_field = LoginEntry {
1323 origin: "https://www.example.com".into(),
1324 http_realm: Some("https://www.example.com".into()),
1325 username_field: "\n".into(),
1326 username: "test".into(),
1327 password: "test".into(),
1328 ..Default::default()
1329 };
1330
1331 let login_with_newline_realm = LoginEntry {
1332 origin: "https://www.example.com".into(),
1333 http_realm: Some("foo\nbar".into()),
1334 username: "test".into(),
1335 password: "test".into(),
1336 ..Default::default()
1337 };
1338
1339 let login_with_newline_password = LoginEntry {
1340 origin: "https://www.example.com".into(),
1341 http_realm: Some("https://www.example.com".into()),
1342 username: "test".into(),
1343 password: "test\n".into(),
1344 ..Default::default()
1345 };
1346
1347 let login_with_period_username_field = LoginEntry {
1348 origin: "https://www.example.com".into(),
1349 http_realm: Some("https://www.example.com".into()),
1350 username_field: ".".into(),
1351 username: "test".into(),
1352 password: "test".into(),
1353 ..Default::default()
1354 };
1355
1356 let login_with_period_form_action_origin = LoginEntry {
1357 form_action_origin: Some(".".into()),
1358 origin: "https://www.example.com".into(),
1359 username: "test".into(),
1360 password: "test".into(),
1361 ..Default::default()
1362 };
1363
1364 let login_with_javascript_form_action_origin = LoginEntry {
1365 form_action_origin: Some("javascript:".into()),
1366 origin: "https://www.example.com".into(),
1367 username: "test".into(),
1368 password: "test".into(),
1369 ..Default::default()
1370 };
1371
1372 let login_with_malformed_origin_parens = LoginEntry {
1373 origin: " (".into(),
1374 http_realm: Some("https://www.example.com".into()),
1375 username: "test".into(),
1376 password: "test".into(),
1377 ..Default::default()
1378 };
1379
1380 let login_with_host_unicode = LoginEntry {
1381 origin: "http://💖.com".into(),
1382 http_realm: Some("https://www.example.com".into()),
1383 username: "test".into(),
1384 password: "test".into(),
1385 ..Default::default()
1386 };
1387
1388 let login_with_origin_trailing_slash = LoginEntry {
1389 origin: "https://www.example.com/".into(),
1390 http_realm: Some("https://www.example.com".into()),
1391 username: "test".into(),
1392 password: "test".into(),
1393 ..Default::default()
1394 };
1395
1396 let login_with_origin_expanded_ipv6 = LoginEntry {
1397 origin: "https://[0:0:0:0:0:0:1:1]".into(),
1398 http_realm: Some("https://www.example.com".into()),
1399 username: "test".into(),
1400 password: "test".into(),
1401 ..Default::default()
1402 };
1403
1404 let login_with_unknown_protocol = LoginEntry {
1405 origin: "moz-proxy://127.0.0.1:8888".into(),
1406 http_realm: Some("https://www.example.com".into()),
1407 username: "test".into(),
1408 password: "test".into(),
1409 ..Default::default()
1410 };
1411
1412 let test_cases = [
1413 TestCase {
1414 login: valid_login,
1415 should_err: false,
1416 expected_err: "",
1417 },
1418 TestCase {
1419 login: login_with_empty_origin,
1420 should_err: true,
1421 expected_err: "Invalid login: Origin is empty",
1422 },
1423 TestCase {
1424 login: login_with_empty_password,
1425 should_err: cfg!(not(feature = "allow_empty_passwords")),
1426 expected_err: "Invalid login: Password is empty",
1427 },
1428 TestCase {
1429 login: login_with_form_submit_and_http_realm,
1430 should_err: true,
1431 expected_err: "Invalid login: Both `formActionOrigin` and `httpRealm` are present",
1432 },
1433 TestCase {
1434 login: login_without_form_submit_or_http_realm,
1435 should_err: true,
1436 expected_err:
1437 "Invalid login: Neither `formActionOrigin` or `httpRealm` are present",
1438 },
1439 TestCase {
1440 login: login_with_null_http_realm,
1441 should_err: true,
1442 expected_err: "Invalid login: Login has illegal field: `http_realm` contains Nul",
1443 },
1444 TestCase {
1445 login: login_with_null_username,
1446 should_err: true,
1447 expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
1448 },
1449 TestCase {
1450 login: login_with_null_password,
1451 should_err: true,
1452 expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
1453 },
1454 TestCase {
1455 login: login_with_newline_origin,
1456 should_err: true,
1457 expected_err: "Invalid login: Login has illegal field: `origin` contains newline",
1458 },
1459 TestCase {
1460 login: login_with_newline_realm,
1461 should_err: true,
1462 expected_err:
1463 "Invalid login: Login has illegal field: `http_realm` contains newline",
1464 },
1465 TestCase {
1466 login: login_with_newline_username_field,
1467 should_err: true,
1468 expected_err:
1469 "Invalid login: Login has illegal field: `username_field` contains newline",
1470 },
1471 TestCase {
1472 login: login_with_newline_password,
1473 should_err: false,
1474 expected_err: "",
1475 },
1476 TestCase {
1477 login: login_with_period_username_field,
1478 should_err: true,
1479 expected_err:
1480 "Invalid login: Login has illegal field: `username_field` is a period",
1481 },
1482 TestCase {
1483 login: login_with_period_form_action_origin,
1484 should_err: false,
1485 expected_err: "",
1486 },
1487 TestCase {
1488 login: login_with_javascript_form_action_origin,
1489 should_err: false,
1490 expected_err: "",
1491 },
1492 TestCase {
1493 login: login_with_malformed_origin_parens,
1494 should_err: true,
1495 expected_err:
1496 "Invalid login: Login has illegal origin: relative URL without a base",
1497 },
1498 TestCase {
1499 login: login_with_host_unicode,
1500 should_err: true,
1501 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1502 },
1503 TestCase {
1504 login: login_with_origin_trailing_slash,
1505 should_err: true,
1506 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1507 },
1508 TestCase {
1509 login: login_with_origin_expanded_ipv6,
1510 should_err: true,
1511 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1512 },
1513 TestCase {
1514 login: login_with_unknown_protocol,
1515 should_err: false,
1516 expected_err: "",
1517 },
1518 TestCase {
1519 login: login_with_legacy_form_submit_and_http_realm,
1520 should_err: false,
1521 expected_err: "",
1522 },
1523 ];
1524
1525 for tc in &test_cases {
1526 let actual = tc.login.check_valid();
1527
1528 if tc.should_err {
1529 assert!(actual.is_err(), "{:#?}", tc);
1530 assert_eq!(
1531 tc.expected_err,
1532 actual.unwrap_err().to_string(),
1533 "{:#?}",
1534 tc,
1535 );
1536 } else {
1537 assert!(actual.is_ok(), "{:#?}", tc);
1538 assert!(
1539 tc.login.clone().fixup().is_ok(),
1540 "Fixup failed after check_valid passed: {:#?}",
1541 &tc,
1542 );
1543 }
1544 }
1545 }
1546
1547 #[test]
1548 fn test_fixup() {
1549 #[derive(Debug, Default)]
1550 struct TestCase {
1551 login: LoginEntry,
1552 fixedup_host: Option<&'static str>,
1553 fixedup_form_action_origin: Option<String>,
1554 }
1555
1556 // Note that most URL fixups are tested above, but we have one or 2 here.
1557 let login_with_full_url = LoginEntry {
1558 origin: "http://example.com/foo?query=wtf#bar".into(),
1559 form_action_origin: Some("http://example.com/foo?query=wtf#bar".into()),
1560 username: "test".into(),
1561 password: "test".into(),
1562 ..Default::default()
1563 };
1564
1565 let login_with_host_unicode = LoginEntry {
1566 origin: "http://😍.com".into(),
1567 form_action_origin: Some("http://😍.com".into()),
1568 username: "test".into(),
1569 password: "test".into(),
1570 ..Default::default()
1571 };
1572
1573 let login_with_period_fsu = LoginEntry {
1574 origin: "https://example.com".into(),
1575 form_action_origin: Some(".".into()),
1576 username: "test".into(),
1577 password: "test".into(),
1578 ..Default::default()
1579 };
1580 let login_with_empty_fsu = LoginEntry {
1581 origin: "https://example.com".into(),
1582 form_action_origin: Some("".into()),
1583 username: "test".into(),
1584 password: "test".into(),
1585 ..Default::default()
1586 };
1587
1588 let login_with_form_submit_and_http_realm = LoginEntry {
1589 origin: "https://www.example.com".into(),
1590 form_action_origin: Some("https://www.example.com".into()),
1591 // If both http_realm and form_action_origin are specified, we drop
1592 // the former when fixing up. So for this test we must have an
1593 // invalid value in http_realm to ensure we don't validate a value
1594 // we end up dropping.
1595 http_realm: Some("\n".into()),
1596 username: "".into(),
1597 password: "test".into(),
1598 ..Default::default()
1599 };
1600
1601 let test_cases = [
1602 TestCase {
1603 login: login_with_full_url,
1604 fixedup_host: "http://example.com".into(),
1605 fixedup_form_action_origin: Some("http://example.com".into()),
1606 },
1607 TestCase {
1608 login: login_with_host_unicode,
1609 fixedup_host: "http://xn--r28h.com".into(),
1610 fixedup_form_action_origin: Some("http://xn--r28h.com".into()),
1611 },
1612 TestCase {
1613 login: login_with_period_fsu,
1614 fixedup_form_action_origin: Some("".into()),
1615 ..TestCase::default()
1616 },
1617 TestCase {
1618 login: login_with_form_submit_and_http_realm,
1619 fixedup_form_action_origin: Some("https://www.example.com".into()),
1620 ..TestCase::default()
1621 },
1622 TestCase {
1623 login: login_with_empty_fsu,
1624 // Should still be empty.
1625 fixedup_form_action_origin: Some("".into()),
1626 ..TestCase::default()
1627 },
1628 ];
1629
1630 for tc in &test_cases {
1631 let login = tc.login.clone().fixup().expect("should work");
1632 if let Some(expected) = tc.fixedup_host {
1633 assert_eq!(login.origin, expected, "origin not fixed in {:#?}", tc);
1634 }
1635 assert_eq!(
1636 login.form_action_origin, tc.fixedup_form_action_origin,
1637 "form_action_origin not fixed in {:#?}",
1638 tc,
1639 );
1640 login.check_valid().unwrap_or_else(|e| {
1641 panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
1642 });
1643 assert_eq!(
1644 login.clone().fixup().unwrap(),
1645 login,
1646 "fixup did not reach fixed point for testcase: {:#?}",
1647 tc,
1648 );
1649 }
1650 }
1651
1652 #[test]
1653 #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1654 fn test_invalid_form_action_origin_allowed() {
1655 let login = LoginEntry {
1656 origin: "https://example.com".into(),
1657 form_action_origin: Some("email".into()),
1658 username: "test".into(),
1659 password: "test".into(),
1660 ..Default::default()
1661 };
1662 let fixed = login.fixup().expect("should not error");
1663 assert_eq!(fixed.form_action_origin, Some("email".into()));
1664 }
1665
1666 #[test]
1667 fn test_secure_fields_serde() {
1668 let sf = SecureLoginFields {
1669 username: "foo".into(),
1670 password: "pwd".into(),
1671 };
1672 assert_eq!(
1673 serde_json::to_string(&sf).unwrap(),
1674 r#"{"u":"foo","p":"pwd"}"#
1675 );
1676 let got: SecureLoginFields = serde_json::from_str(r#"{"u": "user", "p": "p"}"#).unwrap();
1677 let expected = SecureLoginFields {
1678 username: "user".into(),
1679 password: "p".into(),
1680 };
1681 assert_eq!(got, expected);
1682 }
1683}