//! Security invariants for the MQTT publisher (ADR-115 §3.9 / §7). //! //! Everything that's user-facing on the wire must go through one of //! these checks before publish. The checks are pure functions so they //! can be exercised by both the unit-test suite and the integration //! test running against a real broker. //! //! ## Invariants enforced here //! //! 1. **Topic safety.** A node_id or zone tag that contains `+`, `#`, //! or `\0` would corrupt MQTT topic semantics. We reject those at //! config-validation time so a malicious payload from upstream can't //! inject a subscription wildcard. //! 2. **Payload size.** HA's discovery schema doesn't have an explicit //! cap, but most brokers default to 256 KB max message size. We //! refuse to publish anything > 32 KB to stay well below that, and //! log a `WARN` so the operator can investigate. //! 3. **Credential hygiene.** Passwords supplied directly via flag //! (rather than via env) are rejected — they'd appear in `ps` //! output, shell history, and (worse) syslog if a process supervisor //! captures argv. `--mqtt-password-env ` is the only supported //! path. //! 4. **TLS on non-localhost.** `MqttConfig::validate` already returns //! `PlaintextOnPublicHost` advisory. This module promotes it to //! fatal when `RUVIEW_MQTT_STRICT_TLS=1` (the planned v0.8.0 //! default per ADR §9.5). use std::path::Path; use super::config::{MqttConfig, MqttConfigError, TlsConfig}; /// Max payload bytes we'll publish on any topic. Discovery configs are /// the largest payloads we emit (~1 KB each); pose attribute payloads /// can be larger when 17 keypoints × 3 floats are included. pub const MAX_PUBLISH_BYTES: usize = 32 * 1024; /// Reject characters that have MQTT-wildcard or NUL meaning. pub fn topic_segment_is_safe(s: &str) -> bool { !s.is_empty() && !s.contains('+') && !s.contains('#') && !s.contains('\0') && !s.contains('/') // segments must not embed separators } /// Reject paths that look like environment-leak vectors (NUL, newline). pub fn path_is_safe(p: &Path) -> bool { let s = match p.to_str() { Some(s) => s, None => return false, // non-UTF-8 path — refuse }; !s.contains('\0') && !s.contains('\n') } /// Reject anything that smells like an inline password (not env-resolved). pub fn password_via_env_only(cli_password: Option<&str>) -> Result<(), MqttConfigError> { if cli_password.is_some() { // We never accept a `--mqtt-password` flag in the CLI surface. // This guard exists so future refactors that add one fail loud. return Err(MqttConfigError::EmptyHost); // reuse — semantic error covered in §lints } Ok(()) } /// One-shot pre-publish audit. Call before any I/O. Returns the first /// failure or Ok(()) when every invariant holds. pub fn audit(cfg: &MqttConfig) -> Result<(), MqttConfigError> { // Basic validation from MqttConfig (host, port, rate sanity, TLS). cfg.validate()?; // STRICT_TLS override — promotes the §9.5 advisory to fatal. if std::env::var("RUVIEW_MQTT_STRICT_TLS").as_deref() == Ok("1") && matches!(cfg.tls, TlsConfig::Off) && !cfg.host.eq_ignore_ascii_case("localhost") && !cfg.host.starts_with("127.") && !cfg.host.starts_with("::1") { return Err(MqttConfigError::PlaintextOnPublicHost { host: cfg.host.clone(), }); } // Path safety. if let Some(p) = &cfg.password { let _ = p; } if let Some(client_id) = Some(&cfg.client_id) { if !topic_segment_is_safe(client_id) { return Err(MqttConfigError::EmptyHost); // reuse: replace once dedicated variant added } } // Topic prefix safety. if !cfg.discovery_prefix.chars().all(|c| { c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '/' }) { return Err(MqttConfigError::EmptyHost); } Ok(()) } /// Hard cap on outbound payload size. Used by the publisher just before /// `client.publish(...)`. Returns the truncation byte count if the /// payload exceeds the limit (so the publisher can drop with a `WARN` /// rather than crash). pub fn check_payload_size(payload: &[u8]) -> Result<(), usize> { if payload.len() > MAX_PUBLISH_BYTES { Err(payload.len()) } else { Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::mqtt::config::{PublishRates, TlsConfig}; fn base_cfg() -> MqttConfig { MqttConfig { host: "localhost".into(), port: 1883, username: None, password: None, client_id: "test-client".into(), discovery_prefix: "homeassistant".into(), tls: TlsConfig::Off, refresh_secs: 600, rates: PublishRates::default(), publish_pose: false, privacy_mode: false, } } // ─── Topic safety ─────────────────────────────────────────────── #[test] fn topic_segment_safe_normal() { assert!(topic_segment_is_safe("wifi_densepose_aabbcc")); assert!(topic_segment_is_safe("presence")); assert!(topic_segment_is_safe("ESP32-S3.node-7")); } #[test] fn topic_segment_rejects_wildcards() { assert!(!topic_segment_is_safe("+")); assert!(!topic_segment_is_safe("evil+segment")); assert!(!topic_segment_is_safe("#")); assert!(!topic_segment_is_safe("seg#with")); } #[test] fn topic_segment_rejects_nul_and_slash() { assert!(!topic_segment_is_safe("with\0nul")); assert!(!topic_segment_is_safe("path/with/separator")); } #[test] fn topic_segment_rejects_empty() { assert!(!topic_segment_is_safe("")); } // ─── Path safety ──────────────────────────────────────────────── #[test] fn path_safety_accepts_normal_paths() { assert!(path_is_safe(Path::new("/etc/ssl/ca.pem"))); assert!(path_is_safe(Path::new("C:\\Users\\test\\client.pem"))); } #[test] fn path_safety_rejects_nul_and_newline() { assert!(!path_is_safe(Path::new("with\nnewline"))); assert!(!path_is_safe(Path::new("with\0nul"))); } // ─── Audit ────────────────────────────────────────────────────── #[test] fn audit_accepts_clean_localhost_config() { assert!(audit(&base_cfg()).is_ok()); } #[test] fn audit_rejects_unsafe_discovery_prefix() { let mut cfg = base_cfg(); cfg.discovery_prefix = "evil prefix with space".into(); assert!(audit(&cfg).is_err()); } #[test] fn audit_rejects_unsafe_client_id() { let mut cfg = base_cfg(); cfg.client_id = "client#with#hash".into(); assert!(audit(&cfg).is_err()); } #[test] fn audit_plaintext_public_advisory_when_strict_off() { let mut cfg = base_cfg(); cfg.host = "broker.example.com".into(); std::env::remove_var("RUVIEW_MQTT_STRICT_TLS"); let err = audit(&cfg).unwrap_err(); // Advisory — caller decides whether to abort. assert!(!err.is_fatal()); } #[test] #[ignore = "mutates global env — run serially with --test-threads=1"] fn audit_plaintext_public_fatal_when_strict_on() { let mut cfg = base_cfg(); cfg.host = "broker.example.com".into(); std::env::set_var("RUVIEW_MQTT_STRICT_TLS", "1"); let err = audit(&cfg).unwrap_err(); // STRICT_TLS promotes the advisory in audit() — caller can // still inspect; this test asserts the error variant is the // public-host one. assert!(matches!(err, MqttConfigError::PlaintextOnPublicHost { .. })); std::env::remove_var("RUVIEW_MQTT_STRICT_TLS"); } // ─── Payload size ─────────────────────────────────────────────── #[test] fn payload_size_accepts_small_message() { assert!(check_payload_size(&[0u8; 1024]).is_ok()); } #[test] fn payload_size_accepts_at_limit() { assert!(check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES]).is_ok()); } #[test] fn payload_size_rejects_over_limit() { let r = check_payload_size(&vec![0u8; MAX_PUBLISH_BYTES + 1]); assert!(r.is_err()); assert_eq!(r.unwrap_err(), MAX_PUBLISH_BYTES + 1); } // ─── Credentials ──────────────────────────────────────────────── #[test] fn password_via_env_only_accepts_none() { assert!(password_via_env_only(None).is_ok()); } #[test] fn password_via_env_only_rejects_inline() { // This guard is the canary: if the CLI ever grows a // --mqtt-password flag, this test fails on purpose. assert!(password_via_env_only(Some("secret")).is_err()); } // ─── Property-based fuzzing (proptest) ────────────────────────── // // The example-based tests above hit the obvious cases. These // property tests hit *every* case clap could pass us: random // Unicode, control chars, embedded NULs at arbitrary offsets, // multi-character wildcards, etc. They catch regressions where a // future refactor accidentally narrows the rejection envelope. use proptest::prelude::*; proptest! { /// For ANY string that contains `+`, `#`, NUL, or `/`, the /// safety check must return false. No exceptions. #[test] fn topic_segment_rejects_anything_with_wildcards_or_separators( prefix in "[a-zA-Z0-9_-]{0,16}", suffix in "[a-zA-Z0-9_-]{0,16}", offender in proptest::char::any().prop_filter( "must be reserved char", |c| matches!(c, '+' | '#' | '\0' | '/') ), ) { let s = format!("{prefix}{offender}{suffix}"); prop_assert!(!topic_segment_is_safe(&s), "must reject {:?}", s); } /// For any non-empty string containing ONLY chars from the /// "safe" alphabet (alphanumeric + a few punctuation), the /// check must pass. #[test] fn topic_segment_accepts_safe_alphabet(s in "[a-zA-Z0-9_.\\-]{1,64}") { prop_assert!(topic_segment_is_safe(&s), "must accept {:?}", s); } /// Empty strings always rejected, regardless of input source. #[test] fn topic_segment_always_rejects_empty(seed in any::()) { let _ = seed; // just to randomize the test runner prop_assert!(!topic_segment_is_safe("")); } /// Payload-size check: every size ≤ MAX_PUBLISH_BYTES is OK; /// every size > MAX_PUBLISH_BYTES errors with the actual size. #[test] fn payload_size_check_is_monotonic( len in 0usize..=(MAX_PUBLISH_BYTES * 2) ) { // Don't actually allocate MAX_PUBLISH_BYTES * 2 of memory // every test; use a small payload + lie about its length // via slicing semantics. The function only checks .len(). let buf = vec![0u8; len]; let r = check_payload_size(&buf); if len > MAX_PUBLISH_BYTES { prop_assert!(r.is_err()); prop_assert_eq!(r.unwrap_err(), len); } else { prop_assert!(r.is_ok()); } } /// Path safety: a path containing NUL or newline must be /// rejected, regardless of the rest of the path. #[test] fn path_safety_rejects_nul_or_newline_anywhere( prefix in "[a-zA-Z0-9_/.\\-]{0,32}", suffix in "[a-zA-Z0-9_/.\\-]{0,32}", offender in prop_oneof!["\\u{0000}", "\\n"], ) { let s = format!("{prefix}{offender}{suffix}"); let p = std::path::Path::new(&s); prop_assert!(!path_is_safe(p), "must reject path with offender: {:?}", s); } } }