Better app name validation for custom.txt generator
build / build-linux-amd64 (push) Successful in 1m56s

This commit is contained in:
2026-05-06 21:21:45 +02:00
parent bc260b7d8f
commit 885807f00c
+58 -5
View File
@@ -95,6 +95,9 @@ pub async fn generate(
Some(("error", "App name is required.")),
)));
}
if let Err(msg) = validate_app_name(state.app_name.trim()) {
return Ok(Html(render_form(&state, &pubkey, "", Some(("error", msg)))));
}
if logo_too_big {
return Ok(Html(render_form(
&state,
@@ -158,15 +161,21 @@ pub async fn generate(
if !default_obj.is_empty() {
payload.insert("default-settings".into(), Value::Object(default_obj));
}
if !override_obj.is_empty() {
payload.insert("override-settings".into(), Value::Object(override_obj));
// `app-icon` must live inside `override-settings`, not at top level. The
// client routes top-level string keys into HARD_SETTINGS, but the Flutter
// logo loader (`mainGetBuildinOption('app-icon')`) reads from
// BUILTIN_SETTINGS — and BUILTIN_SETTINGS is only populated when a key is
// matched against KEYS_BUILDIN_SETTINGS inside default-/override-settings.
let mut overrides = override_obj;
if let Some(bytes) = &logo_bytes {
overrides.insert("app-icon".into(), json!(base64::encode(bytes)));
}
if !overrides.is_empty() {
payload.insert("override-settings".into(), Value::Object(overrides));
}
for (k, v) in collect_kv_pairs(&state.kv_keys, &state.kv_values) {
payload.insert(k, json!(v));
}
if let Some(bytes) = &logo_bytes {
payload.insert("app-icon".into(), json!(base64::encode(bytes)));
}
let body = match serde_json::to_vec(&Value::Object(payload)) {
Ok(v) => v,
@@ -220,6 +229,50 @@ struct FormState {
kv_values: Vec<String>,
}
/// Reject app names that won't survive the round-trip through the patched
/// client's Windows install/registry/service code paths. Spaces are fine
/// (e.g. "Nintendo Support"), but path separators, quotes, shell
/// metacharacters, and control characters are not — `get_app_name()` is
/// interpolated unquoted into batch scripts, registry subkeys, install
/// dirs, service names, and named-pipe paths. We also reject Windows
/// reserved device names (CON/PRN/AUX/NUL/COM1-9/LPT1-9). This does not
/// change anything about the signing pipeline; it just stops operators
/// from baking foot-guns into custom.txt.
fn validate_app_name(name: &str) -> Result<(), &'static str> {
if name.len() > 64 {
return Err("App name must be 64 characters or fewer.");
}
// Disallowed: ASCII control chars, Windows path-illegal chars
// (`<>:"/\|?*`), and shell-injection-prone chars (`& ; $ \` \n` etc.).
// Allowed printable punctuation is limited to space, '-', '_', '.', '(',
// ')' which is enough for human-friendly names like "Acme Remote (EU)".
const ALLOWED_PUNCT: &[char] = &[' ', '-', '_', '.', '(', ')'];
if !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || ALLOWED_PUNCT.contains(&c))
{
return Err("App name may only contain ASCII letters, digits, spaces, and `- _ . ( )`. Other punctuation breaks Windows install paths.");
}
if name.starts_with(' ') || name.ends_with(' ') {
return Err("App name must not start or end with a space.");
}
if name.contains(" ") {
return Err("App name must not contain consecutive spaces.");
}
let upper = name.to_ascii_uppercase();
const RESERVED: &[&str] = &["CON", "PRN", "AUX", "NUL"];
if RESERVED.contains(&upper.as_str()) {
return Err("App name conflicts with a Windows reserved device name.");
}
if (upper.starts_with("COM") || upper.starts_with("LPT"))
&& upper.len() == 4
&& upper.as_bytes()[3].is_ascii_digit()
{
return Err("App name conflicts with a Windows reserved device name.");
}
Ok(())
}
fn parse_settings_json(raw: &str) -> Result<Map<String, Value>, String> {
let trimmed = raw.trim();
if trimmed.is_empty() {