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