diff --git a/crates/nexum-runtime/src/engine_config.rs b/crates/nexum-runtime/src/engine_config.rs index a5046b75..c4e796b8 100644 --- a/crates/nexum-runtime/src/engine_config.rs +++ b/crates/nexum-runtime/src/engine_config.rs @@ -245,6 +245,12 @@ const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60); /// guest heap that has to buffer it. const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024; +/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough +/// for typical read responses (receipts, log batches, contract state), +/// while preventing a misbehaving or adversarial node from filling the +/// guest heap via a single large reply. +const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024; + /// Ceiling for the `[limits.http]` millisecond knobs (24 h). const HTTP_LIMIT_MS_MAX: u64 = 86_400_000; @@ -292,6 +298,9 @@ fn clamp_http_ms(ms: u64) -> Duration { /// total_deadline_ms = 60_000 /// response_body_max_bytes = 16_777_216 /// +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// /// [limits.logs] /// bytes_per_run = 262_144 /// runs_retained = 16 @@ -309,6 +318,9 @@ pub struct ModuleLimits { /// Outbound wasi:http limits. #[serde(default)] pub http: HttpLimitsSection, + /// Chain JSON-RPC response size limits. + #[serde(default)] + pub chain: ChainLimitsSection, /// Per-run log retention limits. #[serde(default)] pub logs: LogLimitsSection, @@ -334,6 +346,17 @@ impl ModuleLimits { self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT) } + /// Resolved chain response size cap (override or default). A + /// degenerate `0` saturates to 1 byte, matching the `logs` / + /// `poison` sections' zero handling, so resolution never yields a + /// cap that rejects even an empty body. + pub fn chain_response_max_bytes(&self) -> usize { + self.chain + .response_body_max_bytes + .map(|b| (b.max(1)) as usize) + .unwrap_or(DEFAULT_CHAIN_RESPONSE_MAX_BYTES) + } + /// Resolved outbound HTTP limits (overrides or defaults). pub fn http(&self) -> OutboundHttpLimits { OutboundHttpLimits { @@ -447,6 +470,20 @@ pub struct HttpLimitsSection { pub response_body_max_bytes: Option, } +/// `[limits.chain]` chain JSON-RPC response size limit. Optional; +/// omitted values resolve to the built-in 1 MiB default. +/// +/// ```toml +/// [limits.chain] +/// response_body_max_bytes = 1_048_576 +/// ``` +#[derive(Debug, Default, Deserialize)] +pub struct ChainLimitsSection { + /// Cap on one chain JSON-RPC response body, in bytes. Named for + /// symmetry with `[limits.http].response_body_max_bytes`. + pub response_body_max_bytes: Option, +} + /// Resolved outbound HTTP limits the wasi:http gate enforces per /// request. Built by [`ModuleLimits::http`]. #[derive(Debug, Clone, Copy)] @@ -787,6 +824,42 @@ response_body_max_bytes = 1_024 assert_eq!(http.between_bytes_timeout_max, Duration::from_secs(30)); } + #[test] + fn chain_limits_default_when_absent() { + assert_eq!( + ModuleLimits::default().chain_response_max_bytes(), + 1024 * 1024, + ); + } + + #[test] + fn chain_limits_parse_with_override() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 2_048 +"#, + ) + .expect("limits.chain parses"); + assert_eq!(cfg.limits.chain_response_max_bytes(), 2_048); + } + + #[test] + fn chain_limits_saturate_degenerate_zero() { + let cfg: EngineConfig = toml::from_str( + r#" +[limits.chain] +response_body_max_bytes = 0 +"#, + ) + .expect("limits.chain parses"); + assert_eq!( + cfg.limits.chain_response_max_bytes(), + 1, + "zero saturates to 1 so resolution never rejects an empty body", + ); + } + #[test] fn http_limits_saturate_degenerate_millisecond_values() { // Zero would fail every request instantly; u64::MAX would diff --git a/crates/nexum-runtime/src/host/impls/chain.rs b/crates/nexum-runtime/src/host/impls/chain.rs index 3fb67286..f9afad2a 100644 --- a/crates/nexum-runtime/src/host/impls/chain.rs +++ b/crates/nexum-runtime/src/host/impls/chain.rs @@ -24,6 +24,40 @@ fn resolve_method(method: &str) -> Result { }) } +/// Return an error if `body` exceeds `cap` bytes. The check is applied +/// host-side before the response is copied into the guest, so an +/// oversized node response cannot saturate the guest heap. +fn check_response_cap( + body: &str, + cap: usize, + chain_id: u64, + method: &str, +) -> Result<(), ChainError> { + if body.len() > cap { + tracing::warn!( + chain_id, + method, + body_bytes = body.len(), + cap_bytes = cap, + "chain response exceeds size cap - rejecting before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method.to_owned(), + ) + .increment(1); + return Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "chain response ({} bytes) exceeds the configured cap ({} bytes)", + body.len(), + cap, + )), + )); + } + Ok(()) +} + impl nexum::host::chain::Host for HostState { async fn request( &mut self, @@ -57,7 +91,11 @@ impl nexum::host::chain::Host for HostState { .chain .request(chain, method, params) .await - .map_err(ChainError::from); + .map_err(ChainError::from) + .and_then(|body| { + check_response_cap(&body, self.chain_response_max_bytes, chain_id, name)?; + Ok(body) + }); tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done"); let outcome = if result.is_ok() { "ok" } else { "err" }; metrics::counter!( @@ -90,10 +128,44 @@ impl nexum::host::chain::Host for HostState { // per-chain timeout, so the worst-case blocking time for a batch // is N x request_timeout_secs. tracing::debug!(chain_id, count = requests.len(), "chain::request-batch"); + let cap = self.chain_response_max_bytes; let mut out = Vec::with_capacity(requests.len()); + // The per-entry cap (inside `request`) bounds each body; this + // running total bounds the aggregate `Vec` lowered into + // guest memory in one go, so a wide batch of individually-legal + // bodies cannot saturate the guest heap either - the exact failure + // the guidance in #154 (block-range chunking via request-batch) + // would otherwise re-introduce. + let mut total_bytes: usize = 0; for req in requests { + let method = req.method.clone(); match nexum::host::chain::Host::request(self, chain_id, req.method, req.params).await { - Ok(s) => out.push(nexum::host::chain::RpcResult::Ok(s)), + Ok(s) => { + total_bytes = total_bytes.saturating_add(s.len()); + if total_bytes > cap { + tracing::warn!( + chain_id, + method = %method, + total_bytes, + cap_bytes = cap, + "chain batch aggregate exceeds size cap - rejecting entry before guest copy" + ); + metrics::counter!( + "shepherd_chain_response_capped_total", + "chain_id" => chain_id.to_string(), + "method" => method, + ) + .increment(1); + out.push(nexum::host::chain::RpcResult::Err(ChainError::Fault( + crate::bindings::nexum::host::types::Fault::InvalidInput(format!( + "batch aggregate ({total_bytes} bytes) exceeds the configured \ + cap ({cap} bytes)", + )), + ))); + } else { + out.push(nexum::host::chain::RpcResult::Ok(s)); + } + } Err(e) => out.push(nexum::host::chain::RpcResult::Err(e)), } } @@ -283,4 +355,26 @@ mod tests { )); assert!(resolved[2].is_ok()); } + + // ── response size cap tests (#154) ── + + #[test] + fn response_at_cap_is_accepted() { + let body = "x".repeat(10); + assert!( + check_response_cap(&body, 10, 1, "eth_call").is_ok(), + "body exactly at cap should pass" + ); + } + + #[test] + fn response_over_cap_returns_invalid_input() { + let body = "x".repeat(11); + let err = + check_response_cap(&body, 10, 1, "eth_call").expect_err("over-cap body should fail"); + assert!( + matches!(err, ChainError::Fault(Fault::InvalidInput(_))), + "expected InvalidInput fault, got {err:?}" + ); + } } diff --git a/crates/nexum-runtime/src/host/state.rs b/crates/nexum-runtime/src/host/state.rs index df3e2801..d299ccd0 100644 --- a/crates/nexum-runtime/src/host/state.rs +++ b/crates/nexum-runtime/src/host/state.rs @@ -45,6 +45,9 @@ pub struct HostState { pub ext: T::Ext, /// `chain` backend - per-chain alloy `DynProvider` pool. pub chain: T::Chain, + /// Host-enforced cap on a single chain JSON-RPC response body. + /// Responses larger than this are rejected before they reach the guest. + pub chain_response_max_bytes: usize, /// `local-store` backend - per-module handle with pre-computed /// keccak256 namespace prefix. pub store: Handle, diff --git a/crates/nexum-runtime/src/supervisor.rs b/crates/nexum-runtime/src/supervisor.rs index b74bb102..6543444b 100644 --- a/crates/nexum-runtime/src/supervisor.rs +++ b/crates/nexum-runtime/src/supervisor.rs @@ -190,6 +190,9 @@ struct LoadedModule { /// Cached for restart: outbound HTTP limits baked into the /// `HostState` we rebuild on each re-instantiation. http_limits: OutboundHttpLimits, + /// Cached for restart: chain response size cap baked into the + /// `HostState` we rebuild on each re-instantiation. + chain_response_max_bytes: usize, /// Set to `false` when `on_event` traps. Dead modules are /// excluded from dispatch until `next_attempt` is in the past. /// Modules whose `init` failed have `alive = false` @@ -383,6 +386,7 @@ impl Supervisor { messaging_topics: Vec, memory_limit: usize, fuel: u64, + chain_response_max_bytes: usize, clocks: Option<&WasiClockOverride>, pool_router: PoolRouter, ) -> Result> { @@ -437,6 +441,7 @@ impl Supervisor { log_router: router, ext: components.ext.clone(), chain: components.chain.clone(), + chain_response_max_bytes, store: module_store, pool_router, }, @@ -511,6 +516,7 @@ impl Supervisor { Vec::new(), limits_cfg.memory(), limits_cfg.fuel(), + limits_cfg.chain_response_max_bytes(), clocks, pool_router, )?; @@ -584,6 +590,7 @@ impl Supervisor { init_config: config, http_allowlist: loaded_manifest.http_allowlist.clone(), http_limits: limits_cfg.http(), + chain_response_max_bytes: limits_cfg.chain_response_max_bytes(), failure_timestamps: std::collections::VecDeque::new(), poisoned: false, }) @@ -677,6 +684,7 @@ impl Supervisor { entry.messaging_topics.clone(), limits_cfg.memory(), limits_cfg.fuel(), + limits_cfg.chain_response_max_bytes(), clocks, PoolRouter::empty(), )?; @@ -856,6 +864,7 @@ impl Supervisor { Vec::new(), module.memory_limit, module.fuel_per_event, + module.chain_response_max_bytes, clocks.as_ref(), pool_router, )?; diff --git a/crates/nexum-runtime/src/test_utils/harness.rs b/crates/nexum-runtime/src/test_utils/harness.rs index 07a2efa2..31f2a8d7 100644 --- a/crates/nexum-runtime/src/test_utils/harness.rs +++ b/crates/nexum-runtime/src/test_utils/harness.rs @@ -714,6 +714,94 @@ chain_id = 1 ); } + /// `[limits.chain].response_body_max_bytes` is enforced on the real + /// `chain::request` path, end to end: the configured cap reaches + /// `HostState`, an over-cap node response is rejected before the guest + /// copy, and the module observes the typed `invalid-input` fault + /// instead of the body. Guards the wiring the unit tests on + /// `check_response_cap` cannot see (issue #154). + #[tokio::test] + async fn harness_enforces_chain_response_cap_on_the_request_path() { + use crate::engine_config::ChainLimitsSection; + use crate::host::component::ChainMethod; + + let Some(wasm) = module_wasm_or_skip("price_alert.wasm") else { + return; + }; + + // A syntactically valid oracle answer, ~330 bytes - far over the + // 16-byte cap below, so the module must never see it. + fn word(v: u128) -> String { + format!("{v:064x}") + } + let result = format!( + "\"0x{}{}{}{}{}\"", + word(1), + word(300_000_000_000), + word(0), + word(0), + word(1), + ); + + let builder = TestRuntime::builder(wasm) + .manifest_inline( + r#" +[module] +name = "price-alert" + +[capabilities] +required = ["logging", "chain"] + +[[subscription]] +kind = "block" +chain_id = 1 + +[config] +oracle_address = "0x694AA1769357215DE4FAC081bf1f309aDC325306" +decimals = "8" +threshold = "2500.00" +direction = "above" +"#, + ) + .limits(ModuleLimits { + chain: ChainLimitsSection { + response_body_max_bytes: Some(16), + }, + ..Default::default() + }); + builder.chain().on_method(ChainMethod::EthCall, result); + + let mut rt = builder + .launch() + .await + .expect("launch price-alert with a 16-byte chain response cap"); + + rt.push_block(header_numbered(19_000_000)); + let record = rt + .wait_for_log("price-alert", "exceeds the configured cap") + .await + .expect("the module logs the guest-visible cap fault"); + assert!( + record.message.contains("eth_call failed"), + "the cap surfaces as a failed eth_call, got: {}", + record.message, + ); + + // The module never saw the oracle answer, so it must not trigger. + let runs = rt.logs().list_runs("price-alert"); + let triggered = runs.into_iter().any(|meta| { + rt.logs() + .read(&meta.run, 0) + .records + .iter() + .any(|r| r.message.contains("TRIGGERED")) + }); + assert!(!triggered, "an over-cap response must never reach classify"); + + rt.shutdown(); + rt.wait().await.expect("clean shutdown"); + } + /// A dropped block stream is not the end of dispatch: the event loop's /// reconnect task reopens the subscription after backoff and the /// re-armed mock resumes delivery, matching a real provider that comes diff --git a/engine.example.toml b/engine.example.toml index 39624528..a3f883bd 100644 --- a/engine.example.toml +++ b/engine.example.toml @@ -52,6 +52,15 @@ log_level = "info" # total_deadline_ms = 60_000 # response_body_max_bytes = 16_777_216 +# Chain JSON-RPC response size cap, applied host-side before a response +# is copied into guest memory. A single response - and the aggregate of +# one request-batch - beyond the cap fails with a typed invalid-input +# fault instead of inflating the module toward its memory_bytes limit. +# Modules that need more data per call should paginate (block-range +# chunking, request-batch). 0 saturates to 1. Default 1 MiB. +[limits.chain] +# response_body_max_bytes = 1_048_576 + # Per-run log retention. bytes_per_run is the byte budget for one run's # in-memory ring (oldest records evict first, the newest is never evicted # to nothing); runs_retained is how many past runs are kept per module.