mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
fix(automation security): template-bomb DoS (100MB/11s render → fuel-bounded, HIGH) + delay panic-on-config (MEDIUM) (#1083)
* fix(homecore-automation): bound template render to stop unbounded-expansion DoS (HC-SEC-01)
A `template:` condition / value_template comes straight from user
automation config and was rendered with MiniJinja's default (no
instruction budget, no output cap). A single condition such as
`{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}`
rendered a 100 MB string over ~11 s on one render call (proven
empirically) — a CPU/memory denial of service, the bfld-class
"unbounded expansion".
Fix:
- Enable MiniJinja's `fuel` feature and set a per-render instruction
budget (`set_fuel(Some(1_000_000))`). A nested loop burns one unit
per iteration, so the budget caps total work regardless of nesting;
the attack now fails fast (~90 ms) with "engine ran out of fuel".
- Reject template sources over 64 KiB before compilation (defense in
depth so a pathological literal can neither compile nor emit verbatim).
Legitimate HA templates (a few dozen instructions) are unaffected.
Tests (fail on old — unbounded render / no rejection):
- nested_loop_template_is_bounded_not_unbounded_dos
- single_huge_repeat_template_is_bounded
- oversized_template_source_is_rejected
- legitimate_template_still_renders_within_fuel (no regression)
Co-Authored-By: claude-flow <ruv@ruv.net>
* fix(homecore-automation): stop crafted delay/timeout from panicking the run task (HC-SEC-02)
`Action::Delay { seconds }` and `Action::WaitForTrigger { timeout_seconds }`
fed the user-supplied float straight into `Duration::from_secs_f64`, which
PANICS on negative, NaN, infinite, or overflowing inputs. All of those are
reachable from a crafted (or simply typo'd) automation YAML —
`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308` — so one hostile config
aborts the spawned automation task with a panic
("cannot convert float seconds to Duration: value is negative", proven
empirically).
Fix: a `safe_duration_from_secs` guard that saturates instead of panicking,
matching Home Assistant's lenient "non-positive delay = no delay":
- NaN / ±inf / negative -> Duration::ZERO
- absurdly large (would overflow) -> clamped to ~100 years (MAX_DELAY_SECS)
Tests (fail on old — panic = failure):
- delay_negative_seconds_does_not_panic
- delay_nan_seconds_does_not_panic
- delay_infinite_seconds_does_not_panic
- wait_for_trigger_negative_timeout_does_not_panic
- safe_duration_saturates_hostile_values (incl. overflow clamp)
Co-Authored-By: claude-flow <ruv@ruv.net>
* docs(homecore-automation): record HC-SEC-01/02 security review (CHANGELOG + ADR-129 §8a)
Document the two DoS findings (template unbounded-expansion HC-SEC-01,
delay panic-on-config HC-SEC-02) and the dimensions probed clean
(condition fail-closed, bounded run-modes, sandboxed read-only templates).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -29,8 +29,10 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1"
|
||||
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1)
|
||||
minijinja = { version = "2", features = ["json", "loader"] }
|
||||
# MiniJinja — HA-compatible Jinja2 template engine in pure Rust (ADR-129 §2.1).
|
||||
# `fuel` bounds instruction count so a malicious `template:` condition cannot
|
||||
# spin the engine with a nested-loop / huge-repeat DoS (HC-SEC-01).
|
||||
minijinja = { version = "2", features = ["json", "loader", "fuel"] }
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
@@ -70,6 +70,32 @@ impl ExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper bound for a `delay` / `wait_for_trigger` timeout, in seconds
|
||||
/// (~100 years). Caps absurd values so `Duration::from_secs_f64` cannot
|
||||
/// overflow-panic on e.g. `seconds: 1e308`, while still allowing any
|
||||
/// realistic automation delay (HC-SEC-02).
|
||||
const MAX_DELAY_SECS: f64 = 3.15e9;
|
||||
|
||||
/// Convert a user-supplied seconds value into a `Duration` without
|
||||
/// panicking (HC-SEC-02).
|
||||
///
|
||||
/// `Duration::from_secs_f64` **panics** on negative, NaN, infinite, or
|
||||
/// overflowing inputs. Those values are all reachable from a crafted
|
||||
/// automation YAML (`delay: {seconds: -1}`, `.nan`, `.inf`, `1e308`), so a
|
||||
/// single hostile config would crash the running automation task. We
|
||||
/// instead saturate to a safe range — matching Home Assistant's lenient
|
||||
/// treatment of a non-positive delay as "no delay":
|
||||
///
|
||||
/// - non-finite (NaN / ±inf) → `0`
|
||||
/// - negative → `0`
|
||||
/// - above [`MAX_DELAY_SECS`] → clamped to the cap
|
||||
fn safe_duration_from_secs(seconds: f64) -> Duration {
|
||||
if !seconds.is_finite() || seconds <= 0.0 {
|
||||
return Duration::ZERO;
|
||||
}
|
||||
Duration::from_secs_f64(seconds.min(MAX_DELAY_SECS))
|
||||
}
|
||||
|
||||
/// Action configuration. Deserialized from YAML `action:` blocks.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
@@ -154,7 +180,10 @@ impl Action {
|
||||
Ok(result)
|
||||
}
|
||||
Action::Delay { seconds } => {
|
||||
let dur = Duration::from_secs_f64(*seconds);
|
||||
// `safe_duration_from_secs` guards against negative /
|
||||
// NaN / infinite / overflowing values that would
|
||||
// otherwise panic `Duration::from_secs_f64` (HC-SEC-02).
|
||||
let dur = safe_duration_from_secs(*seconds);
|
||||
sleep(dur).await;
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
@@ -172,7 +201,8 @@ impl Action {
|
||||
// P1 stub — just sleeps for the timeout duration if specified.
|
||||
// Full trigger subscription lands in P2.
|
||||
if let Some(secs) = timeout_seconds {
|
||||
sleep(Duration::from_secs_f64(*secs)).await;
|
||||
// Same non-panicking guard as `Delay` (HC-SEC-02).
|
||||
sleep(safe_duration_from_secs(*secs)).await;
|
||||
}
|
||||
Ok(serde_json::Value::Null)
|
||||
}
|
||||
@@ -243,6 +273,68 @@ mod tests {
|
||||
assert!(result.is_null());
|
||||
}
|
||||
|
||||
// ── HC-SEC-02: a crafted delay must not panic the run task ─────────
|
||||
//
|
||||
// `Duration::from_secs_f64` panics on negative / NaN / infinite /
|
||||
// overflowing inputs, all reachable from a YAML `delay:` value. On the
|
||||
// pre-fix code each of these aborts the spawned automation task with a
|
||||
// panic; the guard saturates to a safe Duration instead. These tests
|
||||
// fail on old (panic = test failure).
|
||||
#[tokio::test]
|
||||
async fn delay_negative_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: -1.0 }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "negative delay must be treated as 0, not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_nan_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: f64::NAN }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "NaN delay must be treated as 0, not panic");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delay_infinite_seconds_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::Delay { seconds: f64::INFINITY }.execute(&mut ctx).await;
|
||||
assert!(result.is_ok(), "infinite delay must saturate to 0, not panic");
|
||||
}
|
||||
|
||||
// Note: the overflow case (1e300) is covered by the synchronous
|
||||
// `safe_duration_saturates_hostile_values` unit test below — executing
|
||||
// `Action::Delay { seconds: 1e300 }` would genuinely sleep for the
|
||||
// clamped (~100-year) duration, so we assert the conversion directly
|
||||
// rather than through `execute`.
|
||||
|
||||
#[tokio::test]
|
||||
async fn wait_for_trigger_negative_timeout_does_not_panic() {
|
||||
let hc = HomeCore::new();
|
||||
let mut ctx = ExecutionContext::new(hc, "auto");
|
||||
let result = Action::WaitForTrigger { timeout_seconds: Some(-5.0) }
|
||||
.execute(&mut ctx)
|
||||
.await;
|
||||
assert!(result.is_ok(), "negative wait timeout must not panic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_duration_saturates_hostile_values() {
|
||||
assert_eq!(safe_duration_from_secs(-1.0), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::NAN), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::INFINITY), Duration::ZERO);
|
||||
assert_eq!(safe_duration_from_secs(f64::NEG_INFINITY), Duration::ZERO);
|
||||
// legitimate value preserved
|
||||
assert_eq!(safe_duration_from_secs(2.5), Duration::from_secs_f64(2.5));
|
||||
// huge value clamped to the cap, not overflow-panicked
|
||||
assert_eq!(
|
||||
safe_duration_from_secs(1e300),
|
||||
Duration::from_secs_f64(MAX_DELAY_SECS)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_call_unregistered_returns_error() {
|
||||
let hc = HomeCore::new();
|
||||
|
||||
@@ -13,6 +13,26 @@ use homecore::{EntityId, StateMachine};
|
||||
|
||||
use crate::error::AutomationError;
|
||||
|
||||
/// Instruction budget for a single template render (HC-SEC-01).
|
||||
///
|
||||
/// Templates come from user automation config; without a bound a single
|
||||
/// `template:` condition like
|
||||
/// `{% for i in range(10000) %}{% for j in range(10000) %}x{% endfor %}{% endfor %}`
|
||||
/// renders a multi-gigabyte string and pins a CPU for tens of seconds —
|
||||
/// a memory/CPU denial-of-service (the bfld-class "unbounded expansion").
|
||||
/// MiniJinja's `fuel` feature charges ~1 unit per VM instruction; a
|
||||
/// nested loop burns one unit per iteration, so the budget caps total
|
||||
/// work regardless of how the loops are nested. 1,000,000 instructions is
|
||||
/// far more than any legitimate HA template needs (a typical condition is
|
||||
/// a few dozen) while killing the attack in well under a second.
|
||||
const TEMPLATE_FUEL: u64 = 1_000_000;
|
||||
|
||||
/// Hard cap on the source length of a template (HC-SEC-01, defense in
|
||||
/// depth). A legitimate HA `value_template` is a one-liner; anything past
|
||||
/// 64 KiB is rejected before compilation so a pathological source string
|
||||
/// can neither be compiled nor emitted verbatim.
|
||||
const MAX_TEMPLATE_SOURCE_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// MiniJinja environment pre-loaded with HA-compatible globals.
|
||||
///
|
||||
/// Constructed once per `AutomationEngine` and shared via `Arc`. The
|
||||
@@ -27,6 +47,10 @@ impl TemplateEnvironment {
|
||||
pub fn new(states: Arc<StateMachine>) -> Self {
|
||||
let mut env = Environment::new();
|
||||
|
||||
// Bound per-render work so a hostile `template:` condition cannot
|
||||
// DoS the engine via nested loops / huge repeats (HC-SEC-01).
|
||||
env.set_fuel(Some(TEMPLATE_FUEL));
|
||||
|
||||
// --- states(entity_id) ---
|
||||
// Returns the current state string of an entity, or "unavailable".
|
||||
let states_sm = Arc::clone(&states);
|
||||
@@ -88,7 +112,21 @@ impl TemplateEnvironment {
|
||||
}
|
||||
|
||||
/// Render a template string and return the string output.
|
||||
///
|
||||
/// Renders are bounded by an instruction budget ([`TEMPLATE_FUEL`]) and
|
||||
/// a source-length cap ([`MAX_TEMPLATE_SOURCE_BYTES`]); a malicious
|
||||
/// template that exhausts the budget returns a [`AutomationError::TemplateRender`]
|
||||
/// error rather than running unbounded (HC-SEC-01).
|
||||
pub fn render(&self, template_str: &str) -> Result<String, AutomationError> {
|
||||
// Reject pathologically large sources before compilation (defense
|
||||
// in depth — fuel already bounds runtime work).
|
||||
if template_str.len() > MAX_TEMPLATE_SOURCE_BYTES {
|
||||
return Err(AutomationError::TemplateRender(format!(
|
||||
"template source too large: {} bytes (max {})",
|
||||
template_str.len(),
|
||||
MAX_TEMPLATE_SOURCE_BYTES
|
||||
)));
|
||||
}
|
||||
// Wrap bare expressions like `{{ states('light.kitchen') }}`
|
||||
// in a minimal template wrapper.
|
||||
let tmpl = self
|
||||
@@ -191,4 +229,68 @@ mod tests {
|
||||
assert!(!env.render_bool("0").unwrap());
|
||||
assert!(!env.render_bool("off").unwrap());
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: template DoS is bounded by fuel ─────────────────────
|
||||
//
|
||||
// A `template:` condition is user config. Before the fuel bound a
|
||||
// nested-loop template rendered a multi-GB string over ~11 s (proven
|
||||
// empirically). With fuel enabled it must fail FAST with an error
|
||||
// instead of expanding unboundedly. On the pre-fix code (no `fuel`
|
||||
// feature / `set_fuel`) this render succeeds and burns CPU+RAM, so
|
||||
// this test fails on old (it would `Ok` and exceed the time bound).
|
||||
#[test]
|
||||
fn nested_loop_template_is_bounded_not_unbounded_dos() {
|
||||
use std::time::Instant;
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// 5000 * 5000 = 25M iterations on the old engine (~100 MB, ~11 s).
|
||||
let malicious =
|
||||
"{% for i in range(5000) %}{% for j in range(5000) %}xxxx{% endfor %}{% endfor %}";
|
||||
let start = Instant::now();
|
||||
let result = env.render(malicious);
|
||||
let elapsed = start.elapsed();
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"malicious nested-loop template must be rejected (ran out of fuel), got Ok"
|
||||
);
|
||||
assert!(
|
||||
elapsed.as_secs() < 3,
|
||||
"bounded render must fail fast; took {elapsed:?} (unbounded DoS on old engine)"
|
||||
);
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: a single huge repeat is also bounded ────────────────
|
||||
#[test]
|
||||
fn single_huge_repeat_template_is_bounded() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// range() caps at 10k per call, but multiplied bodies still need a
|
||||
// bound; drive enough instructions to exhaust fuel via deep nesting.
|
||||
let malicious = "{% for a in range(9999) %}{% for b in range(9999) %}\
|
||||
{% for c in range(9999) %}z{% endfor %}{% endfor %}{% endfor %}";
|
||||
let result = env.render(malicious);
|
||||
assert!(result.is_err(), "deeply nested loops must exhaust fuel and error");
|
||||
}
|
||||
|
||||
// ── HC-SEC-01: oversized template source is rejected pre-compile ───
|
||||
#[test]
|
||||
fn oversized_template_source_is_rejected() {
|
||||
let sm = Arc::new(StateMachine::new());
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// 128 KiB of literal text — exceeds MAX_TEMPLATE_SOURCE_BYTES.
|
||||
let big = "x".repeat(128 * 1024);
|
||||
let result = env.render(&big);
|
||||
assert!(result.is_err(), "oversized template source must be rejected");
|
||||
}
|
||||
|
||||
// ── A legitimate small template still renders fine within budget ───
|
||||
#[test]
|
||||
fn legitimate_template_still_renders_within_fuel() {
|
||||
let sm = sm_with("light.kitchen", "on", serde_json::json!({}));
|
||||
let env = TemplateEnvironment::new(sm);
|
||||
// A normal HA condition with a modest loop — well under budget.
|
||||
let ok = "{% for i in range(50) %}{{ states('light.kitchen') }}{% endfor %}";
|
||||
let out = env.render(ok).expect("legitimate template must render");
|
||||
assert!(out.contains("on"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user