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}