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}