mirror of
https://github.com/ruvnet/RuView
synced 2026-08-08 20:11:43 +00:00
249d6c327f
Closes ADR-115's MQTT track (HA-DISCO + HA-MIND + HA-FABRIC scaffolding). Headline: - 21 entity kinds per node (11 raw + 10 semantic primitives) - MQTT auto-discovery with HA conventions - Matter Bridge scaffolding (SDK wiring deferred to v0.7.1 per ADR §9.10) - Privacy mode strips biometrics at the wire, semantic primitives keep working - 420+ lib tests, mosquitto-backed integration tests, property-based fuzzing - 8 starter HA Blueprints + 3 Lovelace dashboards shipped Tracking issue: #776
327 lines
12 KiB
Rust
327 lines
12 KiB
Rust
//! 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 <VAR>` 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::<u64>()) {
|
||
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);
|
||
}
|
||
}
|
||
}
|