max / makenotwork
1 file changed,
+42 insertions,
-0 deletions
| @@ -3,6 +3,24 @@ | |||
| 3 | 3 | use crate::error::AppError; | |
| 4 | 4 | use super::limits; | |
| 5 | 5 | ||
| 6 | + | /// True if the string contains zero-width or bidirectional-override characters. | |
| 7 | + | /// | |
| 8 | + | /// These render invisibly (zero-width: ZWSP/ZWNJ/ZWJ/BOM/word-joiner) or reorder | |
| 9 | + | /// surrounding text (bidi: LRE/RLE/PDF/LRO/RLO, LRI/RLI/FSI/PDI, LRM/RLM), so a | |
| 10 | + | /// name or title carrying them can spoof another identity or hide content in | |
| 11 | + | /// member lists and emails ("Trojan Source"-class spoofing) — ultra-fuzz Run 10 | |
| 12 | + | /// UX N1. Rejected on the short, display-prominent fields (names/titles). | |
| 13 | + | pub fn contains_deceptive_unicode(s: &str) -> bool { | |
| 14 | + | s.chars().any(|c| { | |
| 15 | + | matches!(c, | |
| 16 | + | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}' // zero-width | |
| 17 | + | | '\u{200E}' | '\u{200F}' // LRM/RLM | |
| 18 | + | | '\u{202A}'..='\u{202E}' // bidi embed/override | |
| 19 | + | | '\u{2066}'..='\u{2069}' // bidi isolates | |
| 20 | + | ) | |
| 21 | + | }) | |
| 22 | + | } | |
| 23 | + | ||
| 6 | 24 | /// Validate a display name | |
| 7 | 25 | pub fn validate_display_name(name: &str) -> Result<(), AppError> { | |
| 8 | 26 | if name.chars().count() > limits::DISPLAY_NAME_MAX { | |
| @@ -18,6 +36,11 @@ pub fn validate_display_name(name: &str) -> Result<(), AppError> { | |||
| 18 | 36 | "Display name cannot contain control characters".to_string(), | |
| 19 | 37 | )); | |
| 20 | 38 | } | |
| 39 | + | if contains_deceptive_unicode(name) { | |
| 40 | + | return Err(AppError::validation( | |
| 41 | + | "Display name cannot contain zero-width or bidirectional characters".to_string(), | |
| 42 | + | )); | |
| 43 | + | } | |
| 21 | 44 | Ok(()) | |
| 22 | 45 | } | |
| 23 | 46 | ||
| @@ -82,6 +105,11 @@ pub fn validate_link_title(title: &str) -> Result<(), AppError> { | |||
| 82 | 105 | limits::LINK_TITLE_MAX | |
| 83 | 106 | ))); | |
| 84 | 107 | } | |
| 108 | + | if contains_deceptive_unicode(title) { | |
| 109 | + | return Err(AppError::validation( | |
| 110 | + | "Link title cannot contain zero-width or bidirectional characters".to_string(), | |
| 111 | + | )); | |
| 112 | + | } | |
| 85 | 113 | Ok(()) | |
| 86 | 114 | } | |
| 87 | 115 | ||
| @@ -293,6 +321,20 @@ mod tests { | |||
| 293 | 321 | } | |
| 294 | 322 | ||
| 295 | 323 | #[test] | |
| 324 | + | fn test_validate_names_reject_deceptive_unicode() { | |
| 325 | + | // Zero-width and bidi-override chars are rejected on names and titles | |
| 326 | + | // (Run 10 UX N1), but ordinary non-ASCII (accents, CJK) is still allowed. | |
| 327 | + | assert!(validate_display_name("Alice\u{200B}Bob").is_err()); // ZWSP | |
| 328 | + | assert!(validate_display_name("admin\u{202E}txet").is_err()); // RLO | |
| 329 | + | assert!(validate_display_name("\u{FEFF}Mallory").is_err()); // BOM | |
| 330 | + | assert!(validate_display_name("José Ñoño").is_ok()); // accents fine | |
| 331 | + | assert!(validate_display_name("名前").is_ok()); // CJK fine | |
| 332 | + | assert!(validate_link_title("My Site\u{200D}").is_err()); // ZWJ | |
| 333 | + | assert!(validate_link_title("My Portfolio").is_ok()); | |
| 334 | + | assert!(!contains_deceptive_unicode("normal text")); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | #[test] | |
| 296 | 338 | fn test_activation_and_ssh_labels_reject_control_chars() { | |
| 297 | 339 | assert!(validate_activation_label("Living Room").is_ok()); | |
| 298 | 340 | assert!(validate_activation_label("Living\nRoom").is_err()); |