fix(forms): sanitize multiple and IDN email values

This commit is contained in:
ldm0
2026-09-27 05:50:50 +08:00
committed by Donough Liu
parent 4277649618
commit c2c5ce0da0
7 changed files with 207 additions and 15 deletions
+86 -5
View File
@@ -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::<Vec<String>>()
.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".
+2 -1
View File
@@ -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,
+23 -5
View File
@@ -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());
+16 -4
View File
@@ -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,
);
+3
View File
@@ -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,
);
+16
View File
@@ -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());
}
@@ -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/");