mirror of
https://github.com/lexmount/moli.git
synced 2026-09-28 00:01:34 +00:00
fix(forms): sanitize multiple and IDN email values
This commit is contained in:
@@ -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".
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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/");
|
||||
|
||||
Reference in New Issue
Block a user