mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +00:00
5bc3b634b7
* 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>
297 lines
12 KiB
Rust
297 lines
12 KiB
Rust
//! MiniJinja-based template environment with HA-compatible globals.
|
|
//!
|
|
//! ADR-129 §2.1 — P1 ships four HA globals: `states()`, `state_attr()`,
|
|
//! `is_state()`, `now()`. The `utcnow()`, `as_timestamp()`, `distance()`,
|
|
//! and `iif()` globals plus custom filters land in P2.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use chrono::Utc;
|
|
use minijinja::{Environment, Value};
|
|
|
|
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
|
|
/// globals close over an `Arc<StateMachine>` so every template render
|
|
/// sees the live current state.
|
|
pub struct TemplateEnvironment {
|
|
env: Environment<'static>,
|
|
}
|
|
|
|
impl TemplateEnvironment {
|
|
/// Build a new environment backed by the given state machine.
|
|
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);
|
|
env.add_global(
|
|
"states",
|
|
Value::from_function(move |entity_id: String| -> String {
|
|
EntityId::parse(&entity_id)
|
|
.ok()
|
|
.and_then(|eid| states_sm.get(&eid))
|
|
.map(|s| s.state.clone())
|
|
.unwrap_or_else(|| "unavailable".into())
|
|
}),
|
|
);
|
|
|
|
// --- state_attr(entity_id, attribute) ---
|
|
// Returns an attribute value as a JSON string, or empty string.
|
|
let attr_sm = Arc::clone(&states);
|
|
env.add_global(
|
|
"state_attr",
|
|
Value::from_function(move |entity_id: String, attr: String| -> String {
|
|
EntityId::parse(&entity_id)
|
|
.ok()
|
|
.and_then(|eid| attr_sm.get(&eid))
|
|
.and_then(|s| s.attributes.get(&attr).cloned())
|
|
.map(|v| match v {
|
|
serde_json::Value::String(s) => s,
|
|
other => other.to_string(),
|
|
})
|
|
.unwrap_or_default()
|
|
}),
|
|
);
|
|
|
|
// --- is_state(entity_id, state) ---
|
|
// Returns true if the entity's current state matches the given value.
|
|
let is_state_sm = Arc::clone(&states);
|
|
env.add_global(
|
|
"is_state",
|
|
Value::from_function(move |entity_id: String, expected: String| -> bool {
|
|
EntityId::parse(&entity_id)
|
|
.ok()
|
|
.and_then(|eid| is_state_sm.get(&eid))
|
|
.map(|s| s.state == expected)
|
|
.unwrap_or(false)
|
|
}),
|
|
);
|
|
|
|
// --- now() ---
|
|
// Returns the current UTC datetime as an ISO 8601 string.
|
|
// HA returns a Python datetime; MiniJinja returns a string which
|
|
// templates can further format with the `strftime` filter.
|
|
env.add_global(
|
|
"now",
|
|
Value::from_function(|| -> String {
|
|
Utc::now().format("%Y-%m-%dT%H:%M:%S%.6f+00:00").to_string()
|
|
}),
|
|
);
|
|
|
|
Self { env }
|
|
}
|
|
|
|
/// 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
|
|
.env
|
|
.template_from_str(template_str)
|
|
.map_err(|e| AutomationError::TemplateRender(e.to_string()))?;
|
|
tmpl.render(())
|
|
.map_err(|e| AutomationError::TemplateRender(e.to_string()))
|
|
}
|
|
|
|
/// Render a template and interpret the output as a boolean.
|
|
/// "true", "1", "yes", "on" → true. Everything else → false.
|
|
pub fn render_bool(&self, template_str: &str) -> Result<bool, AutomationError> {
|
|
let raw = self.render(template_str)?;
|
|
let v = raw.trim().to_ascii_lowercase();
|
|
Ok(matches!(v.as_str(), "true" | "1" | "yes" | "on"))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use homecore::{Context, EntityId, StateMachine};
|
|
use std::sync::Arc;
|
|
|
|
fn sm_with(entity_id: &str, state: &str, attrs: serde_json::Value) -> Arc<StateMachine> {
|
|
let sm = Arc::new(StateMachine::new());
|
|
sm.set(EntityId::parse(entity_id).unwrap(), state, attrs, Context::new());
|
|
sm
|
|
}
|
|
|
|
#[test]
|
|
fn states_global_returns_current_state() {
|
|
let sm = sm_with("light.kitchen", "on", serde_json::json!({}));
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ states('light.kitchen') }}").unwrap();
|
|
assert_eq!(out.trim(), "on");
|
|
}
|
|
|
|
#[test]
|
|
fn states_global_unknown_entity_returns_unavailable() {
|
|
let sm = Arc::new(StateMachine::new());
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ states('sensor.unknown') }}").unwrap();
|
|
assert_eq!(out.trim(), "unavailable");
|
|
}
|
|
|
|
#[test]
|
|
fn state_attr_returns_attribute_value() {
|
|
let sm = sm_with(
|
|
"light.kitchen",
|
|
"on",
|
|
serde_json::json!({"brightness": 200}),
|
|
);
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ state_attr('light.kitchen', 'brightness') }}").unwrap();
|
|
assert_eq!(out.trim(), "200");
|
|
}
|
|
|
|
#[test]
|
|
fn is_state_global_true_when_matches() {
|
|
let sm = sm_with("switch.fan", "on", serde_json::json!({}));
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
|
assert_eq!(out.trim(), "true");
|
|
}
|
|
|
|
#[test]
|
|
fn is_state_global_false_when_no_match() {
|
|
let sm = sm_with("switch.fan", "off", serde_json::json!({}));
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ is_state('switch.fan', 'on') }}").unwrap();
|
|
assert_eq!(out.trim(), "false");
|
|
}
|
|
|
|
#[test]
|
|
fn now_global_returns_timestamp_string() {
|
|
let sm = Arc::new(StateMachine::new());
|
|
let env = TemplateEnvironment::new(sm);
|
|
let out = env.render("{{ now() }}").unwrap();
|
|
// Should be an ISO 8601 datetime string containing 'T'
|
|
assert!(out.contains('T'), "now() returned: {out}");
|
|
}
|
|
|
|
#[test]
|
|
fn render_bool_true_values() {
|
|
let sm = Arc::new(StateMachine::new());
|
|
let env = TemplateEnvironment::new(sm);
|
|
for tmpl in &["true", "1", "yes", "on"] {
|
|
let result = env.render_bool(tmpl).unwrap();
|
|
assert!(result, "expected true for: {tmpl}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn render_bool_false_for_other() {
|
|
let sm = Arc::new(StateMachine::new());
|
|
let env = TemplateEnvironment::new(sm);
|
|
assert!(!env.render_bool("false").unwrap());
|
|
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"));
|
|
}
|
|
}
|