Fix some injection stuff

This commit is contained in:
Ethan O'Brien
2026-08-01 21:35:26 -05:00
parent 29ed7a1b46
commit 0b0bef7bfb
4 changed files with 209 additions and 7 deletions

View File

@@ -22,6 +22,7 @@ pub mod card;
pub mod shop;
pub mod custom_song;
pub mod custom_card;
pub mod rich_text;
pub mod webui;
pub mod clear_rate;
pub mod exchange;

View File

@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
use std::fs;
use std::sync::Mutex;
use crate::router::{databases, global, userdata, webui, Login, Api};
use crate::router::{databases, global, rich_text, userdata, webui, Login, Api};
use crate::router::databases::csv::{table, Region};
use crate::router::custom_song::audio;
use crate::database::custom_card as database;
@@ -859,6 +859,10 @@ fn collect_voice(fields: &Fields, stored_voice: &JsonValue) -> Result<(JsonValue
stored_line.map(|line| line[suffix].as_str().unwrap_or("").to_string()).unwrap_or_default()
}
};
// Captions are drawn as live subtitles by the same unescaped TMP path
for suffix in ["text", "text_en"] {
rich_text::reject_tags(&format!("'{}_{}'", base, suffix), &text(suffix), &[])?;
}
if let Some(bytes) = file_of(fields, &base) {
if bytes.len() > MAX_VOICE_BYTES {
return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024)));
@@ -950,9 +954,13 @@ fn validate_character_ref(uid: i64, master_character_id: i64) -> Result<(), Stri
// about. Returns the catalog blob, ready to store and serve verbatim
fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, stored: &JsonValue) -> Result<JsonValue, String> {
for (key, label) in [("name", "Card name"), ("name_en", "Card English name")] {
if text_of(fields, key, stored, key).is_empty() {
let text = text_of(fields, key, stored, key);
if text.is_empty() {
return Err(format!("{} is required", label));
}
// The client renders these through TMP with rich text on and no escaping; official card
// names carry no markup (rich_text.rs)
rich_text::reject_tags(label, &text, &[])?;
}
let card_type = number_of(fields, "type", stored, "type");
@@ -995,9 +1003,13 @@ fn build_card(master_card_id: i64, master_character_id: i64, fields: &Fields, st
("skill_detail_text", "Skill description"),
("skill_detail_text_en", "Skill English description")
] {
if text_of(fields, key, &stored_skill, &key["skill_".len()..]).is_empty() {
let text = text_of(fields, key, &stored_skill, &key["skill_".len()..]);
if text.is_empty() {
return Err(format!("{} is required", label));
}
// Descriptions may wrap, like the official skill_center detailText rows; nothing else
let allowed: &[&str] = if key.ends_with("detail_text") || key.ends_with("detail_text_en") { &["br"] } else { &[] };
rich_text::reject_tags(label, &text, allowed)?;
}
let trigger = number_of(fields, "skill_trigger", &stored_skill, "trigger");
@@ -1113,9 +1125,24 @@ fn build_character(master_character_id: i64, fields: &Fields, stored: &JsonValue
("character_name_richtext_gacha", "Character gacha display name"),
("character_name_richtext_gacha_en", "Character English gacha display name")
] {
if text_of(fields, key, stored, &key["character_".len()..]).is_empty() {
let text = text_of(fields, key, stored, &key["character_".len()..]);
if text.is_empty() {
return Err(format!("{} is required", label));
}
// The gacha display name is the ONE column official data formats, and only ever with
// <size=NN> (character.csv nameRichtextGacha); descriptions may wrap; names carry nothing
let allowed: &[&str] = if key.starts_with("character_name_richtext_gacha") {
&["size"]
} else if key.starts_with("character_detail_text") {
&["br"]
} else {
&[]
};
rich_text::reject_tags(label, &text, allowed)?;
}
// The remaining free-text profile columns: shown on the member page, same TMP treatment
for key in ["height", "blood_type", "blood_type_en", "birthday", "birthday_en", "voice_actor", "voice_actor_en"] {
rich_text::reject_tags(key, &text_of(fields, &format!("character_{}", key), stored, key), &[])?;
}
for key in ["character_image_color", "character_image_color_dark"] {
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
@@ -1656,6 +1683,49 @@ pub mod tests {
rv
}
// Character text and voiceline captions land in the same unescaped TMP labels as card text.
// The gacha display name is the one column official data formats, and only with <size=NN>
#[test]
fn character_text_may_not_carry_rich_text_tags() {
let _lock = crate::runtime::lock_test_data_path();
wipe(4014);
let run = |fields: &Fields| with_permissions(4014, &[permissions::CARD_UPLOAD], || create_character(4014, fields));
let mutated = |key: &str, value: &str| {
let mut fields = character_fields();
field(&mut fields, key, value);
fields
};
for key in ["character_name", "character_name_en", "character_name_ruby", "character_name_ruby_en"] {
let error = run(&mutated(key, "<size=400%>x")).unwrap_err();
assert!(error.contains("<size>"), "{} -> {}", key, error);
}
// Profile columns too
assert!(run(&mutated("character_height", "<b>170cm</b>")).unwrap_err().contains("<b>"));
assert!(run(&mutated("character_voice_actor", "<sprite=2>")).unwrap_err().contains("<sprite>"));
// Descriptions wrap; nothing else
assert!(run(&mutated("character_detail_text", "<rotate=45>x")).unwrap_err().contains("<rotate>"));
// The gacha display name keeps <size>, still nothing else
assert!(run(&mutated("character_name_richtext_gacha", "<sprite=1>")).unwrap_err().contains("<sprite>"));
// Voiceline captions are subtitles - a caption with a tag is rejected before anything
// is written
let mut fields = character_fields();
fields.insert(String::from("voice_live_start_1"), test_wav(1.0, 1));
field(&mut fields, "voice_live_start_1_text", "<color=red>x");
assert!(run(&fields).unwrap_err().contains("<color>"));
// The official shape uploads: <size=NN> on the gacha name, <br> in a description
let mut fields = character_fields();
field(&mut fields, "character_name_richtext_gacha", "<size=80>Test</size>");
field(&mut fields, "character_detail_text", "line one<br>line two");
let id = run(&fields).unwrap();
let character = database::get_character(id).unwrap();
assert_eq!(character["name_richtext_gacha"].as_str(), Some("<size=80>Test</size>"));
assert_eq!(character["detail_text"].as_str(), Some("line one<br>line two"));
}
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
// caption-only edits, replacement GC, deletion, the caps, and the
// content-addressed voice route index
@@ -1939,6 +2009,16 @@ pub mod tests {
assert!(run(&mutated("skill_detail_text_en", "")).unwrap_err().contains("Skill English description is required"));
assert!(run(&mutated("skill_target_group_id", "123")).unwrap_err().contains("skill_target_group_id"));
// Rich-text tags: every one of these strings is drawn by TMP with rich text ON and no
// escaping, so markup in a name or a caption mangles the screens it lands on
for key in ["name", "name_en", "skill_name", "skill_name_en"] {
let error = run(&mutated(key, "<size=400%>x")).unwrap_err();
assert!(error.contains("<size>"), "{} -> {}", key, error);
}
assert!(run(&mutated("skill_detail_text", "<sprite=1>")).unwrap_err().contains("<sprite>"));
// A description may wrap and "<3" is not a tag, so neither is rejected (proved without
// creating cards in rich_text's own unit tests - this test only exercises rejections)
// Enum ranges - each one is a client crash, not a cosmetic error -
// and every message states the actual allowed range
assert!(run(&mutated("type", "0")).unwrap_err().contains("type must be 1-4"));

View File

@@ -16,7 +16,7 @@ use std::collections::HashMap;
use std::fs;
use std::sync::Mutex;
use crate::router::{global, userdata, webui, Login, Api};
use crate::router::{global, rich_text, userdata, webui, Login, Api};
use crate::database::custom_song as database;
use crate::runtime::get_data_path;
use crate::lock_onto_mutex;
@@ -102,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder {
return Api(None);
}
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
let mut songs = database::get_songs_for_user(uid);
for song in songs.members_mut() {
// Additive field: the client turns it into the song's detail-info credit line (the
// staff-credits text the live loading screen and the music library show). Old clients
// that don't know the field simply ignore it. The name is an ACCOUNT name, which the
// profile route stores verbatim, so it is stripped of rich-text tags before it lands in
// a TMP rich-text field (rich_text.rs)
let Some(music_id) = song["music_id"].as_i64() else { continue; };
let owner = database::get_song_owner(music_id).unwrap_or(0);
let name = userdata::get_name_and_rank(owner)["user_name"].as_str().unwrap_or("").to_string();
song["uploader"] = rich_text::strip_tags(&name).into();
}
Api(Some(object!{
"revision": database::get_revision(),
"songs": database::get_songs_for_user(uid)
"songs": songs
}))
}
@@ -349,6 +361,25 @@ fn default_scores() -> (JsonValue, JsonValue) {
})
}
// Every free-text column the client shows for a song. It renders them through TMP with rich
// text on and no escaping (rich_text.rs), and the official music table carries no markup in ANY
// of these columns - <br> only ever appears in detailInfo - so no tag is allowed in any of them.
fn validate_song_text(
name: &str, name_en: &str, short_name: &str, kana: &str, artist: &str, artist_en: &str
) -> Result<(), String> {
for (label, text) in [
("Song name", name),
("Song English name", name_en),
("Short name", short_name),
("Name reading", kana),
("Artist", artist),
("English artist", artist_en)
] {
rich_text::reject_tags(label, text, &[])?;
}
Ok(())
}
// Combo-mission targets. Official live_mission_combo rows are round(hardest difficulty's
// full combo * 0.2/0.4/0.6/0.8) - verified against 626 of the 637 shipped rows (the 11
// outliers are songs that gained a harder difficulty after the mission row was authored).
@@ -419,6 +450,15 @@ fn create_song(uid: i64, fields: &HashMap<String, Vec<u8>>) -> Result<i64, Strin
return Err(format!("Unknown band category '{}'", band_category));
}
validate_song_text(
&name,
&field_str(fields, "name_en"),
&field_str(fields, "short_name"),
&field_str(fields, "kana"),
&artist,
&field_str(fields, "artist_en")
)?;
let mut visibility = field_str(fields, "visibility");
if visibility.is_empty() {
visibility = String::from("public");
@@ -621,6 +661,11 @@ fn update_song(music_id: i64, fields: &HashMap<String, Vec<u8>>) -> Result<(), S
return Err(format!("Unknown band category '{}'", band_category));
}
// The RESULTING text, so an edit that leaves a field alone is checked against what stays
validate_song_text(
&name, &text("name_en"), &text("short_name"), &text("kana"), &artist, &text("artist_en")
)?;
// (level, replacement chart json + original SIF1 bytes, full_combo, level_number)
let mut charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
let mut removed: Vec<i64> = Vec::new();
@@ -1260,6 +1305,82 @@ mod tests {
assert_eq!(database::get_revision(), revision + 1);
}
// Song text is rendered by TMP with rich text on and no escaping, so a tag in a name or an
// artist is rejected at upload and at edit. A '<' that TMP wouldn't read as a tag survives.
#[test]
fn song_text_may_not_carry_rich_text_tags() {
let _lock = crate::runtime::lock_test_data_path();
let base = || {
let mut fields = HashMap::new();
field(&mut fields, "name", "Tag Check");
field(&mut fields, "artist", "Tag Artist");
field(&mut fields, "attribute", "1");
field(&mut fields, "level_number_1", "5");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(1210.0));
fields.insert(String::from("chart_1"), test_chart());
fields
};
for (key, label) in [
("name", "Song name"),
("name_en", "Song English name"),
("short_name", "Short name"),
("kana", "Name reading"),
("artist", "Artist"),
("artist_en", "English artist")
] {
let mut fields = base();
field(&mut fields, key, "<size=400%>boom");
let error = create_song(8888, &fields).unwrap_err();
assert!(error.contains(label), "{} -> {}", key, error);
assert!(error.contains("<size>"), "{} -> {}", key, error);
assert!(database::get_song(8888).is_none());
}
// "<3" is not a tag, so it uploads
let mut fields = base();
field(&mut fields, "name", "I <3 LIVE");
let music_id = create_song(8888, &fields).unwrap();
assert_eq!(database::get_song(music_id).unwrap()["name"], "I <3 LIVE");
// Edits are held to the same rule, and the stored song survives the rejection
let before = database::get_song(music_id).unwrap();
let mut fields = HashMap::new();
field(&mut fields, "artist", "<sprite=1>");
assert!(update_song(music_id, &fields).unwrap_err().contains("Artist"));
assert_eq!(jzon::stringify(database::get_song(music_id).unwrap()), jzon::stringify(before));
}
// The catalog the GAME reads carries the uploader's account name, which the client turns
// into the song's detail-info credit line. Account names are stored verbatim by the profile
// route, so the catalog strips rich-text tags out of them
#[test]
fn the_game_catalog_carries_a_tag_free_uploader_name() {
let _lock = crate::runtime::lock_test_data_path();
let uid = 1;
let mut fields = HashMap::new();
field(&mut fields, "name", "Credited");
field(&mut fields, "artist", "Credit Artist");
field(&mut fields, "attribute", "2");
field(&mut fields, "level_number_1", "5");
fields.insert(String::from("jacket"), test_png());
fields.insert(String::from("audio"), test_ogg_tone(1320.0));
fields.insert(String::from("chart_1"), test_chart());
let music_id = create_song(uid, &fields).unwrap();
let songs = database::get_songs_for_user(uid);
let song = songs.members().find(|s| s["music_id"] == music_id).unwrap();
// The router adds the field; the stored blob never holds it
assert!(song["uploader"].is_null());
// The tag stripper is what the router applies to the account name
assert_eq!(rich_text::strip_tags("<size=400%>Nozomi"), "Nozomi");
assert_eq!(rich_text::strip_tags("Honoka"), "Honoka");
}
// A chart that outlives its audio is rejected on upload AND on edit (from either side -
// swapping in a longer chart, or shorter audio under charts that stay). The live ends when
// the music does, so those notes would never be judged. A note at t=0 is fine: the 2.0s