From 23e3c813487baf9c4c83c43a6c3aed2f2effd32f Mon Sep 17 00:00:00 2001 From: soulhackzlol Date: Sun, 4 Oct 2026 20:42:33 +0200 Subject: [PATCH] fix(obsws): clear errors for a missing OBS password or challenge The WebSocket password was an empty string both when OBS asks for none and when it asks for one that isn't saved. It is now None when OBS's server doesn't ask for one, and a server that asks with no saved password is reported as soon as its settings are read. A Hello that asks for a password without its salt or challenge used to get a proof built from empty text, which OBS can only refuse with an unclear close code. It now fails with a message saying so. Also clears CodeQL's two rust/hard-coded-cryptographic-value alerts on shipping code (the empty fallbacks flowed into the password proof). --- src/integrations/obsws.rs | 52 +++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/integrations/obsws.rs b/src/integrations/obsws.rs index a577df4..35e4820 100644 --- a/src/integrations/obsws.rs +++ b/src/integrations/obsws.rs @@ -113,7 +113,8 @@ pub struct Command { /// Not `Debug`: it holds the WebSocket password. struct Settings { port: u16, - password: String, + /// `None` when OBS's server doesn't ask for one. + password: Option, } /// One whole message from OBS, as the reader task hands it over. @@ -188,6 +189,8 @@ fn set_state(status: &Mutex, state: &'static str, problem: &str) { } } +const NO_PASSWORD: &str = "OBS wants a WebSocket password but none is saved. Set one in OBS: Tools > WebSocket Server Settings."; + /// OBS's WebSocket server settings, from the file OBS keeps them in. fn read_settings() -> Result { let path = crate::obs_register::obs_config_dirs() @@ -217,9 +220,14 @@ fn parse_settings(text: &str) -> Result { .filter(|p| (1.0..=65535.0).contains(p)) .map_or(DEFAULT_PORT, |p| p as u16); let password = if v.bool_or("auth_required", false) { - v.str_or("server_password", "").to_string() + let saved = v + .get("server_password") + .and_then(Value::as_str) + .filter(|p| !p.is_empty()) + .ok_or_else(|| NO_PASSWORD.to_string())?; + Some(saved.to_string()) } else { - String::new() + None }; Ok(Settings { port, password }) } @@ -271,7 +279,7 @@ async fn session( async fn greet(stream: &mut TcpStream, settings: &Settings) -> Result<(), String> { handshake(stream, settings.port).await?; let hello = read_json(stream).await?; - let identify = identify_message(&hello, &settings.password)?; + let identify = identify_message(&hello, settings.password.as_deref())?; write_frame(stream, 1, identify.as_bytes()).await?; let identified = read_json(stream).await?; if identified.u64_or("op", 99) != 2 { @@ -533,7 +541,7 @@ fn names(data: &Value, list: &str, field: &str, reversed: bool) -> Vec { /// The Identify message answering OBS's Hello, with the password proof /// when OBS asks for one. -fn identify_message(hello: &Value, password: &str) -> Result { +fn identify_message(hello: &Value, password: Option<&str>) -> Result { if hello.u64_or("op", 99) != 0 { return Err("that isn't OBS's WebSocket server".to_string()); } @@ -545,14 +553,16 @@ fn identify_message(hello: &Value, password: &str) -> Result { ), ]; if let Some(auth) = hello.path("d.authentication") { - if password.is_empty() { - return Err("OBS wants a WebSocket password but none is saved. Set one in OBS: Tools > WebSocket Server Settings.".to_string()); - } - let proof = auth_proof( - password, - auth.str_or("salt", ""), - auth.str_or("challenge", ""), - ); + let password = password.ok_or_else(|| NO_PASSWORD.to_string())?; + // Without both, no proof could ever match: say so instead of + // sending one OBS can only refuse. + let (Some(salt), Some(challenge)) = ( + auth.get("salt").and_then(Value::as_str), + auth.get("challenge").and_then(Value::as_str), + ) else { + return Err("OBS asked for a password without the challenge to answer it.".to_string()); + }; + let proof = auth_proof(password, salt, challenge); d.push(("authentication".to_string(), json::str(proof))); } Ok(json::obj([("op", Value::Num(1.0)), ("d", Value::Obj(d))]).to_json()) @@ -766,10 +776,13 @@ mod tests { fn settings_need_the_server_switched_on() { let on = r#"{"server_enabled":true,"server_port":4456,"auth_required":true,"server_password":"pw"}"#; let s = parse_settings(on).unwrap(); - assert_eq!((s.port, s.password.as_str()), (4456, "pw")); + assert_eq!((s.port, s.password.as_deref()), (4456, Some("pw"))); let open = r#"{"server_enabled":true,"auth_required":false,"server_password":"pw"}"#; let s = parse_settings(open).unwrap(); - assert_eq!((s.port, s.password.as_str()), (4455, "")); + assert_eq!((s.port, s.password.as_deref()), (4455, None)); + let unsaved = r#"{"server_enabled":true,"auth_required":true}"#; + let missing = parse_settings(unsaved).err(); + assert!(missing.is_some_and(|e| e.contains("password"))); let off = parse_settings(r#"{"server_enabled":false}"#).err(); assert!(off.is_some_and(|e| e.contains("Tools"))); assert!(parse_settings("nope").is_err()); @@ -778,7 +791,7 @@ mod tests { #[test] fn identify_answers_hello_with_or_without_a_password() { let open = json::parse(r#"{"op":0,"d":{"rpcVersion":1}}"#).unwrap(); - let msg = json::parse(&identify_message(&open, "").unwrap()).unwrap(); + let msg = json::parse(&identify_message(&open, None).unwrap()).unwrap(); assert_eq!(msg.u64_or("op", 0), 1); assert!(msg.path("d.authentication").is_none()); assert_eq!( @@ -790,9 +803,12 @@ mod tests { r#"{"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"s","challenge":"c"}}}"#, ) .unwrap(); - assert!(identify_message(&locked, "").is_err_and(|e| e.contains("password"))); - let msg = json::parse(&identify_message(&locked, "pw").unwrap()).unwrap(); + assert!(identify_message(&locked, None).is_err_and(|e| e.contains("password"))); + let msg = json::parse(&identify_message(&locked, Some("pw")).unwrap()).unwrap(); assert!(msg.path("d.authentication").is_some()); + let no_challenge = + json::parse(r#"{"op":0,"d":{"rpcVersion":1,"authentication":{"salt":"s"}}}"#).unwrap(); + assert!(identify_message(&no_challenge, Some("pw")).is_err_and(|e| e.contains("challenge"))); } #[test]