diff --git a/moli-dom/src/forms/input_type.rs b/moli-dom/src/forms/input_type.rs index b42fddaf5..58b6f1b66 100644 --- a/moli-dom/src/forms/input_type.rs +++ b/moli-dom/src/forms/input_type.rs @@ -30,18 +30,25 @@ pub fn form_control_type_supports_intrinsic_validation( } pub fn sanitize_input_value_for_type(input_type: InputType, value: &str) -> String { + sanitize_input_value_for_type_with_multiple(input_type, value, false) +} + +pub fn sanitize_input_value_for_type_with_multiple( + input_type: InputType, + value: &str, + multiple: bool, +) -> String { match input_type { // Text-family — strip newlines / CR but leave whitespace runs alone. InputType::Text | InputType::Search | InputType::Tel | InputType::Password => { strip_input_value_line_breaks(value) } - // URL / Email — strip newlines AND trim leading/trailing ASCII whitespace. - // (Email's multi-value parsing isn't relevant here since the dirty - // value path only ever sees a single value.) - InputType::Url | InputType::Email => { + // URL — strip newlines AND trim leading/trailing ASCII whitespace. + InputType::Url => { let stripped = strip_input_value_line_breaks(value); stripped.trim_matches(is_ascii_whitespace_char).to_owned() } + InputType::Email => sanitize_email_input_value(value, multiple), InputType::Number if !is_valid_number_input_value(value) => String::new(), InputType::Date if !is_valid_date_input_value(value) => String::new(), InputType::Time if !is_valid_time_input_value(value) => String::new(), @@ -66,6 +73,34 @@ pub fn sanitize_input_value_for_type(input_type: InputType, value: &str) -> Stri } } +fn sanitize_email_input_value(value: &str, multiple: bool) -> String { + let stripped = strip_input_value_line_breaks(value); + if !multiple { + return sanitize_email_address(stripped.trim_matches(is_ascii_whitespace_char)); + } + stripped + .split(',') + .map(|address| address.trim_matches(is_ascii_whitespace_char)) + .map(sanitize_email_address) + .collect::>() + .join(",") +} + +fn sanitize_email_address(address: &str) -> String { + let Some((local, domain)) = address.rsplit_once('@') else { + return address.to_owned(); + }; + // Only IDN domains need conversion. URL host parsing would also lowercase + // ordinary addresses and percent-decode invalid email domain syntax. + if domain.is_ascii() || domain.contains('%') { + return address.to_owned(); + } + let Ok(url::Host::Domain(domain)) = url::Host::parse(domain) else { + return address.to_owned(); + }; + format!("{local}@{domain}") +} + fn strip_input_value_line_breaks(value: &str) -> String { value .chars() @@ -191,7 +226,7 @@ pub fn url_value_type_mismatch(value: &str) -> bool { #[cfg(test)] mod tests { - use super::sanitize_input_value_for_type; + use super::{sanitize_input_value_for_type, sanitize_input_value_for_type_with_multiple}; use moli_html_input_type::InputType; const INITIAL: &str = " foo\rbar "; @@ -229,6 +264,52 @@ mod tests { } } + #[test] + fn email_idn_sanitization_preserves_ascii_case_and_percent_sequences() { + for value in [ + "User@EXAMPLE.TEST", + "User@%65xample.test", + "User@exämple%2e.test", + ] { + assert_eq!( + sanitize_input_value_for_type(InputType::Email, value), + value + ); + } + } + + #[test] + fn multiple_email_trims_each_comma_separated_address() { + assert_eq!( + sanitize_input_value_for_type_with_multiple( + InputType::Email, + " first@example.com \r, second@example.test\n ", + true, + ), + "first@example.com,second@example.test" + ); + assert_eq!( + sanitize_input_value_for_type_with_multiple( + InputType::Email, + " first@example.com , , third@example.test ", + true, + ), + "first@example.com,,third@example.test" + ); + assert_eq!( + sanitize_input_value_for_type(InputType::Email, " test@exämle.com "), + "test@xn--exmle-hra.com" + ); + assert_eq!( + sanitize_input_value_for_type_with_multiple( + InputType::Email, + " test@exämle.com, user@お.com ", + true, + ), + "test@xn--exmle-hra.com,user@xn--t8j.com" + ); + } + #[test] fn range_defaults_to_midpoint_for_invalid_input() { // " foo\rbar " parses as NaN -> default midpoint of 0..=100 = "50". diff --git a/moli-dom/src/forms/mod.rs b/moli-dom/src/forms/mod.rs index 5b5e0a1b4..986ff8be0 100644 --- a/moli-dom/src/forms/mod.rs +++ b/moli-dom/src/forms/mod.rs @@ -6,7 +6,8 @@ mod validity; pub use input_type::{ email_value_type_mismatch, form_control_type_supports_intrinsic_validation, input_type_has_value_sanitization, input_type_value_mismatch, is_valid_email_address, - sanitize_input_value_for_type, url_value_type_mismatch, + sanitize_input_value_for_type, sanitize_input_value_for_type_with_multiple, + url_value_type_mismatch, }; pub use moli_html_input_temporal::{ MS_PER_DAY, MS_PER_HOUR, MS_PER_MINUTE, MS_PER_SECOND, MS_PER_WEEK, WEEK_INPUT_STEP_BASE, diff --git a/moli-dom/src/native/element/control_state.rs b/moli-dom/src/native/element/control_state.rs index c5d720f44..14986a0b7 100644 --- a/moli-dom/src/native/element/control_state.rs +++ b/moli-dom/src/native/element/control_state.rs @@ -1,5 +1,7 @@ use super::Attribute; -use crate::forms::{input_type_has_value_sanitization, sanitize_input_value_for_type}; +use crate::forms::{ + input_type_has_value_sanitization, sanitize_input_value_for_type_with_multiple, +}; use crate::native::NativeNodeId; use indexmap::IndexSet; use moli_html_input_type::InputType; @@ -246,9 +248,10 @@ impl ElementControlState { if local_name == "input" { let input_type = InputType::from_attribute_value(attribute("type")); - state.input_value = Some(sanitize_input_value_for_type( + state.input_value = Some(sanitize_input_value_for_type_with_multiple( input_type, attribute("value").unwrap_or_default(), + attribute("multiple").is_some(), )); state.checked = Some(attribute("checked").is_some()); state.selection_start = Some(0); @@ -874,6 +877,7 @@ impl ElementControlState { namespace: &str, local_name: &str, input_type: InputType, + input_multiple: bool, attribute_name: &str, attribute_value: Option<&str>, ) { @@ -886,18 +890,32 @@ impl ElementControlState { match (local_name, attribute_name) { ("input", "value") => { if !self.input_value_dirty { - self.input_value = Some(sanitize_input_value_for_type( + self.input_value = Some(sanitize_input_value_for_type_with_multiple( input_type, attribute_value.unwrap_or_default(), + input_multiple, )); } } ("input", "type") => { - if input_type_has_value_sanitization(input_type) { + if input_type_has_value_sanitization(input_type) || input_type == InputType::Email { let current = self.input_value.as_deref().unwrap_or_default(); - self.input_value = Some(sanitize_input_value_for_type(input_type, current)); + self.input_value = Some(sanitize_input_value_for_type_with_multiple( + input_type, + current, + input_multiple, + )); } } + ("input", "multiple") if input_type == InputType::Email => { + let current = self.input_value.as_deref().unwrap_or_default(); + self.input_value = Some(sanitize_input_value_for_type_with_multiple( + input_type, + current, + input_multiple, + )); + self.input_bad_input = false; + } ("input", "checked") => { if !self.checked_dirty { self.checked = Some(attribute_value.is_some()); diff --git a/moli-dom/src/native/element/mod.rs b/moli-dom/src/native/element/mod.rs index e82d2b666..ff832861b 100644 --- a/moli-dom/src/native/element/mod.rs +++ b/moli-dom/src/native/element/mod.rs @@ -21,7 +21,9 @@ use thin_vec::ThinVec; use super::NativeDom; use super::node::{NativeNodeId, Node}; use crate::custom_elements::is_valid_custom_element_name; -use crate::forms::{InputType, is_valid_number_input_value, sanitize_input_value_for_type}; +use crate::forms::{ + InputType, is_valid_number_input_value, sanitize_input_value_for_type_with_multiple, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CustomElementState { @@ -561,6 +563,14 @@ impl Element { self.is_html_element("link") && self.control_state().link_created_by_parser() } + fn sanitized_input_value(&self, value: &str) -> String { + sanitize_input_value_for_type_with_multiple( + self.input_type(), + value, + self.has_attribute("multiple"), + ) + } + pub fn set_input_value(&mut self, value: &str) -> bool { if !self.is_html_input() && !self.is_html_textarea() { return false; @@ -572,7 +582,7 @@ impl Element { return self.set_selected_files(Vec::new()); } let value = if self.is_html_input() { - sanitize_input_value_for_type(self.input_type(), value) + self.sanitized_input_value(value) } else { value.to_owned() }; @@ -590,7 +600,7 @@ impl Element { return self.set_selected_files(Vec::new()); } let value = if self.is_html_input() { - sanitize_input_value_for_type(self.input_type(), value) + self.sanitized_input_value(value) } else { value.to_owned() }; @@ -620,7 +630,7 @@ impl Element { let value = if bad_input { String::new() } else { - sanitize_input_value_for_type(input_type, value) + self.sanitized_input_value(value) }; (value, bad_input) } else { @@ -1152,10 +1162,12 @@ impl Element { } else { self.input_type() }; + let input_multiple = self.is_html_input() && self.has_attribute("multiple"); self.rare_data.sync_control_state_from_attribute( self.namespace.as_ref(), self.local_name.as_ref(), input_type, + input_multiple, attribute_name, attribute_value, ); diff --git a/moli-dom/src/native/element/rare_data.rs b/moli-dom/src/native/element/rare_data.rs index 708c4e599..1cc44ffe2 100644 --- a/moli-dom/src/native/element/rare_data.rs +++ b/moli-dom/src/native/element/rare_data.rs @@ -177,6 +177,7 @@ impl ElementRareData { namespace: &str, local_name: &str, input_type: InputType, + input_multiple: bool, attribute_name: &str, attribute_value: Option<&str>, ) { @@ -189,6 +190,7 @@ impl ElementRareData { namespace, local_name, input_type, + input_multiple, attribute_name, attribute_value, ); @@ -208,6 +210,7 @@ impl ElementRareData { namespace, local_name, input_type, + input_multiple, attribute_name, attribute_value, ); diff --git a/moli-dom/src/native/element/tests.rs b/moli-dom/src/native/element/tests.rs index 6cedfa0f8..6e8c6f421 100644 --- a/moli-dom/src/native/element/tests.rs +++ b/moli-dom/src/native/element/tests.rs @@ -417,3 +417,19 @@ fn parser_created_link_processing_state_is_consumed_at_children_finish() { assert!(!parser_created.link_created_by_parser()); assert!(!parser_created.finish_parsing_link_children()); } + +#[test] +fn email_multiple_attribute_resanitizes_dirty_value() { + let mut input = Element::new_html("input"); + assert!(input.set_attribute("type".to_owned(), String::new(), None, "email".to_owned())); + assert!(input.set_input_value(" first@example.com , second@example.test ")); + assert_eq!( + input.input_value(), + "first@example.com , second@example.test" + ); + assert!(input.input_value_dirty()); + + assert!(input.set_attribute("multiple".to_owned(), String::new(), None, String::new())); + assert_eq!(input.input_value(), "first@example.com,second@example.test"); + assert!(input.input_value_dirty()); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index 3d9c7a22d..91d02bb15 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -58,6 +58,67 @@ fn input_type_enumerated_attribute_preserves_raw_value_and_exposes_canonical_sta ); } +#[test] +fn email_value_keeps_ascii_domain_case_and_literal_percent_sequences() { + let mut vm = new_storage_test_vm("https://email-ascii.test/"); + let result = vm + .eval( + r#" + (() => { + const input = document.createElement('input'); + input.type = 'email'; + input.multiple = true; + input.value = ' User@EXAMPLE.TEST , User@%65xample.test , User@exämple%2e.test '; + return input.value; + })() + "#, + ) + .unwrap(); + assert_eq!( + result, + "User@EXAMPLE.TEST,User@%65xample.test,User@exämple%2e.test" + ); +} + +#[test] +fn email_multiple_value_sanitization_tracks_attribute_state() { + let mut vm = new_storage_test_vm("https://email-multiple-sanitization.test/"); + + let result = vm + .eval( + r#" +(() => { + const input = document.createElement('input'); + input.type = 'email'; + input.multiple = true; + input.value = ' first@example.com , second@example.test '; + const multipleValue = input.value; + + input.multiple = false; + input.value = 'first@example.com , second@example.test'; + const singleValue = input.value; + + input.multiple = true; + const multipleAfterToggle = input.value; + + const idn = document.createElement('input'); + idn.setAttribute('value', 'test@exämle.com'); + idn.type = 'email'; + const idnSingle = idn.value; + idn.multiple = true; + idn.value = 'test@exämle.com, user@お.com'; + return [multipleValue, singleValue, multipleAfterToggle, idnSingle, idn.value].join('|'); +})() +"#, + ) + .expect("email multiple sanitizer probe should evaluate"); + + assert_eq!( + result, + "first@example.com,second@example.test|first@example.com , second@example.test|first@example.com,second@example.test|test@xn--exmle-hra.com|test@xn--exmle-hra.com,user@xn--t8j.com" + ); +} + #[test] fn readonly_controls_match_validity_pseudo_without_will_validate() { let mut vm = new_storage_test_vm("https://readonly-validity-pseudo.test/");