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}