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// LoginEntry fields that are stored in cleartext
288#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
289pub struct LoginFields {
290 pub origin: String,
291 pub form_action_origin: Option<String>,
292 pub http_realm: Option<String>,
293 pub username_field: String,
294 pub password_field: String,
295 pub time_of_last_breach: Option<i64>,
296 pub time_last_breach_alert_dismissed: Option<i64>,
297}
298
299/// LoginEntry fields that are stored encrypted
300#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
301pub struct SecureLoginFields {
302 // - Username cannot be null, use the empty string instead
303 // - Password can't be empty or null (enforced in the ValidateAndFixup code)
304 //
305 // This matches the desktop behavior:
306 // https://searchfox.org/mozilla-central/rev/d3683dbb252506400c71256ef3994cdbdfb71ada/toolkit/components/passwordmgr/LoginManager.jsm#260-267
307
308 // Because we store the json version of this in the DB, and that's the only place the json
309 // is used, we rename the fields to short names, just to reduce the overhead in the DB.
310 #[serde(rename = "u")]
311 pub username: String,
312 #[serde(rename = "p")]
313 pub password: String,
314}
315
316impl SecureLoginFields {
317 pub fn encrypt(&self, encdec: &dyn EncryptorDecryptor, login_id: &str) -> Result<String> {
318 let string = serde_json::to_string(&self)?;
319 let cipherbytes = encdec
320 .encrypt(string.as_bytes().into())
321 .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting {login_id})")))?;
322 let ciphertext = std::str::from_utf8(&cipherbytes).map_err(|e| {
323 Error::EncryptionFailed(format!("{e} (encrypting {login_id}: data not utf8)"))
324 })?;
325 Ok(ciphertext.to_owned())
326 }
327
328 pub fn decrypt(
329 ciphertext: &str,
330 encdec: &dyn EncryptorDecryptor,
331 login_id: &str,
332 ) -> Result<Self> {
333 let jsonbytes = encdec
334 .decrypt(ciphertext.as_bytes().into())
335 .map_err(|e| Error::DecryptionFailed(format!("{e} (decrypting {login_id})")))?;
336 let json =
337 std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
338 Ok(serde_json::from_str(json)?)
339 }
340}
341
342/// Login data specific to database records
343#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
344pub struct LoginMeta {
345 pub id: String,
346 pub time_created: i64,
347 pub time_password_changed: i64,
348 pub time_last_used: i64,
349 pub times_used: i64,
350}
351
352/// A login together with meta fields, handed over to the store API; ie a login persisted
353/// elsewhere, useful for migrations
354pub struct LoginEntryWithMeta {
355 pub entry: LoginEntry,
356 pub meta: LoginMeta,
357}
358
359/// A bulk insert result entry, returned by `add_many` and `add_many_with_records`
360/// Please note that although the success case is much larger than the error case, this is
361/// negligible in real life, as we expect a very small success/error ratio.
362#[allow(clippy::large_enum_variant)]
363pub enum BulkResultEntry {
364 Success { login: Login },
365 Error { message: String },
366}
367
368/// A login handed over to the store API; ie a login not yet persisted
369#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
370pub struct LoginEntry {
371 // login fields
372 pub origin: String,
373 pub form_action_origin: Option<String>,
374 pub http_realm: Option<String>,
375 pub username_field: String,
376 pub password_field: String,
377
378 // secure fields
379 pub username: String,
380 pub password: String,
381}
382
383impl LoginEntry {
384 pub fn new(fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
385 Self {
386 origin: fields.origin,
387 form_action_origin: fields.form_action_origin,
388 http_realm: fields.http_realm,
389 username_field: fields.username_field,
390 password_field: fields.password_field,
391
392 username: sec_fields.username,
393 password: sec_fields.password,
394 }
395 }
396
397 /// Helper for validation and fixups of an "origin" provided as a string.
398 pub fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
399 // Check we can parse the origin, then use the normalized version of it.
400 match Url::parse(origin) {
401 Ok(mut u) => {
402 // Presumably this is a faster path than always setting?
403 if u.path() != "/"
404 || u.fragment().is_some()
405 || u.query().is_some()
406 || u.username() != "/"
407 || u.password().is_some()
408 {
409 // Not identical - we only want the origin part, so kill
410 // any other parts which may exist.
411 // But first special case `file://` URLs which always
412 // resolve to `file://`
413 if u.scheme() == "file" {
414 return Ok(if origin == "file://" {
415 None
416 } else {
417 Some("file://".into())
418 });
419 }
420 u.set_path("");
421 u.set_fragment(None);
422 u.set_query(None);
423 let _ = u.set_username("");
424 let _ = u.set_password(None);
425 let mut href = String::from(u);
426 // We always store without the trailing "/" which Urls have.
427 if href.ends_with('/') {
428 href.pop().expect("url must have a length");
429 }
430 if origin != href {
431 // Needs to be fixed up.
432 return Ok(Some(href));
433 }
434 }
435 Ok(None)
436 }
437 Err(e) => {
438 breadcrumb!(
439 "Error parsing login origin: {e:?} ({})",
440 error_support::redact_url(origin)
441 );
442 // We can't fixup completely invalid records, so always throw.
443 Err(InvalidLogin::IllegalOrigin {
444 reason: e.to_string(),
445 }
446 .into())
447 }
448 }
449 }
450}
451
452/// A login handed over from the store API, which has been persisted and contains persistence
453/// information such as id and time stamps
454#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
455pub struct Login {
456 // meta fields
457 pub id: String,
458 pub time_created: i64,
459 pub time_password_changed: i64,
460 pub time_last_used: i64,
461 pub times_used: i64,
462
463 // login fields
464 pub origin: String,
465 pub form_action_origin: Option<String>,
466 pub http_realm: Option<String>,
467 pub username_field: String,
468 pub password_field: String,
469
470 // secure fields
471 pub username: String,
472 pub password: String,
473
474 // breach alerts
475 pub time_of_last_breach: Option<i64>,
476 pub time_last_breach_alert_dismissed: Option<i64>,
477}
478
479impl Login {
480 pub fn new(meta: LoginMeta, fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
481 Self {
482 id: meta.id,
483 time_created: meta.time_created,
484 time_password_changed: meta.time_password_changed,
485 time_last_used: meta.time_last_used,
486 times_used: meta.times_used,
487
488 origin: fields.origin,
489 form_action_origin: fields.form_action_origin,
490 http_realm: fields.http_realm,
491 username_field: fields.username_field,
492 password_field: fields.password_field,
493
494 username: sec_fields.username,
495 password: sec_fields.password,
496
497 time_of_last_breach: fields.time_last_breach_alert_dismissed,
498 time_last_breach_alert_dismissed: fields.time_last_breach_alert_dismissed,
499 }
500 }
501
502 #[inline]
503 pub fn guid(&self) -> Guid {
504 Guid::from_string(self.id.clone())
505 }
506
507 pub fn entry(&self) -> LoginEntry {
508 LoginEntry {
509 origin: self.origin.clone(),
510 form_action_origin: self.form_action_origin.clone(),
511 http_realm: self.http_realm.clone(),
512 username_field: self.username_field.clone(),
513 password_field: self.password_field.clone(),
514
515 username: self.username.clone(),
516 password: self.password.clone(),
517 }
518 }
519
520 pub fn encrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<EncryptedLogin> {
521 let sec_fields = SecureLoginFields {
522 username: self.username,
523 password: self.password,
524 }
525 .encrypt(encdec, &self.id)?;
526 Ok(EncryptedLogin {
527 meta: LoginMeta {
528 id: self.id,
529 time_created: self.time_created,
530 time_password_changed: self.time_password_changed,
531 time_last_used: self.time_last_used,
532 times_used: self.times_used,
533 },
534 fields: LoginFields {
535 origin: self.origin,
536 form_action_origin: self.form_action_origin,
537 http_realm: self.http_realm,
538 username_field: self.username_field,
539 password_field: self.password_field,
540 time_of_last_breach: self.time_last_breach_alert_dismissed,
541 time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed,
542 },
543 sec_fields,
544 })
545 }
546}
547
548/// A login stored in the database
549#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
550pub struct EncryptedLogin {
551 pub meta: LoginMeta,
552 pub fields: LoginFields,
553 pub sec_fields: String,
554}
555
556impl EncryptedLogin {
557 #[inline]
558 pub fn guid(&self) -> Guid {
559 Guid::from_string(self.meta.id.clone())
560 }
561
562 // TODO: Remove this: https://github.com/mozilla/application-services/issues/4185
563 #[inline]
564 pub fn guid_str(&self) -> &str {
565 &self.meta.id
566 }
567
568 pub fn decrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<Login> {
569 let sec_fields = self.decrypt_fields(encdec)?;
570 Ok(Login::new(self.meta, self.fields, sec_fields))
571 }
572
573 pub fn decrypt_fields(&self, encdec: &dyn EncryptorDecryptor) -> Result<SecureLoginFields> {
574 SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)
575 }
576
577 pub(crate) fn from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
578 let login = EncryptedLogin {
579 meta: LoginMeta {
580 id: row.get("guid")?,
581 time_created: row.get("timeCreated")?,
582 // Might be null
583 time_last_used: row
584 .get::<_, Option<i64>>("timeLastUsed")?
585 .unwrap_or_default(),
586
587 time_password_changed: row.get("timePasswordChanged")?,
588 times_used: row.get("timesUsed")?,
589 },
590 fields: LoginFields {
591 origin: row.get("origin")?,
592 http_realm: row.get("httpRealm")?,
593
594 form_action_origin: row.get("formActionOrigin")?,
595
596 username_field: string_or_default(row, "usernameField")?,
597 password_field: string_or_default(row, "passwordField")?,
598
599 time_of_last_breach: row.get::<_, Option<i64>>("timeOfLastBreach")?,
600 time_last_breach_alert_dismissed: row
601 .get::<_, Option<i64>>("timeLastBreachAlertDismissed")?,
602 },
603 sec_fields: row.get("secFields")?,
604 };
605 // XXX - we used to perform a fixup here, but that seems heavy-handed
606 // and difficult - we now only do that on add/insert when we have the
607 // encryption key.
608 Ok(login)
609 }
610}
611
612fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
613 Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
614}
615
616pub trait ValidateAndFixup {
617 // Our validate and fixup functions.
618 fn check_valid(&self) -> Result<()>
619 where
620 Self: Sized,
621 {
622 self.validate_and_fixup(false)?;
623 Ok(())
624 }
625
626 fn fixup(self) -> Result<Self>
627 where
628 Self: Sized,
629 {
630 match self.maybe_fixup()? {
631 None => Ok(self),
632 Some(login) => Ok(login),
633 }
634 }
635
636 fn maybe_fixup(&self) -> Result<Option<Self>>
637 where
638 Self: Sized,
639 {
640 self.validate_and_fixup(true)
641 }
642
643 // validates, and optionally fixes, a struct. If fixup is false and there is a validation
644 // issue, an `Err` is returned. If fixup is true and a problem was fixed, and `Ok(Some<Self>)`
645 // is returned with the fixed version. If there was no validation problem, `Ok(None)` is
646 // returned.
647 fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>>
648 where
649 Self: Sized;
650}
651
652impl ValidateAndFixup for LoginEntry {
653 fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
654 // XXX TODO: we've definitely got more validation and fixups to add here!
655
656 let mut maybe_fixed = None;
657
658 /// A little helper to magic a Some(self.clone()) into existence when needed.
659 macro_rules! get_fixed_or_throw {
660 ($err:expr) => {
661 // This is a block expression returning a local variable,
662 // entirely so we can give it an explicit type declaration.
663 {
664 if !fixup {
665 return Err($err.into());
666 }
667 warn!("Fixing login record {:?}", $err);
668 let fixed: Result<&mut Self> =
669 Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
670 fixed
671 }
672 };
673 }
674
675 if self.origin.is_empty() {
676 return Err(InvalidLogin::EmptyOrigin.into());
677 }
678
679 if self.form_action_origin.is_some() && self.http_realm.is_some() {
680 get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
681 }
682
683 if self.form_action_origin.is_none() && self.http_realm.is_none() {
684 return Err(InvalidLogin::NoTarget.into());
685 }
686
687 let form_action_origin = self.form_action_origin.clone().unwrap_or_default();
688 let http_realm = maybe_fixed
689 .as_ref()
690 .unwrap_or(self)
691 .http_realm
692 .clone()
693 .unwrap_or_default();
694
695 let field_data = [
696 ("form_action_origin", &form_action_origin),
697 ("http_realm", &http_realm),
698 ("origin", &self.origin),
699 ("username_field", &self.username_field),
700 ("password_field", &self.password_field),
701 ];
702
703 for (field_name, field_value) in &field_data {
704 // Nuls are invalid.
705 if field_value.contains('\0') {
706 return Err(InvalidLogin::IllegalFieldValue {
707 field_info: format!("`{}` contains Nul", field_name),
708 }
709 .into());
710 }
711
712 // Newlines are invalid in Desktop for all the fields here.
713 if field_value.contains('\n') || field_value.contains('\r') {
714 return Err(InvalidLogin::IllegalFieldValue {
715 field_info: format!("`{}` contains newline", field_name),
716 }
717 .into());
718 }
719 }
720
721 // Desktop doesn't like fields with the below patterns
722 if self.username_field == "." {
723 return Err(InvalidLogin::IllegalFieldValue {
724 field_info: "`username_field` is a period".into(),
725 }
726 .into());
727 }
728
729 // Check we can parse the origin, then use the normalized version of it.
730 if let Some(fixed) = Self::validate_and_fixup_origin(&self.origin)? {
731 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
732 field_info: "Origin is not normalized".into()
733 })?
734 .origin = fixed;
735 }
736
737 match &maybe_fixed.as_ref().unwrap_or(self).form_action_origin {
738 None => {
739 if !self.username_field.is_empty() {
740 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
741 field_info: "username_field must be empty when form_action_origin is null"
742 .into()
743 })?
744 .username_field
745 .clear();
746 }
747 if !self.password_field.is_empty() {
748 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
749 field_info: "password_field must be empty when form_action_origin is null"
750 .into()
751 })?
752 .password_field
753 .clear();
754 }
755 }
756 Some(href) => {
757 // "", ".", and "javascript:" are special cases documented at the top of this file.
758 if href == "." {
759 // A bit of a special case - if we are being asked to fixup, we replace
760 // "." with an empty string - but if not fixing up we don't complain.
761 if fixup {
762 maybe_fixed
763 .get_or_insert_with(|| self.clone())
764 .form_action_origin = Some("".into());
765 }
766 } else if !href.is_empty() && href != "javascript:" {
767 if let Some(fixed) = Self::validate_and_fixup_origin(href)? {
768 get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
769 field_info: "form_action_origin is not normalized".into()
770 })?
771 .form_action_origin = Some(fixed);
772 }
773 }
774 }
775 }
776
777 // secure fields
778 //
779 // \r\n chars are valid in desktop for some reason, so we allow them here too.
780 if self.username.contains('\0') {
781 return Err(InvalidLogin::IllegalFieldValue {
782 field_info: "`username` contains Nul".into(),
783 }
784 .into());
785 }
786 if self.password.is_empty() {
787 return Err(InvalidLogin::EmptyPassword.into());
788 }
789 if self.password.contains('\0') {
790 return Err(InvalidLogin::IllegalFieldValue {
791 field_info: "`password` contains Nul".into(),
792 }
793 .into());
794 }
795
796 Ok(maybe_fixed)
797 }
798}
799
800#[cfg(test)]
801pub mod test_utils {
802 use super::*;
803 use crate::encryption::test_utils::encrypt_struct;
804
805 // Factory function to make a new login
806 //
807 // It uses the guid to create a unique origin/form_action_origin
808 pub fn enc_login(id: &str, password: &str) -> EncryptedLogin {
809 let sec_fields = SecureLoginFields {
810 username: "user".to_string(),
811 password: password.to_string(),
812 };
813 EncryptedLogin {
814 meta: LoginMeta {
815 id: id.to_string(),
816 ..Default::default()
817 },
818 fields: LoginFields {
819 form_action_origin: Some(format!("https://{}.example.com", id)),
820 origin: format!("https://{}.example.com", id),
821 ..Default::default()
822 },
823 // TODO: fixme
824 sec_fields: encrypt_struct(&sec_fields),
825 }
826 }
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn test_url_fixups() -> Result<()> {
835 // Start with URLs which are all valid and already normalized.
836 for input in &[
837 // The list of valid origins documented at the top of this file.
838 "https://site.com",
839 "http://site.com:1234",
840 "ftp://ftp.site.com",
841 "moz-proxy://127.0.0.1:8888",
842 "chrome://MyLegacyExtension",
843 "file://",
844 "https://[::1]",
845 ] {
846 assert_eq!(LoginEntry::validate_and_fixup_origin(input)?, None);
847 }
848
849 // And URLs which get normalized.
850 for (input, output) in &[
851 ("https://site.com/", "https://site.com"),
852 ("http://site.com:1234/", "http://site.com:1234"),
853 ("http://example.com/foo?query=wtf#bar", "http://example.com"),
854 ("http://example.com/foo#bar", "http://example.com"),
855 (
856 "http://username:password@example.com/",
857 "http://example.com",
858 ),
859 ("http://😍.com/", "http://xn--r28h.com"),
860 ("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
861 // All `file://` URLs normalize to exactly `file://`. See #2384 for
862 // why we might consider changing that later.
863 ("file:///", "file://"),
864 ("file://foo/bar", "file://"),
865 ("file://foo/bar/", "file://"),
866 ("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
867 (
868 "moz-proxy://127.0.0.1:8888/foo",
869 "moz-proxy://127.0.0.1:8888",
870 ),
871 ("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
872 (
873 "chrome://MyLegacyExtension/foo",
874 "chrome://MyLegacyExtension",
875 ),
876 ] {
877 assert_eq!(
878 LoginEntry::validate_and_fixup_origin(input)?,
879 Some((*output).into())
880 );
881 }
882
883 // Finally, look at some invalid logins
884 for input in &[".", "example", "example.com"] {
885 assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
886 }
887
888 Ok(())
889 }
890
891 #[test]
892 fn test_check_valid() {
893 #[derive(Debug, Clone)]
894 struct TestCase {
895 login: LoginEntry,
896 should_err: bool,
897 expected_err: &'static str,
898 }
899
900 let valid_login = LoginEntry {
901 origin: "https://www.example.com".into(),
902 http_realm: Some("https://www.example.com".into()),
903 username: "test".into(),
904 password: "test".into(),
905 ..Default::default()
906 };
907
908 let login_with_empty_origin = LoginEntry {
909 origin: "".into(),
910 http_realm: Some("https://www.example.com".into()),
911 username: "test".into(),
912 password: "test".into(),
913 ..Default::default()
914 };
915
916 let login_with_empty_password = LoginEntry {
917 origin: "https://www.example.com".into(),
918 http_realm: Some("https://www.example.com".into()),
919 username: "test".into(),
920 password: "".into(),
921 ..Default::default()
922 };
923
924 let login_with_form_submit_and_http_realm = LoginEntry {
925 origin: "https://www.example.com".into(),
926 http_realm: Some("https://www.example.com".into()),
927 form_action_origin: Some("https://www.example.com".into()),
928 username: "".into(),
929 password: "test".into(),
930 ..Default::default()
931 };
932
933 let login_without_form_submit_or_http_realm = LoginEntry {
934 origin: "https://www.example.com".into(),
935 username: "".into(),
936 password: "test".into(),
937 ..Default::default()
938 };
939
940 let login_with_legacy_form_submit_and_http_realm = LoginEntry {
941 origin: "https://www.example.com".into(),
942 form_action_origin: Some("".into()),
943 username: "".into(),
944 password: "test".into(),
945 ..Default::default()
946 };
947
948 let login_with_null_http_realm = LoginEntry {
949 origin: "https://www.example.com".into(),
950 http_realm: Some("https://www.example.\0com".into()),
951 username: "test".into(),
952 password: "test".into(),
953 ..Default::default()
954 };
955
956 let login_with_null_username = LoginEntry {
957 origin: "https://www.example.com".into(),
958 http_realm: Some("https://www.example.com".into()),
959 username: "\0".into(),
960 password: "test".into(),
961 ..Default::default()
962 };
963
964 let login_with_null_password = LoginEntry {
965 origin: "https://www.example.com".into(),
966 http_realm: Some("https://www.example.com".into()),
967 username: "username".into(),
968 password: "test\0".into(),
969 ..Default::default()
970 };
971
972 let login_with_newline_origin = LoginEntry {
973 origin: "\rhttps://www.example.com".into(),
974 http_realm: Some("https://www.example.com".into()),
975 username: "test".into(),
976 password: "test".into(),
977 ..Default::default()
978 };
979
980 let login_with_newline_username_field = LoginEntry {
981 origin: "https://www.example.com".into(),
982 http_realm: Some("https://www.example.com".into()),
983 username_field: "\n".into(),
984 username: "test".into(),
985 password: "test".into(),
986 ..Default::default()
987 };
988
989 let login_with_newline_realm = LoginEntry {
990 origin: "https://www.example.com".into(),
991 http_realm: Some("foo\nbar".into()),
992 username: "test".into(),
993 password: "test".into(),
994 ..Default::default()
995 };
996
997 let login_with_newline_password = LoginEntry {
998 origin: "https://www.example.com".into(),
999 http_realm: Some("https://www.example.com".into()),
1000 username: "test".into(),
1001 password: "test\n".into(),
1002 ..Default::default()
1003 };
1004
1005 let login_with_period_username_field = LoginEntry {
1006 origin: "https://www.example.com".into(),
1007 http_realm: Some("https://www.example.com".into()),
1008 username_field: ".".into(),
1009 username: "test".into(),
1010 password: "test".into(),
1011 ..Default::default()
1012 };
1013
1014 let login_with_period_form_action_origin = LoginEntry {
1015 form_action_origin: Some(".".into()),
1016 origin: "https://www.example.com".into(),
1017 username: "test".into(),
1018 password: "test".into(),
1019 ..Default::default()
1020 };
1021
1022 let login_with_javascript_form_action_origin = LoginEntry {
1023 form_action_origin: Some("javascript:".into()),
1024 origin: "https://www.example.com".into(),
1025 username: "test".into(),
1026 password: "test".into(),
1027 ..Default::default()
1028 };
1029
1030 let login_with_malformed_origin_parens = LoginEntry {
1031 origin: " (".into(),
1032 http_realm: Some("https://www.example.com".into()),
1033 username: "test".into(),
1034 password: "test".into(),
1035 ..Default::default()
1036 };
1037
1038 let login_with_host_unicode = LoginEntry {
1039 origin: "http://💖.com".into(),
1040 http_realm: Some("https://www.example.com".into()),
1041 username: "test".into(),
1042 password: "test".into(),
1043 ..Default::default()
1044 };
1045
1046 let login_with_origin_trailing_slash = LoginEntry {
1047 origin: "https://www.example.com/".into(),
1048 http_realm: Some("https://www.example.com".into()),
1049 username: "test".into(),
1050 password: "test".into(),
1051 ..Default::default()
1052 };
1053
1054 let login_with_origin_expanded_ipv6 = LoginEntry {
1055 origin: "https://[0:0:0:0:0:0:1:1]".into(),
1056 http_realm: Some("https://www.example.com".into()),
1057 username: "test".into(),
1058 password: "test".into(),
1059 ..Default::default()
1060 };
1061
1062 let login_with_unknown_protocol = LoginEntry {
1063 origin: "moz-proxy://127.0.0.1:8888".into(),
1064 http_realm: Some("https://www.example.com".into()),
1065 username: "test".into(),
1066 password: "test".into(),
1067 ..Default::default()
1068 };
1069
1070 let test_cases = [
1071 TestCase {
1072 login: valid_login,
1073 should_err: false,
1074 expected_err: "",
1075 },
1076 TestCase {
1077 login: login_with_empty_origin,
1078 should_err: true,
1079 expected_err: "Invalid login: Origin is empty",
1080 },
1081 TestCase {
1082 login: login_with_empty_password,
1083 should_err: true,
1084 expected_err: "Invalid login: Password is empty",
1085 },
1086 TestCase {
1087 login: login_with_form_submit_and_http_realm,
1088 should_err: true,
1089 expected_err: "Invalid login: Both `formActionOrigin` and `httpRealm` are present",
1090 },
1091 TestCase {
1092 login: login_without_form_submit_or_http_realm,
1093 should_err: true,
1094 expected_err:
1095 "Invalid login: Neither `formActionOrigin` or `httpRealm` are present",
1096 },
1097 TestCase {
1098 login: login_with_null_http_realm,
1099 should_err: true,
1100 expected_err: "Invalid login: Login has illegal field: `http_realm` contains Nul",
1101 },
1102 TestCase {
1103 login: login_with_null_username,
1104 should_err: true,
1105 expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
1106 },
1107 TestCase {
1108 login: login_with_null_password,
1109 should_err: true,
1110 expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
1111 },
1112 TestCase {
1113 login: login_with_newline_origin,
1114 should_err: true,
1115 expected_err: "Invalid login: Login has illegal field: `origin` contains newline",
1116 },
1117 TestCase {
1118 login: login_with_newline_realm,
1119 should_err: true,
1120 expected_err:
1121 "Invalid login: Login has illegal field: `http_realm` contains newline",
1122 },
1123 TestCase {
1124 login: login_with_newline_username_field,
1125 should_err: true,
1126 expected_err:
1127 "Invalid login: Login has illegal field: `username_field` contains newline",
1128 },
1129 TestCase {
1130 login: login_with_newline_password,
1131 should_err: false,
1132 expected_err: "",
1133 },
1134 TestCase {
1135 login: login_with_period_username_field,
1136 should_err: true,
1137 expected_err:
1138 "Invalid login: Login has illegal field: `username_field` is a period",
1139 },
1140 TestCase {
1141 login: login_with_period_form_action_origin,
1142 should_err: false,
1143 expected_err: "",
1144 },
1145 TestCase {
1146 login: login_with_javascript_form_action_origin,
1147 should_err: false,
1148 expected_err: "",
1149 },
1150 TestCase {
1151 login: login_with_malformed_origin_parens,
1152 should_err: true,
1153 expected_err:
1154 "Invalid login: Login has illegal origin: relative URL without a base",
1155 },
1156 TestCase {
1157 login: login_with_host_unicode,
1158 should_err: true,
1159 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1160 },
1161 TestCase {
1162 login: login_with_origin_trailing_slash,
1163 should_err: true,
1164 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1165 },
1166 TestCase {
1167 login: login_with_origin_expanded_ipv6,
1168 should_err: true,
1169 expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1170 },
1171 TestCase {
1172 login: login_with_unknown_protocol,
1173 should_err: false,
1174 expected_err: "",
1175 },
1176 TestCase {
1177 login: login_with_legacy_form_submit_and_http_realm,
1178 should_err: false,
1179 expected_err: "",
1180 },
1181 ];
1182
1183 for tc in &test_cases {
1184 let actual = tc.login.check_valid();
1185
1186 if tc.should_err {
1187 assert!(actual.is_err(), "{:#?}", tc);
1188 assert_eq!(
1189 tc.expected_err,
1190 actual.unwrap_err().to_string(),
1191 "{:#?}",
1192 tc,
1193 );
1194 } else {
1195 assert!(actual.is_ok(), "{:#?}", tc);
1196 assert!(
1197 tc.login.clone().fixup().is_ok(),
1198 "Fixup failed after check_valid passed: {:#?}",
1199 &tc,
1200 );
1201 }
1202 }
1203 }
1204
1205 #[test]
1206 fn test_fixup() {
1207 #[derive(Debug, Default)]
1208 struct TestCase {
1209 login: LoginEntry,
1210 fixedup_host: Option<&'static str>,
1211 fixedup_form_action_origin: Option<String>,
1212 }
1213
1214 // Note that most URL fixups are tested above, but we have one or 2 here.
1215 let login_with_full_url = LoginEntry {
1216 origin: "http://example.com/foo?query=wtf#bar".into(),
1217 form_action_origin: Some("http://example.com/foo?query=wtf#bar".into()),
1218 username: "test".into(),
1219 password: "test".into(),
1220 ..Default::default()
1221 };
1222
1223 let login_with_host_unicode = LoginEntry {
1224 origin: "http://😍.com".into(),
1225 form_action_origin: Some("http://😍.com".into()),
1226 username: "test".into(),
1227 password: "test".into(),
1228 ..Default::default()
1229 };
1230
1231 let login_with_period_fsu = LoginEntry {
1232 origin: "https://example.com".into(),
1233 form_action_origin: Some(".".into()),
1234 username: "test".into(),
1235 password: "test".into(),
1236 ..Default::default()
1237 };
1238 let login_with_empty_fsu = LoginEntry {
1239 origin: "https://example.com".into(),
1240 form_action_origin: Some("".into()),
1241 username: "test".into(),
1242 password: "test".into(),
1243 ..Default::default()
1244 };
1245
1246 let login_with_form_submit_and_http_realm = LoginEntry {
1247 origin: "https://www.example.com".into(),
1248 form_action_origin: Some("https://www.example.com".into()),
1249 // If both http_realm and form_action_origin are specified, we drop
1250 // the former when fixing up. So for this test we must have an
1251 // invalid value in http_realm to ensure we don't validate a value
1252 // we end up dropping.
1253 http_realm: Some("\n".into()),
1254 username: "".into(),
1255 password: "test".into(),
1256 ..Default::default()
1257 };
1258
1259 let test_cases = [
1260 TestCase {
1261 login: login_with_full_url,
1262 fixedup_host: "http://example.com".into(),
1263 fixedup_form_action_origin: Some("http://example.com".into()),
1264 },
1265 TestCase {
1266 login: login_with_host_unicode,
1267 fixedup_host: "http://xn--r28h.com".into(),
1268 fixedup_form_action_origin: Some("http://xn--r28h.com".into()),
1269 },
1270 TestCase {
1271 login: login_with_period_fsu,
1272 fixedup_form_action_origin: Some("".into()),
1273 ..TestCase::default()
1274 },
1275 TestCase {
1276 login: login_with_form_submit_and_http_realm,
1277 fixedup_form_action_origin: Some("https://www.example.com".into()),
1278 ..TestCase::default()
1279 },
1280 TestCase {
1281 login: login_with_empty_fsu,
1282 // Should still be empty.
1283 fixedup_form_action_origin: Some("".into()),
1284 ..TestCase::default()
1285 },
1286 ];
1287
1288 for tc in &test_cases {
1289 let login = tc.login.clone().fixup().expect("should work");
1290 if let Some(expected) = tc.fixedup_host {
1291 assert_eq!(login.origin, expected, "origin not fixed in {:#?}", tc);
1292 }
1293 assert_eq!(
1294 login.form_action_origin, tc.fixedup_form_action_origin,
1295 "form_action_origin not fixed in {:#?}",
1296 tc,
1297 );
1298 login.check_valid().unwrap_or_else(|e| {
1299 panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
1300 });
1301 assert_eq!(
1302 login.clone().fixup().unwrap(),
1303 login,
1304 "fixup did not reach fixed point for testcase: {:#?}",
1305 tc,
1306 );
1307 }
1308 }
1309
1310 #[test]
1311 fn test_secure_fields_serde() {
1312 let sf = SecureLoginFields {
1313 username: "foo".into(),
1314 password: "pwd".into(),
1315 };
1316 assert_eq!(
1317 serde_json::to_string(&sf).unwrap(),
1318 r#"{"u":"foo","p":"pwd"}"#
1319 );
1320 let got: SecureLoginFields = serde_json::from_str(r#"{"u": "user", "p": "p"}"#).unwrap();
1321 let expected = SecureLoginFields {
1322 username: "user".into(),
1323 password: "p".into(),
1324 };
1325 assert_eq!(got, expected);
1326 }
1327}