mirror of
https://git.ethanthesleepy.one/ethanaobrien/ew
synced 2026-08-26 15:02:18 +08:00
Fix some injection stuff
This commit is contained in:
@@ -22,6 +22,7 @@ pub mod card;
|
|||||||
pub mod shop;
|
pub mod shop;
|
||||||
pub mod custom_song;
|
pub mod custom_song;
|
||||||
pub mod custom_card;
|
pub mod custom_card;
|
||||||
|
pub mod rich_text;
|
||||||
pub mod webui;
|
pub mod webui;
|
||||||
pub mod clear_rate;
|
pub mod clear_rate;
|
||||||
pub mod exchange;
|
pub mod exchange;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
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::databases::csv::{table, Region};
|
||||||
use crate::router::custom_song::audio;
|
use crate::router::custom_song::audio;
|
||||||
use crate::database::custom_card as database;
|
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()
|
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 let Some(bytes) = file_of(fields, &base) {
|
||||||
if bytes.len() > MAX_VOICE_BYTES {
|
if bytes.len() > MAX_VOICE_BYTES {
|
||||||
return Err(format!("'{}' exceeds the {} MB per-file limit for voicelines", base, MAX_VOICE_BYTES / (1024 * 1024)));
|
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
|
// 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> {
|
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")] {
|
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));
|
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");
|
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", "Skill description"),
|
||||||
("skill_detail_text_en", "Skill English 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));
|
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");
|
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", "Character gacha display name"),
|
||||||
("character_name_richtext_gacha_en", "Character English 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));
|
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"] {
|
for key in ["character_image_color", "character_image_color_dark"] {
|
||||||
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
|
if !valid_color(&text_of(fields, key, stored, &key["character_".len()..])) {
|
||||||
@@ -1656,6 +1683,49 @@ pub mod tests {
|
|||||||
rv
|
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,
|
// Voicelines: transcode to ogg, wire shape + captions, renumbering,
|
||||||
// caption-only edits, replacement GC, deletion, the caps, and the
|
// caption-only edits, replacement GC, deletion, the caps, and the
|
||||||
// content-addressed voice route index
|
// 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_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"));
|
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 -
|
// Enum ranges - each one is a client crash, not a cosmetic error -
|
||||||
// and every message states the actual allowed range
|
// and every message states the actual allowed range
|
||||||
assert!(run(&mutated("type", "0")).unwrap_err().contains("type must be 1-4"));
|
assert!(run(&mutated("type", "0")).unwrap_err().contains("type must be 1-4"));
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Mutex;
|
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::database::custom_song as database;
|
||||||
use crate::runtime::get_data_path;
|
use crate::runtime::get_data_path;
|
||||||
use crate::lock_onto_mutex;
|
use crate::lock_onto_mutex;
|
||||||
@@ -102,9 +102,21 @@ async fn list(Login(key): Login) -> impl Responder {
|
|||||||
return Api(None);
|
return Api(None);
|
||||||
}
|
}
|
||||||
let uid = userdata::get_acc(&key)["user"]["id"].as_i64().unwrap();
|
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!{
|
Api(Some(object!{
|
||||||
"revision": database::get_revision(),
|
"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
|
// 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
|
// 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).
|
// 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));
|
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");
|
let mut visibility = field_str(fields, "visibility");
|
||||||
if visibility.is_empty() {
|
if visibility.is_empty() {
|
||||||
visibility = String::from("public");
|
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));
|
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)
|
// (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 charts: Vec<(i64, Option<(JsonValue, Vec<u8>)>, i64, i64)> = Vec::new();
|
||||||
let mut removed: Vec<i64> = Vec::new();
|
let mut removed: Vec<i64> = Vec::new();
|
||||||
@@ -1260,6 +1305,82 @@ mod tests {
|
|||||||
assert_eq!(database::get_revision(), revision + 1);
|
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 -
|
// 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
|
// 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
|
// the music does, so those notes would never be judged. A note at t=0 is fine: the 2.0s
|
||||||
|
|||||||
2
webui
2
webui
Submodule webui updated: 124065a313...700dae60a3
Reference in New Issue
Block a user