From 885807f00c11b39229ef5a8b2b432e495bdfc3fc Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Wed, 6 May 2026 21:21:45 +0200 Subject: [PATCH] Better app name validation for custom.txt generator --- src/api/admin/pages/customization.rs | 63 +++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/src/api/admin/pages/customization.rs b/src/api/admin/pages/customization.rs index 33293e3..e1fc81f 100644 --- a/src/api/admin/pages/customization.rs +++ b/src/api/admin/pages/customization.rs @@ -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, } +/// 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, String> { let trimmed = raw.trim(); if trimmed.is_empty() {