Skip to main content

max / makenotwork

Reject zero-width and bidi characters in display names and link titles (ultra-fuzz Run 10 UX N1) Re-presented deferred item, now addressed: names and titles render in member lists and emails, so zero-width (ZWSP/ZWNJ/ZWJ/BOM/word-joiner) and bidirectional override characters (LRO/RLO, isolates, LRM/RLM) let one identity spoof another or hide content. New validation::users::contains_deceptive_unicode rejects them on display_name and link_title; ordinary non-ASCII (accents, CJK) stays allowed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-30 19:18 UTC
Commit: 39ed9e16b8047cd1d829c6bf00089d0523e53b90
Parent: f361c2f
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());