From 582d56c3012bee4f60003454cfb9c4e73ac2727e Mon Sep 17 00:00:00 2001 From: Ethan O'Brien Date: Sat, 1 Aug 2026 21:49:57 -0500 Subject: [PATCH] Forgot to push module --- .gitignore | 1 + src/router/rich_text.rs | 144 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/router/rich_text.rs diff --git a/.gitignore b/.gitignore index 01dfd46..9213888 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ docker/data/ ndk/ .DS_Store custom_songs/ +custom_cards/ diff --git a/src/router/rich_text.rs b/src/router/rich_text.rs new file mode 100644 index 0000000..8f4bb84 --- /dev/null +++ b/src/router/rich_text.rs @@ -0,0 +1,144 @@ +// TextMeshPro rich-text guarding for user-supplied strings. +// +// Every name/description the custom-song and custom-card uploads accept is rendered by the game +// through TextMeshUI -> TMP with rich text ENABLED (the shipped labels carry m_isRichText: 1), +// and the client does NOT escape it: it hands user text straight to SetText, exactly as it does +// for other players' account names. So a song called "x", a card whose skill +// description carries , or a character named "" mangles or breaks +// every screen that shows it - for EVERY player, since public songs and published cards are +// visible to all. This is the in-game equivalent of stored XSS. +// +// The fix belongs here rather than at the render seam, because that is where official data draws +// the line: the shipped masterdata carries NO markup in any name or artist column (1102 of the +// 1103 tags in the whole EN music table are
, all of them inside detailInfo),
in the +// descriptive columns, and only in character nameRichtextGacha - a column whose name +// says it is meant to be rich text. Uploads are held to exactly that shape. +// +// A '<' that TMP would not read as a tag is left alone, so titles like "<3" still upload. + +// The tags found in `text`, lowercased and without a leading '/'. Mirrors TMP's own scan: a tag +// opens at '<' followed by an optional '/' then a letter or '#' (a colour tag), and must close +// with '>' before the next '<'. Anything else is literal text. +fn tags(text: &str) -> Vec { + let chars: Vec = text.chars().collect(); + let mut rv = Vec::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] != '<' { + i += 1; + continue; + } + let mut j = i + 1; + if j < chars.len() && chars[j] == '/' { + j += 1; + } + // Not tag-like: a bare "<3", "< 3", "<<" + if j >= chars.len() || !(chars[j].is_ascii_alphabetic() || chars[j] == '#') { + i += 1; + continue; + } + let name_start = j; + while j < chars.len() && chars[j] != '>' && chars[j] != '<' && chars[j] != '=' && chars[j] != ' ' { + j += 1; + } + let name: String = chars[name_start..j].iter().collect(); + // The tag has to actually close before another one opens, or TMP prints it verbatim + while j < chars.len() && chars[j] != '>' && chars[j] != '<' { + j += 1; + } + if j < chars.len() && chars[j] == '>' { + rv.push(name.to_lowercase()); + i = j + 1; + } else { + i += 1; + } + } + rv +} + +// Reject text carrying a rich-text tag the field is not supposed to have. `allowed` holds +// lowercase tag names without the slash, so listing "size" permits both and . +pub fn reject_tags(label: &str, text: &str, allowed: &[&str]) -> Result<(), String> { + for tag in tags(text) { + if !allowed.contains(&tag.as_str()) { + return Err(format!( + "{} may not contain the rich text tag <{}> - the game renders it as formatting and it would break the screens it appears on", + label, tag + )); + } + } + Ok(()) +} + +// Drop every rich-text tag, keeping the text between them. For strings that are ALREADY stored +// and cannot be rejected at the point of use - an uploader's account name, which ew accepts +// verbatim on the profile route. +pub fn strip_tags(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let mut rv = String::new(); + let mut i = 0; + while i < chars.len() { + if chars[i] == '<' { + let mut j = i + 1; + if j < chars.len() && chars[j] == '/' { + j += 1; + } + if j < chars.len() && (chars[j].is_ascii_alphabetic() || chars[j] == '#') { + while j < chars.len() && chars[j] != '>' && chars[j] != '<' { + j += 1; + } + if j < chars.len() && chars[j] == '>' { + i = j + 1; + continue; + } + } + } + rv.push(chars[i]); + i += 1; + } + rv +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_real_tags_are_tags() { + // Literal text TMP never reads as formatting + for text in ["I <3 you", "a < b", "3 <", "<<", "1<2", "x", "x", "x", "", "<#ff0000>x", "x", + "x", "x", "x", "x", + // the unclosed opener is literal, but the tag after it is not + "x"] { + assert!(reject_tags("Name", text, &[]).is_err(), "{}", text); + } + } + + #[test] + fn allowed_tags_pass_and_others_still_dont() { + assert!(reject_tags("Description", "line one
line two", &["br"]).is_ok()); + assert!(reject_tags("Description", "line one
line two", &[]).is_err()); + // The slash form of an allowed tag is allowed too + assert!(reject_tags("Gacha name", "Mari", &["size"]).is_ok()); + assert!(reject_tags("Gacha name", "", &["size"]).is_err()); + } + + #[test] + fn the_error_names_the_field_and_the_tag() { + let error = reject_tags("Song name", "boom", &[]).unwrap_err(); + assert!(error.contains("Song name"), "{}", error); + assert!(error.contains(""), "{}", error); + } + + #[test] + fn stripping_keeps_the_words_and_the_harmless_angle_brackets() { + assert_eq!(strip_tags("Nozomi"), "Nozomi"); + assert_eq!(strip_tags("I <3 you"), "I <3 you"); + assert_eq!(strip_tags("plain name"), "plain name"); + assert_eq!(strip_tags("