Skip to content

Commit 9b126e9

Browse files
authored
harden(assist security): bound untrusted utterance (DoS); cmd-injection/ReDoS/NaN/fail-open all proven clean with evidence (ruvnet#1086)
* fix(homecore-assist): bound untrusted utterance length, fail closed (ADR-133 security) The intent recognizers accept utterances from untrusted callers (voice transcripts, the WebSocket `assist` command). Neither the regex nor the semantic path bounded utterance length, so a pathological multi-megabyte utterance forced an unbounded `to_lowercase()` clone plus a per-registered- pattern scan (and, in the semantic path, full tokenisation + feature-hash embedding) — an allocation/CPU amplification on attacker-controlled input. The `regex` crate is linear-time (no catastrophic backtracking), so this was a throughput/memory DoS rather than a hang, but it was still unbounded. Fix: introduce MAX_UTTERANCE_BYTES (4 KiB — far above any real spoken command) and check it at both recognizer boundaries BEFORE any allocation or scan. An over-length utterance fails closed: Ok(None) (no intent, no action), identical to an unrecognised phrase. No legitimate command is affected. Pinned by fails-on-old tests: - recognizer::over_length_utterance_fails_closed — an over-length utterance that contains a valid command resolves to None (would have matched before) - semantic_recognizer::over_length_utterance_fails_closed_semantic Co-Authored-By: claude-flow <ruv@ruv.net> * test(homecore-assist): pin clean security dimensions with evidence (ADR-133) Adds regression tests documenting the dimensions reviewed and found clean, so the properties cannot silently regress: - runner: no subprocess surface exists. RufloRunnerOpts.{script_path,env} are inert and never executed; even a hostile script_path/env spawns nothing. And the entity_id capture class [a-z0-9_ .] strips every shell metacharacter, so a resolved slot can never carry ; | & $ ` / etc into a (future) argv — sanitisation by construction. (shell_metachars_never_survive_into_a_resolved_slot, runner_opts_are_inert_no_process_spawned) - recognizer: the regex crate is a linear-time finite automaton; a classic catastrophic-backtracking shape (a+)+$ on adversarial input completes in bounded time — no ReDoS. (pathological_backtracking_pattern_completes_in_bounded_time) - embedding: 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 to poison cosine k-NN; cosine against the zero vector is a finite 0.0, never NaN. (embeddings_are_structurally_finite, cosine_with_zero_vector_is_finite_not_nan, empty_utterance_against_empty_index_no_panic_no_match) - pipeline: injection-shaped utterances never deliver a metacharacter into a service call; the worst case resolves to a clean entity token, and an unrecognised utterance fails closed to not_understood (no action). (pipeline_injection_shaped_utterance_carries_no_metachars_to_service) Co-Authored-By: claude-flow <ruv@ruv.net> * docs(homecore-assist): record ADR-133 security review (HC-ASSIST-01 + clean dims) CHANGELOG [Unreleased] Security entry + ADR-133 section 6 review notes for the homecore-assist voice/intent pipeline review. Co-Authored-By: claude-flow <ruv@ruv.net>
1 parent 41bee64 commit 9b126e9

8 files changed

Lines changed: 314 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- **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).
2323

2424
### Security
25+
- **`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.
2526
- **`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).
2627
- **`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:**
2728
- **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").

docs/adr/ADR-133-homecore-assist-ruflo.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,71 @@ vs. an in-memory array at compile time), which intersects with ADR-084 (RabitQ)
174174
| **P1** (this ADR) | `intent`, `recognizer` (regex), `handler` (5 built-ins), `runner` (trait + noop), `pipeline` (end-to-end wiring), 10–15 tests |
175175
| **P2** | Real `tokio::process::Child` runner with Windows-safe teardown; `SemanticIntentRecognizer` with ruvector HNSW |
176176
| **P3** | STT/TTS bridge, satellite protocol, cloud fallback |
177+
178+
---
179+
180+
## 6. Security review (beyond-SOTA, untrusted-input → action path)
181+
182+
A focused security review of the Assist pipeline — `utterance → recognizer →
183+
intent → handler → action`, plus `RufloRunner` — treating the utterance as
184+
untrusted input (voice transcripts, the WebSocket `assist` command). This
185+
surface was not covered by the ADR-154–159 sweep.
186+
187+
### 6.1 Finding fixed — HC-ASSIST-01 (unbounded-utterance DoS, LOW)
188+
189+
Both `RegexIntentRecognizer::recognize` and the semantic `recognize_scored`
190+
accepted utterances of **unbounded length** and ran `to_lowercase()` (a full
191+
clone) + a per-registered-pattern scan (and, in the semantic path, full
192+
tokenisation + feature-hash embedding) before any bound — an allocation/CPU
193+
amplification on attacker-controlled input. The `regex` crate is **linear-time**
194+
(RE2-style finite automaton, no catastrophic backtracking), so this was a
195+
throughput/memory DoS, not a hang.
196+
197+
**Fix:** `MAX_UTTERANCE_BYTES = 4096` (far above any real spoken command),
198+
checked at **both** recognizer boundaries *before* any allocation/scan. An
199+
over-length utterance **fails closed** to `Ok(None)` — no intent, no action,
200+
identical to an unrecognised phrase — so it can never be coerced into firing a
201+
handler. Pinned by `over_length_utterance_fails_closed` (an over-length
202+
utterance that *contains* a valid command resolves to `None`, which would have
203+
matched on the old code) and `over_length_utterance_fails_closed_semantic`.
204+
205+
### 6.2 Dimensions confirmed clean (with evidence)
206+
207+
- **Command / argument injection — NO SUBPROCESS SURFACE.** The `RufloRunner`
208+
has exactly two impls: `NoopRunner` (no process) and `LocalRunner` (runs the
209+
local recognizer, no process). There is **no** `std::process` / `tokio::process`
210+
/ `Command` / process `.spawn()` anywhere in the crate — the trait `spawn` is
211+
only a `started: bool` lifecycle flag — and `RufloRunnerOpts.{script_path,env}`
212+
are **inert data, never consumed**. The live `node ruflo-agent.js` runner is
213+
genuinely data-gated/future (P2). Defence-in-depth: the `entity_id` capture
214+
class `[a-z_][a-z0-9_ .]*` **excludes every shell/SQL metacharacter**, so even
215+
when an injection-shaped utterance resolves (the regex is not exact-anchored),
216+
the captured slot is a clean token — sanitisation by construction. Pins:
217+
`shell_metachars_never_survive_into_a_resolved_slot`,
218+
`runner_opts_are_inert_no_process_spawned`,
219+
`pipeline_injection_shaped_utterance_carries_no_metachars_to_service`.
220+
- **ReDoS — STRUCTURALLY IMPOSSIBLE.** `regex 1.12.3` (no `fancy-regex` in the
221+
dependency tree) is linear-time; a classic `(a+)+$` shape on adversarial input
222+
completes in bounded time. Pin:
223+
`pathological_backtracking_pattern_completes_in_bounded_time`. Patterns are
224+
operator-registered, not user-supplied, in any case.
225+
- **NaN-poisoning — EMBEDDINGS STRUCTURALLY FINITE.** The embedding path takes
226+
only `&str` and produces values via FNV feature-hashing + a guarded L2
227+
normalise (`norm > 1e-12`); no external float input, no unguarded division, so
228+
a crafted utterance cannot inject NaN/Inf to poison the cosine k-NN. Cosine
229+
against the zero vector is a finite `0.0`; an empty index `max_by` returns
230+
`None` (no panic); the NaN-safe `partial_cmp().unwrap_or(Equal)` is already in
231+
place. Pins: `embeddings_are_structurally_finite`,
232+
`cosine_with_zero_vector_is_finite_not_nan`,
233+
`empty_utterance_against_empty_index_no_panic_no_match`.
234+
- **Intent confusion / fail-closed.** An unrecognised utterance → `not_understood()`
235+
(no service call); a recognised intent with no registered handler →
236+
`not_understood()`; semantic below-threshold / empty-index → regex fallback.
237+
No default high-privilege intent, no fail-open path.
238+
- **Panic-on-input.** No `unwrap`/`expect`/index reachable from a crafted
239+
utterance; the one `exemplars[id]` index uses an `id` from `enumerate()` over
240+
the append-only exemplar `Vec` (no remove API), so it is always in bounds.
241+
242+
`cargo test -p homecore-assist --no-default-features`: **29→36, 0 failed** (+7);
243+
default/`semantic`: **39→48, 0 failed** (+9). Python deterministic proof
244+
unchanged (homecore-assist is off the signal proof path).

v2/crates/homecore-assist/src/embedding.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,44 @@ mod tests {
149149
assert!(sim_unrel < 0.3, "unrelated similarity too high: {sim_unrel:.3}");
150150
}
151151

152+
#[test]
153+
fn embeddings_are_structurally_finite() {
154+
// SECURITY (NaN-poisoning): the embedding path takes only `&str` and
155+
// produces values via FNV feature-hashing + a guarded L2 normalise.
156+
// There is NO external float input and NO unguarded division, so a
157+
// crafted utterance cannot inject NaN/±Inf into a vector and poison the
158+
// cosine k-NN match. Prove every component is finite across adversarial
159+
// inputs (empty, punctuation-only, unicode, very long, control chars).
160+
for s in [
161+
"",
162+
"!!! ???",
163+
"turn on the kitchen light",
164+
"🔥🔥🔥 \u{0}\u{1}\u{7f} mix",
165+
&"x".repeat(10_000),
166+
"NaN inf -inf 1e999",
167+
] {
168+
let v = embed(s);
169+
assert_eq!(v.len(), EMBEDDING_DIM);
170+
assert!(
171+
v.iter().all(|x| x.is_finite()),
172+
"embedding of {s:?} contained a non-finite component"
173+
);
174+
}
175+
}
176+
177+
#[test]
178+
fn cosine_with_zero_vector_is_finite_not_nan() {
179+
// SECURITY (NaN-poisoning): an empty/punctuation-only utterance embeds
180+
// to the zero vector. Cosine against any exemplar must be a finite 0.0,
181+
// never NaN — so a below-threshold comparison stays well-defined and the
182+
// recognizer falls through (no action) rather than matching on garbage.
183+
let zero = embed("!!! ???");
184+
let real = embed("turn on the light");
185+
let sim = cosine_similarity(&zero, &real);
186+
assert!(sim.is_finite(), "cosine vs zero vector must be finite, got {sim}");
187+
assert_eq!(sim, 0.0, "dot product with the zero vector is exactly 0");
188+
}
189+
152190
#[test]
153191
fn identical_text_is_similarity_one() {
154192
let a = embed("lock the front door");

v2/crates/homecore-assist/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,9 @@ pub mod pipeline;
4747
pub mod embedding;
4848

4949
pub use intent::{Card, Intent, IntentName, IntentResponse};
50-
pub use recognizer::{IntentRecognizer, RecognizerError, RegexIntentRecognizer};
50+
pub use recognizer::{
51+
IntentRecognizer, RecognizerError, RegexIntentRecognizer, MAX_UTTERANCE_BYTES,
52+
};
5153
pub use semantic_recognizer::{SemanticIntentRecognizer, DEFAULT_SIMILARITY_THRESHOLD};
5254
pub use handler::{
5355
HandlerError, HassCancelAll, HassLightSet, HassNevermind, HassTurnOff, HassTurnOn,

0 commit comments

Comments
 (0)