Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion crates/rustmotion-core/src/engine/renderer/assets.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use dashmap::DashMap;

Expand Down Expand Up @@ -35,6 +36,40 @@ pub fn gif_cache() -> &'static GifCacheMap {
GIF_CACHE.get_or_init(|| Arc::new(DashMap::new()))
}

// ─── Shared HTTP agent ──────────────────────────────────────────────────────

/// The [`ureq::Agent`] every outbound HTTP call in this crate must go
/// through — the icon fetch below, and the remote `include` fetch in the
/// `rustmotion` crate (`crates/rustmotion/src/include.rs`), which imports
/// [`http_agent`] rather than building its own.
///
/// `ureq::get(...)`, the free function used before this fix, always resolves
/// to an *unconfigured* default agent. In ureq 3.x every field of
/// `Timeouts` defaults to `None` except `await_100` (`config.rs`'s `impl
/// Default for Timeouts`), so a host that accepts the TCP connection and
/// then never answers — or trickles one byte a minute — hangs the calling
/// thread forever; ureq's 10 MB body cap bounds bytes, not time. On the icon
/// path that thread can be a render worker with nobody at the keyboard to
/// notice.
///
/// `Config::builder()` starts from `Config::default()`, which already
/// resolves a proxy from `HTTPS_PROXY`/`https_proxy`/`HTTP_PROXY`/
/// `http_proxy`/`ALL_PROXY` via `Proxy::try_from_env()` — the same audit
/// separately found every network call here ignoring a configured egress
/// proxy, and routing through the builder rather than hand-building a
/// `Config` fixes that as a side effect, not a separate change.
static HTTP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();

pub fn http_agent() -> &'static ureq::Agent {
HTTP_AGENT.get_or_init(|| {
let config = ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(20)))
.timeout_connect(Some(Duration::from_secs(5)))
.build();
ureq::Agent::new_with_config(config)
})
}

// ─── Icon fetching ──────────────────────────────────────────────────────────

/// How much larger than the *target* (layout) size icons are rasterized, so
Expand Down Expand Up @@ -138,7 +173,8 @@ pub fn fetch_icon_svg_in(
"https://api.iconify.design/{}/{}.svg?color=%23{}&width={}&height={}",
prefix, name, hex_color, width, height
);
let response = ureq::get(&url)
let response = http_agent()
.get(&url)
.call()
.map_err(|e| RustmotionError::IconFetch {
icon: icon.to_string(),
Expand Down
100 changes: 100 additions & 0 deletions crates/rustmotion-core/tests/audit_ws_h.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Regression tests for the workstream H (untrusted scenario ingestion)
//! audit findings that live in `rustmotion-core`.

use rustmotion_core::engine::renderer::{extract_video_frame, http_agent};

// ---- no timeout on any HTTP call — a bare `ureq::get` always uses
// the default, untimed agent, so a stalled host hangs a render forever ----

#[test]
fn shared_http_agent_has_finite_global_and_connect_timeouts() {
let timeouts = http_agent().config().timeouts();
assert!(
timeouts.global.is_some(),
"ureq 3.x's default Timeouts::global is None — the shared agent must override it \
so a stalled host cannot hang a render forever"
);
assert!(
timeouts.connect.is_some(),
"a hung TCP handshake must not hang forever either"
);
}

// ---- a scenario's `video.src` reaches `ffmpeg -i` verbatim, with no
// protocol allowlist — a remote-looking src turns into an SSRF primitive ----

#[test]
fn extract_video_frame_rejects_a_remote_src_before_it_ever_reaches_ffmpeg() {
let result = extract_video_frame(
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
0.0,
16,
16,
);
assert!(
result.is_err(),
"a scheme-prefixed src must be rejected outright, not hand ffmpeg a URL to dereference"
);
}

#[test]
fn extract_video_frame_does_not_reject_a_plain_local_path() {
let result = extract_video_frame("/no/such/file/on/disk.mp4", 0.0, 16, 16);
let err = result.expect_err("a missing local file is still an error");
assert!(
!err.to_string().contains("does not fetch video"),
"a plain local path must fail on ffmpeg/the missing file, not on the scheme check: {err}"
);
}

// ---- `for-each` expansion has a depth ceiling but no node budget —
// nesting is multiplicative, so a handful of small arrays nested a few
// levels deep can declare a product in the millions ----

#[test]
fn for_each_node_budget_rejects_a_declared_product_that_exceeds_the_cap() {
fn items(n: usize) -> serde_json::Value {
serde_json::Value::Array(
(0..n)
.map(|i| serde_json::json!({ "v": i }))
.collect::<Vec<_>>(),
)
}

// Three levels of 200 elements nested directly in each other's
// `template.children`: a declared product of 200^3 = 8,000,000 nodes,
// comfortably past a low-millions cap. The array literals themselves
// (200 small JSON objects, three times) are cheap to build — the
// assertion is that expansion refuses the *product*, not that it
// finishes computing it.
let mut doc = serde_json::json!({
"video": { "width": 100, "height": 100 },
"scenes": [{
"duration": 1.0,
"children": [{
"for-each": items(200),
"template": {
"type": "card",
"children": [{
"for-each": items(200),
"template": {
"type": "card",
"children": [{
"for-each": items(200),
"template": { "type": "text", "content": "$v" }
}]
}
}]
}
}]
}]
});

let err = rustmotion_core::expand::expand_directives(&mut doc, "test.json")
.expect_err("a declared product this far past the cap must be rejected");
let msg = err.to_string();
assert!(
msg.contains("budget"),
"error should name the node-budget ceiling it exceeded: {msg}"
);
}
Loading