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::{error::*, util::sanitize_timestamp};
285use db_crypto::EncryptorDecryptor;
286use rusqlite::Row;
287use serde_derive::*;
288use sync_guid::Guid;
289use url::Url;
290
291// The Desktop FxA session-credentials pseudo-login. Firefox stores its account
292// credentials as a login under this origin; it must never be synced. This
293// mirrors the exclusion the JS `PasswordEngine` does via
294// `Utils.getSyncCredentialsHosts()`. Only relevant on Desktop (mobile never has
295// such a login), but it's harmless to filter everywhere.
296pub(crate) const FXA_CREDENTIALS_ORIGIN: &str = "chrome://FirefoxAccounts";
297
298// LoginEntry fields that are stored in cleartext
299#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
300pub struct LoginFields {
301    pub origin: String,
302    pub form_action_origin: Option<String>,
303    pub http_realm: Option<String>,
304    pub username_field: String,
305    pub password_field: String,
306}
307
308/// LoginEntry fields that are stored encrypted
309#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
310pub struct SecureLoginFields {
311    // - Username cannot be null, use the empty string instead
312    // - Password can't be empty or null (enforced in the ValidateAndFixup code)
313    //
314    // This matches the desktop behavior:
315    // https://searchfox.org/mozilla-central/rev/d3683dbb252506400c71256ef3994cdbdfb71ada/toolkit/components/passwordmgr/LoginManager.jsm#260-267
316
317    // Because we store the json version of this in the DB, and that's the only place the json
318    // is used, we rename the fields to short names, just to reduce the overhead in the DB.
319    #[serde(rename = "u")]
320    pub username: String,
321    #[serde(rename = "p")]
322    pub password: String,
323}
324
325impl SecureLoginFields {
326    pub fn encrypt(&self, encdec: &dyn EncryptorDecryptor, login_id: &str) -> Result<String> {
327        let string = serde_json::to_string(&self)?;
328        let cipherbytes = encdec
329            .encrypt(string.as_bytes().into())
330            .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting {login_id})")))?;
331        let ciphertext = std::str::from_utf8(&cipherbytes).map_err(|e| {
332            Error::EncryptionFailed(format!("{e} (encrypting {login_id}: data not utf8)"))
333        })?;
334        Ok(ciphertext.to_owned())
335    }
336
337    pub fn decrypt(
338        ciphertext: &str,
339        encdec: &dyn EncryptorDecryptor,
340        login_id: &str,
341    ) -> Result<Self> {
342        let jsonbytes = encdec.decrypt(ciphertext.as_bytes().into()).map_err(|e| {
343            Error::DecryptionFailed(format!(
344                "{e} (decrypting {login_id}, ciphertext length: {})",
345                ciphertext.len(),
346            ))
347        })?;
348        let json =
349            std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
350        Ok(serde_json::from_str(json)?)
351    }
352}
353
354/// Login data specific to database records
355#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
356pub struct LoginMeta {
357    pub id: String,
358    pub time_created: i64,
359    pub time_password_changed: i64,
360    pub time_last_used: i64,
361    pub times_used: i64,
362    pub time_last_breach_alert_dismissed: Option<i64>,
363}
364
365impl LoginMeta {
366    /// Clamp every timestamp into `[0, now]`.
367    ///
368    /// This runs on both read and write - so that a corrupt timestamp does not break.
369    pub(crate) fn sanitize_timestamps(self) -> Self {
370        Self {
371            time_created: sanitize_timestamp(self.time_created),
372            time_password_changed: sanitize_timestamp(self.time_password_changed),
373            time_last_used: sanitize_timestamp(self.time_last_used),
374            time_last_breach_alert_dismissed: self
375                .time_last_breach_alert_dismissed
376                .map(sanitize_timestamp),
377            ..self
378        }
379    }
380}
381
382/// A login together with meta fields, handed over to the store API; ie a login persisted
383/// elsewhere, useful for migrations
384pub struct LoginEntryWithMeta {
385    pub entry: LoginEntry,
386    pub meta: LoginMeta,
387}
388
389/// A bulk insert result entry, returned by `add_many` and `add_many_with_records`
390/// Please note that although the success case is much larger than the error case, this is
391/// negligible in real life, as we expect a very small success/error ratio.
392#[allow(clippy::large_enum_variant)]
393pub enum BulkResultEntry {
394    Success { login: Login },
395    Error { message: String },
396}
397
398/// A login handed over to the store API; ie a login not yet persisted
399#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
400pub struct LoginEntry {
401    // login fields
402    pub origin: String,
403    pub form_action_origin: Option<String>,
404    pub http_realm: Option<String>,
405    pub username_field: String,
406    pub password_field: String,
407
408    // secure fields
409    pub username: String,
410    pub password: String,
411}
412
413#[cfg(feature = "perform_additional_origin_fixups")]
414mod origin_fixup {
415    fn looks_like_bare_ipv4(s: &str) -> bool {
416        let parts: Vec<&str> = s.split('.').collect();
417        parts.len() == 4 && parts.iter().all(|p| p.parse::<u8>().is_ok())
418    }
419
420    // Returns true if `s` looks like a bare domain name (e.g. `example.com`):
421    // at least two dot-separated labels, each label only ASCII alphanumeric or hyphens.
422    fn looks_like_bare_domain(s: &str) -> bool {
423        let parts: Vec<&str> = s.split('.').collect();
424        parts.len() >= 2
425            && parts
426                .iter()
427                .all(|p| !p.is_empty() && p.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))
428    }
429
430    // Returns true if `s` looks like a single hostname label (no dots),
431    // e.g. addon-generated origins like "example".
432    fn looks_like_bare_label(s: &str) -> bool {
433        !s.is_empty()
434            && !s.contains('.')
435            && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
436    }
437
438    // Attempts to repair origins that fail URL parsing:
439    // - bare https: / https:/ / https:// → https://moz.pwmngr.fixed
440    // - http://ftp.<IPv4>[:port] → ftp://<IPv4>[:port]  (FireFTP quirk)
441    // - ftp.<IPv4>[:port] without a scheme → ftp://<IPv4>[:port]
442    // - ftp.<domain> without a scheme → ftp://ftp.<domain>
443    // - bare IPv4 address or bare domain → moz-pwmngr-fixed://<host>
444    // - bare label (e.g. example) → moz-pwmngr-fixed://<label>
445    pub fn perform_additional_origin_fixup(origin: &str) -> Option<String> {
446        // Bare https: with missing or incomplete authority.
447        if matches!(origin, "https:" | "https:/" | "https://") {
448            return Some("https://moz.pwmngr.fixed".to_string());
449        }
450
451        // http://ftp.<IP>[:port] → ftp://<IP>[:port]
452        if let Some(rest) = origin.strip_prefix("http://ftp.") {
453            let host = rest.split(':').next().unwrap_or(rest);
454            if looks_like_bare_ipv4(host) {
455                return Some(format!("ftp://{rest}"));
456            }
457        }
458
459        // ftp.<IPv4 or bare domain> without a scheme
460        if let Some(rest) = origin.strip_prefix("ftp.") {
461            if looks_like_bare_ipv4(rest) {
462                // ftp.<IP> → ftp://<IP> (strips ftp. prefix; ftp://ftp.<IP> would fail URL parsing)
463                return Some(format!("ftp://{rest}"));
464            } else if looks_like_bare_domain(rest) {
465                // ftp.<domain> → ftp://ftp.<domain>
466                return Some(format!("ftp://{origin}"));
467            }
468        }
469
470        // bare domain, IPv4 address, or single-label hostname → moz-pwmngr-fixed://
471        if looks_like_bare_domain(origin) || looks_like_bare_label(origin) {
472            return Some(format!("moz-pwmngr-fixed://{origin}"));
473        }
474
475        None
476    }
477}
478
479impl LoginEntry {
480    pub fn new(fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
481        Self {
482            origin: fields.origin,
483            form_action_origin: fields.form_action_origin,
484            http_realm: fields.http_realm,
485            username_field: fields.username_field,
486            password_field: fields.password_field,
487
488            username: sec_fields.username,
489            password: sec_fields.password,
490        }
491    }
492
493    /// Shared core logic for origin-like fields: parses `origin` as a URL and
494    /// normalizes it to origin-only form. Returns `Ok(None)` if the input is
495    /// already a valid, normalized origin, `Ok(Some(fixed))` if it needed
496    /// normalization, or `Err` if the input cannot be parsed as a URL.
497    fn parse_and_normalize_origin(origin: &str) -> Result<Option<String>> {
498        match Url::parse(origin) {
499            Ok(mut u) => {
500                // Presumably this is a faster path than always setting?
501                if u.path() != "/"
502                    || u.fragment().is_some()
503                    || u.query().is_some()
504                    || u.username() != "/"
505                    || u.password().is_some()
506                {
507                    // Not identical - we only want the origin part, so kill
508                    // any other parts which may exist.
509                    // But first special case `file://` URLs which always
510                    // resolve to `file://`
511                    if u.scheme() == "file" {
512                        return Ok(if origin == "file://" {
513                            None
514                        } else {
515                            Some("file://".into())
516                        });
517                    }
518                    u.set_path("");
519                    u.set_fragment(None);
520                    u.set_query(None);
521                    let _ = u.set_username("");
522                    let _ = u.set_password(None);
523                    let mut href = String::from(u);
524                    // We always store without the trailing "/" which Urls have.
525                    if href.ends_with('/') {
526                        href.pop().expect("url must have a length");
527                    }
528                    if origin != href {
529                        // Needs to be fixed up.
530                        return Ok(Some(href));
531                    }
532                }
533                Ok(None)
534            }
535            Err(e) => {
536                breadcrumb!(
537                    "Error parsing login origin: {e:?} ({})",
538                    error_support::redact_url(origin)
539                );
540                Err(InvalidLogin::IllegalOrigin {
541                    reason: e.to_string(),
542                }
543                .into())
544            }
545        }
546    }
547
548    /// Validation and fixups for a login `origin`.
549    ///
550    /// When the `perform_additional_origin_fixups` feature is enabled, some
551    /// origins that fail URL parsing (bare domains, FireFTP quirks, etc.)
552    /// are repaired into parseable URLs.
553    pub fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
554        match Self::parse_and_normalize_origin(origin) {
555            Ok(result) => Ok(result),
556            Err(e) => {
557                #[cfg(feature = "perform_additional_origin_fixups")]
558                if let Some(fixed) = origin_fixup::perform_additional_origin_fixup(origin) {
559                    if Url::parse(&fixed).is_ok() {
560                        return Ok(Some(fixed));
561                    }
562                }
563                Err(e)
564            }
565        }
566    }
567
568    /// Validation and normalizations for a login `form_action_origin`.
569    ///
570    /// When the `ignore_form_action_origin_validation_errors` feature is
571    /// enabled, unparseable values are accepted as-is (returning `Ok(None)`
572    /// so callers keep the original string), allowing non-URL values such
573    /// as "email" or "UserCode" that exist in some Desktop databases to be
574    /// saved regardless.
575    pub fn validate_and_normalize_form_action_origin(
576        form_action_origin: &str,
577    ) -> Result<Option<String>> {
578        match Self::parse_and_normalize_origin(form_action_origin) {
579            Ok(result) => Ok(result),
580            #[cfg(feature = "ignore_form_action_origin_validation_errors")]
581            Err(_) => Ok(None),
582            #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
583            Err(e) => Err(e),
584        }
585    }
586}
587
588/// A login handed over from the store API, which has been persisted and contains persistence
589/// information such as id and time stamps
590#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
591pub struct Login {
592    // meta fields
593    pub id: String,
594    pub time_created: i64,
595    pub time_password_changed: i64,
596    pub time_last_used: i64,
597    pub times_used: i64,
598    // breach alerts
599    pub time_last_breach_alert_dismissed: Option<i64>,
600
601    // login fields
602    pub origin: String,
603    pub form_action_origin: Option<String>,
604    pub http_realm: Option<String>,
605    pub username_field: String,
606    pub password_field: String,
607
608    // secure fields
609    pub username: String,
610    pub password: String,
611}
612
613impl Login {
614    pub fn new(meta: LoginMeta, fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
615        Self {
616            id: meta.id,
617            time_created: meta.time_created,
618            time_password_changed: meta.time_password_changed,
619            time_last_used: meta.time_last_used,
620            times_used: meta.times_used,
621            time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,
622
623            origin: fields.origin,
624            form_action_origin: fields.form_action_origin,
625            http_realm: fields.http_realm,
626            username_field: fields.username_field,
627            password_field: fields.password_field,
628
629            username: sec_fields.username,
630            password: sec_fields.password,
631        }
632    }
633
634    #[inline]
635    pub fn guid(&self) -> Guid {
636        Guid::from_string(self.id.clone())
637    }
638
639    pub fn entry(&self) -> LoginEntry {
640        LoginEntry {
641            origin: self.origin.clone(),
642            form_action_origin: self.form_action_origin.clone(),
643            http_realm: self.http_realm.clone(),
644            username_field: self.username_field.clone(),
645            password_field: self.password_field.clone(),
646
647            username: self.username.clone(),
648            password: self.password.clone(),
649        }
650    }
651
652    pub fn encrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<EncryptedLogin> {
653        let sec_fields = SecureLoginFields {
654            username: self.username,
655            password: self.password,
656        }
657        .encrypt(encdec, &self.id)?;
658        Ok(EncryptedLogin {
659            meta: LoginMeta {
660                id: self.id,
661                time_created: self.time_created,
662                time_password_changed: self.time_password_changed,
663                time_last_used: self.time_last_used,
664                times_used: self.times_used,
665                time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed,
666            },
667            fields: LoginFields {
668                origin: self.origin,
669                form_action_origin: self.form_action_origin,
670                http_realm: self.http_realm,
671                username_field: self.username_field,
672                password_field: self.password_field,
673            },
674            sec_fields,
675        })
676    }
677}
678
679/// A login stored in the database
680#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
681pub struct EncryptedLogin {
682    pub meta: LoginMeta,
683    pub fields: LoginFields,
684    pub sec_fields: String,
685}
686
687impl EncryptedLogin {
688    #[inline]
689    pub fn guid(&self) -> Guid {
690        Guid::from_string(self.meta.id.clone())
691    }
692
693    // TODO: Remove this: https://github.com/mozilla/application-services/issues/4185
694    #[inline]
695    pub fn guid_str(&self) -> &str {
696        &self.meta.id
697    }
698
699    pub fn decrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<Login> {
700        let sec_fields = self.decrypt_fields(encdec)?;
701        Ok(Login::new(self.meta, self.fields, sec_fields))
702    }
703
704    pub fn decrypt_fields(&self, encdec: &dyn EncryptorDecryptor) -> Result<SecureLoginFields> {
705        SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)
706    }
707
708    pub(crate) fn from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
709        let login = EncryptedLogin {
710            meta: LoginMeta {
711                id: row.get("guid")?,
712                time_created: row.get("timeCreated")?,
713                // Might be null
714                time_last_used: row
715                    .get::<_, Option<i64>>("timeLastUsed")?
716                    .unwrap_or_default(),
717
718                time_password_changed: row.get("timePasswordChanged")?,
719                times_used: row.get("timesUsed")?,
720
721                time_last_breach_alert_dismissed: row
722                    .get::<_, Option<i64>>("timeLastBreachAlertDismissed")?,
723            },
724            fields: LoginFields {
725                origin: row.get("origin")?,
726                http_realm: row.get("httpRealm")?,
727
728                form_action_origin: row.get("formActionOrigin")?,
729
730                username_field: string_or_default(row, "usernameField")?,
731                password_field: string_or_default(row, "passwordField")?,
732            },
733            sec_fields: row.get("secFields")?,
734        };
735        // XXX - we used to perform a fixup here, but that seems heavy-handed
736        // and difficult - we now only do that on add/insert when we have the
737        // encryption key.
738        //
739        // Timestamps are the exception: they need no key, and a corrupt one read back out of
740        // the database breaks consumers hard enough to be worth fixing on every read (bug
741        // 2066257).
742        Ok(EncryptedLogin {
743            meta: login.meta.sanitize_timestamps(),
744            ..login
745        })
746    }
747}
748
749/// A login stored in the database, minus the encrypted fields.
750///
751/// Getting one of these never needs the encryption key, so callers which only match on the
752/// cleartext fields (eg, `origin`) can do so without forcing the user to authenticate.  Once
753/// they know which logins they want, `LoginStore::get_many()` decrypts just those.
754#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
755pub struct LoginCandidate {
756    // meta fields
757    pub id: String,
758    pub time_created: i64,
759    pub time_password_changed: i64,
760    pub time_last_used: i64,
761    pub times_used: i64,
762
763    // breach alerts
764    pub time_last_breach_alert_dismissed: Option<i64>,
765
766    // login fields
767    pub origin: String,
768    pub form_action_origin: Option<String>,
769    pub http_realm: Option<String>,
770    pub username_field: String,
771    pub password_field: String,
772}
773
774impl From<EncryptedLogin> for LoginCandidate {
775    fn from(login: EncryptedLogin) -> Self {
776        // Note the `sec_fields` are simply dropped - we never look at the key.
777        let EncryptedLogin { meta, fields, .. } = login;
778        Self {
779            id: meta.id,
780            time_created: meta.time_created,
781            time_password_changed: meta.time_password_changed,
782            time_last_used: meta.time_last_used,
783            times_used: meta.times_used,
784            time_last_breach_alert_dismissed: meta.time_last_breach_alert_dismissed,
785
786            origin: fields.origin,
787            form_action_origin: fields.form_action_origin,
788            http_realm: fields.http_realm,
789            username_field: fields.username_field,
790            password_field: fields.password_field,
791        }
792    }
793}
794
795fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
796    Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
797}
798
799pub trait ValidateAndFixup {
800    // Our validate and fixup functions.
801    fn check_valid(&self) -> Result<()>
802    where
803        Self: Sized,
804    {
805        self.validate_and_fixup(false)?;
806        Ok(())
807    }
808
809    fn fixup(self) -> Result<Self>
810    where
811        Self: Sized,
812    {
813        match self.maybe_fixup()? {
814            None => Ok(self),
815            Some(login) => Ok(login),
816        }
817    }
818
819    fn maybe_fixup(&self) -> Result<Option<Self>>
820    where
821        Self: Sized,
822    {
823        self.validate_and_fixup(true)
824    }
825
826    // validates, and optionally fixes, a struct. If fixup is false and there is a validation
827    // issue, an `Err` is returned. If fixup is true and a problem was fixed, and `Ok(Some<Self>)`
828    // is returned with the fixed version. If there was no validation problem, `Ok(None)` is
829    // returned.
830    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>>
831    where
832        Self: Sized;
833}
834
835impl ValidateAndFixup for LoginEntry {
836    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
837        // XXX TODO: we've definitely got more validation and fixups to add here!
838
839        let mut maybe_fixed = None;
840
841        /// A little helper to magic a Some(self.clone()) into existence when needed.
842        macro_rules! get_fixed_or_throw {
843            ($err:expr) => {
844                // This is a block expression returning a local variable,
845                // entirely so we can give it an explicit type declaration.
846                {
847                    if !fixup {
848                        return Err($err.into());
849                    }
850                    warn!("Fixing login record {:?}", $err);
851                    let fixed: Result<&mut Self> =
852                        Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
853                    fixed
854                }
855            };
856        }
857
858        if self.origin.is_empty() {
859            return Err(InvalidLogin::EmptyOrigin.into());
860        }
861
862        if self.form_action_origin.is_some() && self.http_realm.is_some() {
863            get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
864        }
865
866        if self.form_action_origin.is_none() && self.http_realm.is_none() {
867            return Err(InvalidLogin::NoTarget.into());
868        }
869
870        let form_action_origin = self.form_action_origin.clone().unwrap_or_default();
871        let http_realm = maybe_fixed
872            .as_ref()
873            .unwrap_or(self)
874            .http_realm
875            .clone()
876            .unwrap_or_default();
877
878        let field_data = [
879            ("form_action_origin", &form_action_origin),
880            ("http_realm", &http_realm),
881            ("origin", &self.origin),
882            ("username_field", &self.username_field),
883            ("password_field", &self.password_field),
884        ];
885
886        for (field_name, field_value) in &field_data {
887            // Nuls are invalid.
888            if field_value.contains('\0') {
889                return Err(InvalidLogin::IllegalFieldValue {
890                    field_info: format!("`{}` contains Nul", field_name),
891                }
892                .into());
893            }
894
895            // Newlines are invalid in Desktop for all the fields here.
896            if field_value.contains('\n') || field_value.contains('\r') {
897                return Err(InvalidLogin::IllegalFieldValue {
898                    field_info: format!("`{}` contains newline", field_name),
899                }
900                .into());
901            }
902        }
903
904        // Desktop doesn't like fields with the below patterns
905        if self.username_field == "." {
906            return Err(InvalidLogin::IllegalFieldValue {
907                field_info: "`username_field` is a period".into(),
908            }
909            .into());
910        }
911
912        // Check we can parse the origin, then use the normalized version of it.
913        if let Some(fixed) = Self::validate_and_fixup_origin(&self.origin)? {
914            get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
915                field_info: "Origin is not normalized".into()
916            })?
917            .origin = fixed;
918        }
919
920        match &maybe_fixed.as_ref().unwrap_or(self).form_action_origin {
921            None => {
922                if !self.username_field.is_empty() {
923                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
924                        field_info: "username_field must be empty when form_action_origin is null"
925                            .into()
926                    })?
927                    .username_field
928                    .clear();
929                }
930                if !self.password_field.is_empty() {
931                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
932                        field_info: "password_field must be empty when form_action_origin is null"
933                            .into()
934                    })?
935                    .password_field
936                    .clear();
937                }
938            }
939            Some(href) => {
940                // "", ".", and "javascript:" are special cases documented at the top of this file.
941                if href == "." {
942                    // A bit of a special case - if we are being asked to fixup, we replace
943                    // "." with an empty string - but if not fixing up we don't complain.
944                    if fixup {
945                        maybe_fixed
946                            .get_or_insert_with(|| self.clone())
947                            .form_action_origin = Some("".into());
948                    }
949                } else if !href.is_empty() && href != "javascript:" {
950                    match Self::validate_and_normalize_form_action_origin(href) {
951                        Ok(Some(fixed)) => {
952                            get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
953                                field_info: "form_action_origin is not normalized".into()
954                            })?
955                            .form_action_origin = Some(fixed);
956                        }
957                        Ok(None) => {}
958                        Err(e) => return Err(e),
959                    }
960                }
961            }
962        }
963
964        // secure fields
965        //
966        // \r\n chars are valid in desktop for some reason, so we allow them here too.
967        if self.username.contains('\0') {
968            return Err(InvalidLogin::IllegalFieldValue {
969                field_info: "`username` contains Nul".into(),
970            }
971            .into());
972        }
973        // The `allow_empty_passwords` feature flag is used on desktop during the migration phase
974        // to allow existing logins with empty passwords to be imported.
975        #[cfg(not(feature = "allow_empty_passwords"))]
976        if self.password.is_empty() {
977            return Err(InvalidLogin::EmptyPassword.into());
978        }
979        if self.password.contains('\0') {
980            return Err(InvalidLogin::IllegalFieldValue {
981                field_info: "`password` contains Nul".into(),
982            }
983            .into());
984        }
985
986        Ok(maybe_fixed)
987    }
988}
989
990#[cfg(not(feature = "keydb"))]
991#[cfg(test)]
992pub mod test_utils {
993    use super::*;
994    use crate::test_utils::encrypt_struct;
995
996    // Factory function to make a new login
997    //
998    // It uses the guid to create a unique origin/form_action_origin
999    pub fn enc_login(id: &str, password: &str) -> EncryptedLogin {
1000        let sec_fields = SecureLoginFields {
1001            username: "user".to_string(),
1002            password: password.to_string(),
1003        };
1004        EncryptedLogin {
1005            meta: LoginMeta {
1006                id: id.to_string(),
1007                ..Default::default()
1008            },
1009            fields: LoginFields {
1010                form_action_origin: Some(format!("https://{}.example.com", id)),
1011                origin: format!("https://{}.example.com", id),
1012                ..Default::default()
1013            },
1014            // TODO: fixme
1015            sec_fields: encrypt_struct(&sec_fields),
1016        }
1017    }
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use super::*;
1023
1024    #[test]
1025    fn test_url_fixups() -> Result<()> {
1026        // Start with URLs which are all valid and already normalized.
1027        for input in &[
1028            // The list of valid origins documented at the top of this file.
1029            "https://site.com",
1030            "http://site.com:1234",
1031            "ftp://ftp.site.com",
1032            "moz-proxy://127.0.0.1:8888",
1033            "chrome://MyLegacyExtension",
1034            "file://",
1035            "https://[::1]",
1036        ] {
1037            assert_eq!(LoginEntry::validate_and_fixup_origin(input)?, None);
1038        }
1039
1040        // And URLs which get normalized.
1041        for (input, output) in &[
1042            ("https://site.com/", "https://site.com"),
1043            ("http://site.com:1234/", "http://site.com:1234"),
1044            ("http://example.com/foo?query=wtf#bar", "http://example.com"),
1045            ("http://example.com/foo#bar", "http://example.com"),
1046            (
1047                "http://username:password@example.com/",
1048                "http://example.com",
1049            ),
1050            ("http://😍.com/", "http://xn--r28h.com"),
1051            ("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
1052            // All `file://` URLs normalize to exactly `file://`. See #2384 for
1053            // why we might consider changing that later.
1054            ("file:///", "file://"),
1055            ("file://foo/bar", "file://"),
1056            ("file://foo/bar/", "file://"),
1057            ("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
1058            (
1059                "moz-proxy://127.0.0.1:8888/foo",
1060                "moz-proxy://127.0.0.1:8888",
1061            ),
1062            ("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
1063            (
1064                "chrome://MyLegacyExtension/foo",
1065                "chrome://MyLegacyExtension",
1066            ),
1067        ] {
1068            assert_eq!(
1069                LoginEntry::validate_and_fixup_origin(input)?,
1070                Some((*output).into())
1071            );
1072        }
1073
1074        // Finally, look at some invalid logins
1075        {
1076            let input = &".";
1077            assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
1078        }
1079        // With perform_additional_origin_fixups, bare domains/labels get a moz-pwmngr-fixed:// scheme
1080        #[cfg(not(feature = "perform_additional_origin_fixups"))]
1081        for input in &["example.com", "example"] {
1082            assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
1083        }
1084        #[cfg(feature = "perform_additional_origin_fixups")]
1085        {
1086            assert_eq!(
1087                LoginEntry::validate_and_fixup_origin("example.com")?,
1088                Some("moz-pwmngr-fixed://example.com".into())
1089            );
1090            assert_eq!(
1091                LoginEntry::validate_and_fixup_origin("example")?,
1092                Some("moz-pwmngr-fixed://example".into())
1093            );
1094        }
1095
1096        Ok(())
1097    }
1098
1099    #[cfg(feature = "perform_additional_origin_fixups")]
1100    #[test]
1101    fn test_additional_origin_fixups() -> Result<()> {
1102        // Origins that are already valid should not be changed
1103        for input in &[
1104            "https://example.com",
1105            "http://example.com:8080",
1106            "ftp://ftp.example.com",
1107            "moz-pwmngr-fixed://example.com",
1108            "moz-pwmngr-fixed://foo.bar",
1109        ] {
1110            assert_eq!(
1111                LoginEntry::validate_and_fixup_origin(input)?,
1112                None,
1113                "expected no change for: {input}"
1114            );
1115        }
1116
1117        // bare https: with incomplete authority (e.g. corrupted or addon-generated entry)
1118        for input in &["https:", "https:/", "https://"] {
1119            assert_eq!(
1120                LoginEntry::validate_and_fixup_origin(input)?,
1121                Some("https://moz.pwmngr.fixed".into()),
1122                "input: {input}"
1123            );
1124        }
1125
1126        // http://ftp.<IP>[:port] — FireFTP stored origins like this instead of ftp://
1127        assert_eq!(
1128            LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4")?,
1129            Some("ftp://1.2.3.4".into())
1130        );
1131        assert_eq!(
1132            LoginEntry::validate_and_fixup_origin("http://ftp.1.2.3.4:21")?,
1133            Some("ftp://1.2.3.4:21".into())
1134        );
1135
1136        // ftp.<IPv4> without a scheme — FireFTP IP variant (ftp. prefix stripped;
1137        // ftp://ftp.<IP> would fail URL parsing due to the url crate's IPv4 detection)
1138        assert_eq!(
1139            LoginEntry::validate_and_fixup_origin("ftp.1.2.3.4")?,
1140            Some("ftp://1.2.3.4".into())
1141        );
1142        // ftp.<domain> without a scheme — FireFTP domain variant
1143        assert_eq!(
1144            LoginEntry::validate_and_fixup_origin("ftp.example.com")?,
1145            Some("ftp://ftp.example.com".into())
1146        );
1147
1148        // bare IPv4 address — addon-generated or manually entered
1149        assert_eq!(
1150            LoginEntry::validate_and_fixup_origin("1.2.3.4")?,
1151            Some("moz-pwmngr-fixed://1.2.3.4".into())
1152        );
1153
1154        // bare domain without a scheme — addon-generated origins (e.g. PassHash, gManager)
1155        for (input, output) in &[
1156            ("example.com", "moz-pwmngr-fixed://example.com"),
1157            ("sub.example.com", "moz-pwmngr-fixed://sub.example.com"),
1158            ("foo.bar", "moz-pwmngr-fixed://foo.bar"),
1159        ] {
1160            assert_eq!(
1161                LoginEntry::validate_and_fixup_origin(input)?,
1162                Some((*output).into()),
1163                "input: {input}"
1164            );
1165        }
1166
1167        // bare single-label hostname — addon-generated origins
1168        assert_eq!(
1169            LoginEntry::validate_and_fixup_origin("example")?,
1170            Some("moz-pwmngr-fixed://example".into())
1171        );
1172
1173        // things that cannot be fixed even with the feature on
1174        assert!(LoginEntry::validate_and_fixup_origin(".").is_err());
1175
1176        Ok(())
1177    }
1178
1179    #[test]
1180    fn test_form_action_origin_normalizes_valid_urls() -> Result<()> {
1181        // Already-normalized origins pass through.
1182        assert_eq!(
1183            LoginEntry::validate_and_normalize_form_action_origin("https://example.com")?,
1184            None
1185        );
1186        // Full URLs get normalized to origin-only form, same as for `origin`.
1187        assert_eq!(
1188            LoginEntry::validate_and_normalize_form_action_origin("https://example.com/foo?x=1")?,
1189            Some("https://example.com".into())
1190        );
1191        Ok(())
1192    }
1193
1194    // The `perform_additional_origin_fixups` feature is intentionally scoped
1195    // to the `origin` field. Inputs that it would repair for `origin` must
1196    // NOT be repaired here.
1197    #[cfg(feature = "perform_additional_origin_fixups")]
1198    #[test]
1199    fn test_form_action_origin_skips_additional_fixups() {
1200        for input in &[
1201            "example.com",
1202            "example",
1203            "1.2.3.4",
1204            "https:",
1205            "ftp.example.com",
1206        ] {
1207            let result = LoginEntry::validate_and_normalize_form_action_origin(input);
1208            // The result depends on the other feature flag, but in no case
1209            // should it be the moz-pwmngr-fixed:// / repaired form returned
1210            // by `validate_and_fixup_origin`.
1211            #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1212            assert_eq!(result.unwrap(), None, "input: {input}");
1213            #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1214            assert!(result.is_err(), "input: {input}");
1215        }
1216    }
1217
1218    #[test]
1219    #[cfg(not(feature = "ignore_form_action_origin_validation_errors"))]
1220    fn test_form_action_origin_rejects_invalid() {
1221        assert!(LoginEntry::validate_and_normalize_form_action_origin("email").is_err());
1222    }
1223
1224    #[test]
1225    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1226    fn test_form_action_origin_accepts_invalid_with_feature() {
1227        // With the feature on, unparseable values return Ok(None) — meaning
1228        // "no fixup needed", so callers keep the original string as-is.
1229        assert_eq!(
1230            LoginEntry::validate_and_normalize_form_action_origin("email").unwrap(),
1231            None
1232        );
1233    }
1234
1235    #[test]
1236    fn test_check_valid() {
1237        #[derive(Debug, Clone)]
1238        struct TestCase {
1239            login: LoginEntry,
1240            should_err: bool,
1241            expected_err: &'static str,
1242        }
1243
1244        let valid_login = LoginEntry {
1245            origin: "https://www.example.com".into(),
1246            http_realm: Some("https://www.example.com".into()),
1247            username: "test".into(),
1248            password: "test".into(),
1249            ..Default::default()
1250        };
1251
1252        let login_with_empty_origin = LoginEntry {
1253            origin: "".into(),
1254            http_realm: Some("https://www.example.com".into()),
1255            username: "test".into(),
1256            password: "test".into(),
1257            ..Default::default()
1258        };
1259
1260        let login_with_empty_password = LoginEntry {
1261            origin: "https://www.example.com".into(),
1262            http_realm: Some("https://www.example.com".into()),
1263            username: "test".into(),
1264            password: "".into(),
1265            ..Default::default()
1266        };
1267
1268        let login_with_form_submit_and_http_realm = LoginEntry {
1269            origin: "https://www.example.com".into(),
1270            http_realm: Some("https://www.example.com".into()),
1271            form_action_origin: Some("https://www.example.com".into()),
1272            username: "".into(),
1273            password: "test".into(),
1274            ..Default::default()
1275        };
1276
1277        let login_without_form_submit_or_http_realm = LoginEntry {
1278            origin: "https://www.example.com".into(),
1279            username: "".into(),
1280            password: "test".into(),
1281            ..Default::default()
1282        };
1283
1284        let login_with_legacy_form_submit_and_http_realm = LoginEntry {
1285            origin: "https://www.example.com".into(),
1286            form_action_origin: Some("".into()),
1287            username: "".into(),
1288            password: "test".into(),
1289            ..Default::default()
1290        };
1291
1292        let login_with_null_http_realm = LoginEntry {
1293            origin: "https://www.example.com".into(),
1294            http_realm: Some("https://www.example.\0com".into()),
1295            username: "test".into(),
1296            password: "test".into(),
1297            ..Default::default()
1298        };
1299
1300        let login_with_null_username = LoginEntry {
1301            origin: "https://www.example.com".into(),
1302            http_realm: Some("https://www.example.com".into()),
1303            username: "\0".into(),
1304            password: "test".into(),
1305            ..Default::default()
1306        };
1307
1308        let login_with_null_password = LoginEntry {
1309            origin: "https://www.example.com".into(),
1310            http_realm: Some("https://www.example.com".into()),
1311            username: "username".into(),
1312            password: "test\0".into(),
1313            ..Default::default()
1314        };
1315
1316        let login_with_newline_origin = LoginEntry {
1317            origin: "\rhttps://www.example.com".into(),
1318            http_realm: Some("https://www.example.com".into()),
1319            username: "test".into(),
1320            password: "test".into(),
1321            ..Default::default()
1322        };
1323
1324        let login_with_newline_username_field = LoginEntry {
1325            origin: "https://www.example.com".into(),
1326            http_realm: Some("https://www.example.com".into()),
1327            username_field: "\n".into(),
1328            username: "test".into(),
1329            password: "test".into(),
1330            ..Default::default()
1331        };
1332
1333        let login_with_newline_realm = LoginEntry {
1334            origin: "https://www.example.com".into(),
1335            http_realm: Some("foo\nbar".into()),
1336            username: "test".into(),
1337            password: "test".into(),
1338            ..Default::default()
1339        };
1340
1341        let login_with_newline_password = LoginEntry {
1342            origin: "https://www.example.com".into(),
1343            http_realm: Some("https://www.example.com".into()),
1344            username: "test".into(),
1345            password: "test\n".into(),
1346            ..Default::default()
1347        };
1348
1349        let login_with_period_username_field = LoginEntry {
1350            origin: "https://www.example.com".into(),
1351            http_realm: Some("https://www.example.com".into()),
1352            username_field: ".".into(),
1353            username: "test".into(),
1354            password: "test".into(),
1355            ..Default::default()
1356        };
1357
1358        let login_with_period_form_action_origin = LoginEntry {
1359            form_action_origin: Some(".".into()),
1360            origin: "https://www.example.com".into(),
1361            username: "test".into(),
1362            password: "test".into(),
1363            ..Default::default()
1364        };
1365
1366        let login_with_javascript_form_action_origin = LoginEntry {
1367            form_action_origin: Some("javascript:".into()),
1368            origin: "https://www.example.com".into(),
1369            username: "test".into(),
1370            password: "test".into(),
1371            ..Default::default()
1372        };
1373
1374        let login_with_malformed_origin_parens = LoginEntry {
1375            origin: " (".into(),
1376            http_realm: Some("https://www.example.com".into()),
1377            username: "test".into(),
1378            password: "test".into(),
1379            ..Default::default()
1380        };
1381
1382        let login_with_host_unicode = LoginEntry {
1383            origin: "http://💖.com".into(),
1384            http_realm: Some("https://www.example.com".into()),
1385            username: "test".into(),
1386            password: "test".into(),
1387            ..Default::default()
1388        };
1389
1390        let login_with_origin_trailing_slash = LoginEntry {
1391            origin: "https://www.example.com/".into(),
1392            http_realm: Some("https://www.example.com".into()),
1393            username: "test".into(),
1394            password: "test".into(),
1395            ..Default::default()
1396        };
1397
1398        let login_with_origin_expanded_ipv6 = LoginEntry {
1399            origin: "https://[0:0:0:0:0:0:1:1]".into(),
1400            http_realm: Some("https://www.example.com".into()),
1401            username: "test".into(),
1402            password: "test".into(),
1403            ..Default::default()
1404        };
1405
1406        let login_with_unknown_protocol = LoginEntry {
1407            origin: "moz-proxy://127.0.0.1:8888".into(),
1408            http_realm: Some("https://www.example.com".into()),
1409            username: "test".into(),
1410            password: "test".into(),
1411            ..Default::default()
1412        };
1413
1414        let test_cases = [
1415            TestCase {
1416                login: valid_login,
1417                should_err: false,
1418                expected_err: "",
1419            },
1420            TestCase {
1421                login: login_with_empty_origin,
1422                should_err: true,
1423                expected_err: "Invalid login: Origin is empty",
1424            },
1425            TestCase {
1426                login: login_with_empty_password,
1427                should_err: cfg!(not(feature = "allow_empty_passwords")),
1428                expected_err: "Invalid login: Password is empty",
1429            },
1430            TestCase {
1431                login: login_with_form_submit_and_http_realm,
1432                should_err: true,
1433                expected_err: "Invalid login: Both `formActionOrigin` and `httpRealm` are present",
1434            },
1435            TestCase {
1436                login: login_without_form_submit_or_http_realm,
1437                should_err: true,
1438                expected_err:
1439                    "Invalid login: Neither `formActionOrigin` or `httpRealm` are present",
1440            },
1441            TestCase {
1442                login: login_with_null_http_realm,
1443                should_err: true,
1444                expected_err: "Invalid login: Login has illegal field: `http_realm` contains Nul",
1445            },
1446            TestCase {
1447                login: login_with_null_username,
1448                should_err: true,
1449                expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
1450            },
1451            TestCase {
1452                login: login_with_null_password,
1453                should_err: true,
1454                expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
1455            },
1456            TestCase {
1457                login: login_with_newline_origin,
1458                should_err: true,
1459                expected_err: "Invalid login: Login has illegal field: `origin` contains newline",
1460            },
1461            TestCase {
1462                login: login_with_newline_realm,
1463                should_err: true,
1464                expected_err:
1465                    "Invalid login: Login has illegal field: `http_realm` contains newline",
1466            },
1467            TestCase {
1468                login: login_with_newline_username_field,
1469                should_err: true,
1470                expected_err:
1471                    "Invalid login: Login has illegal field: `username_field` contains newline",
1472            },
1473            TestCase {
1474                login: login_with_newline_password,
1475                should_err: false,
1476                expected_err: "",
1477            },
1478            TestCase {
1479                login: login_with_period_username_field,
1480                should_err: true,
1481                expected_err:
1482                    "Invalid login: Login has illegal field: `username_field` is a period",
1483            },
1484            TestCase {
1485                login: login_with_period_form_action_origin,
1486                should_err: false,
1487                expected_err: "",
1488            },
1489            TestCase {
1490                login: login_with_javascript_form_action_origin,
1491                should_err: false,
1492                expected_err: "",
1493            },
1494            TestCase {
1495                login: login_with_malformed_origin_parens,
1496                should_err: true,
1497                expected_err:
1498                    "Invalid login: Login has illegal origin: relative URL without a base",
1499            },
1500            TestCase {
1501                login: login_with_host_unicode,
1502                should_err: true,
1503                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1504            },
1505            TestCase {
1506                login: login_with_origin_trailing_slash,
1507                should_err: true,
1508                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1509            },
1510            TestCase {
1511                login: login_with_origin_expanded_ipv6,
1512                should_err: true,
1513                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1514            },
1515            TestCase {
1516                login: login_with_unknown_protocol,
1517                should_err: false,
1518                expected_err: "",
1519            },
1520            TestCase {
1521                login: login_with_legacy_form_submit_and_http_realm,
1522                should_err: false,
1523                expected_err: "",
1524            },
1525        ];
1526
1527        for tc in &test_cases {
1528            let actual = tc.login.check_valid();
1529
1530            if tc.should_err {
1531                assert!(actual.is_err(), "{:#?}", tc);
1532                assert_eq!(
1533                    tc.expected_err,
1534                    actual.unwrap_err().to_string(),
1535                    "{:#?}",
1536                    tc,
1537                );
1538            } else {
1539                assert!(actual.is_ok(), "{:#?}", tc);
1540                assert!(
1541                    tc.login.clone().fixup().is_ok(),
1542                    "Fixup failed after check_valid passed: {:#?}",
1543                    &tc,
1544                );
1545            }
1546        }
1547    }
1548
1549    #[test]
1550    fn test_fixup() {
1551        #[derive(Debug, Default)]
1552        struct TestCase {
1553            login: LoginEntry,
1554            fixedup_host: Option<&'static str>,
1555            fixedup_form_action_origin: Option<String>,
1556        }
1557
1558        // Note that most URL fixups are tested above, but we have one or 2 here.
1559        let login_with_full_url = LoginEntry {
1560            origin: "http://example.com/foo?query=wtf#bar".into(),
1561            form_action_origin: Some("http://example.com/foo?query=wtf#bar".into()),
1562            username: "test".into(),
1563            password: "test".into(),
1564            ..Default::default()
1565        };
1566
1567        let login_with_host_unicode = LoginEntry {
1568            origin: "http://😍.com".into(),
1569            form_action_origin: Some("http://😍.com".into()),
1570            username: "test".into(),
1571            password: "test".into(),
1572            ..Default::default()
1573        };
1574
1575        let login_with_period_fsu = LoginEntry {
1576            origin: "https://example.com".into(),
1577            form_action_origin: Some(".".into()),
1578            username: "test".into(),
1579            password: "test".into(),
1580            ..Default::default()
1581        };
1582        let login_with_empty_fsu = LoginEntry {
1583            origin: "https://example.com".into(),
1584            form_action_origin: Some("".into()),
1585            username: "test".into(),
1586            password: "test".into(),
1587            ..Default::default()
1588        };
1589
1590        let login_with_form_submit_and_http_realm = LoginEntry {
1591            origin: "https://www.example.com".into(),
1592            form_action_origin: Some("https://www.example.com".into()),
1593            // If both http_realm and form_action_origin are specified, we drop
1594            // the former when fixing up. So for this test we must have an
1595            // invalid value in http_realm to ensure we don't validate a value
1596            // we end up dropping.
1597            http_realm: Some("\n".into()),
1598            username: "".into(),
1599            password: "test".into(),
1600            ..Default::default()
1601        };
1602
1603        let test_cases = [
1604            TestCase {
1605                login: login_with_full_url,
1606                fixedup_host: "http://example.com".into(),
1607                fixedup_form_action_origin: Some("http://example.com".into()),
1608            },
1609            TestCase {
1610                login: login_with_host_unicode,
1611                fixedup_host: "http://xn--r28h.com".into(),
1612                fixedup_form_action_origin: Some("http://xn--r28h.com".into()),
1613            },
1614            TestCase {
1615                login: login_with_period_fsu,
1616                fixedup_form_action_origin: Some("".into()),
1617                ..TestCase::default()
1618            },
1619            TestCase {
1620                login: login_with_form_submit_and_http_realm,
1621                fixedup_form_action_origin: Some("https://www.example.com".into()),
1622                ..TestCase::default()
1623            },
1624            TestCase {
1625                login: login_with_empty_fsu,
1626                // Should still be empty.
1627                fixedup_form_action_origin: Some("".into()),
1628                ..TestCase::default()
1629            },
1630        ];
1631
1632        for tc in &test_cases {
1633            let login = tc.login.clone().fixup().expect("should work");
1634            if let Some(expected) = tc.fixedup_host {
1635                assert_eq!(login.origin, expected, "origin not fixed in {:#?}", tc);
1636            }
1637            assert_eq!(
1638                login.form_action_origin, tc.fixedup_form_action_origin,
1639                "form_action_origin not fixed in {:#?}",
1640                tc,
1641            );
1642            login.check_valid().unwrap_or_else(|e| {
1643                panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
1644            });
1645            assert_eq!(
1646                login.clone().fixup().unwrap(),
1647                login,
1648                "fixup did not reach fixed point for testcase: {:#?}",
1649                tc,
1650            );
1651        }
1652    }
1653
1654    #[test]
1655    #[cfg(feature = "ignore_form_action_origin_validation_errors")]
1656    fn test_invalid_form_action_origin_allowed() {
1657        let login = LoginEntry {
1658            origin: "https://example.com".into(),
1659            form_action_origin: Some("email".into()),
1660            username: "test".into(),
1661            password: "test".into(),
1662            ..Default::default()
1663        };
1664        let fixed = login.fixup().expect("should not error");
1665        assert_eq!(fixed.form_action_origin, Some("email".into()));
1666    }
1667
1668    #[test]
1669    fn test_secure_fields_serde() {
1670        let sf = SecureLoginFields {
1671            username: "foo".into(),
1672            password: "pwd".into(),
1673        };
1674        assert_eq!(
1675            serde_json::to_string(&sf).unwrap(),
1676            r#"{"u":"foo","p":"pwd"}"#
1677        );
1678        let got: SecureLoginFields = serde_json::from_str(r#"{"u": "user", "p": "p"}"#).unwrap();
1679        let expected = SecureLoginFields {
1680            username: "user".into(),
1681            password: "p".into(),
1682        };
1683        assert_eq!(got, expected);
1684    }
1685}