Skip to content

Commit 4acf3ed

Browse files
lgahdlclaude
andcommitted
feat(chain): cap JSON-RPC response size before lowering into the guest (#154)
Adds a configurable `[limits.chain] response_max_bytes` knob (default 1 MiB) that is enforced host-side in `chain::request()` before the response string is copied into the guest heap. Oversized responses are rejected with an `InvalidInput` fault, a `WARN` log, and a dedicated `shepherd_chain_response_capped_total` metric. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TfSsJPYDQ1GQGbnSVFxATx
1 parent 38bea45 commit 4acf3ed

4 files changed

Lines changed: 104 additions & 1 deletion

File tree

crates/nexum-runtime/src/engine_config.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,12 @@ const DEFAULT_HTTP_TOTAL_DEADLINE: Duration = Duration::from_secs(60);
203203
/// guest heap that has to buffer it.
204204
const DEFAULT_HTTP_RESPONSE_BODY_MAX: u64 = 16 * 1024 * 1024;
205205

206+
/// Default cap on one chain JSON-RPC response body (1 MiB). Large enough
207+
/// for typical read responses (receipts, log batches, contract state),
208+
/// while preventing a misbehaving or adversarial node from filling the
209+
/// guest heap via a single large reply.
210+
const DEFAULT_CHAIN_RESPONSE_MAX_BYTES: usize = 1024 * 1024;
211+
206212
/// Ceiling for the `[limits.http]` millisecond knobs (24 h).
207213
const HTTP_LIMIT_MS_MAX: u64 = 86_400_000;
208214

@@ -245,6 +251,9 @@ fn clamp_http_ms(ms: u64) -> Duration {
245251
/// total_deadline_ms = 60_000
246252
/// response_body_max_bytes = 16_777_216
247253
///
254+
/// [limits.chain]
255+
/// response_max_bytes = 1_048_576
256+
///
248257
/// [limits.logs]
249258
/// bytes_per_run = 262_144
250259
/// runs_retained = 16
@@ -262,6 +271,9 @@ pub struct ModuleLimits {
262271
/// Outbound wasi:http limits.
263272
#[serde(default)]
264273
pub http: HttpLimitsSection,
274+
/// Chain JSON-RPC response size limits.
275+
#[serde(default)]
276+
pub chain: ChainLimitsSection,
265277
/// Per-run log retention limits.
266278
#[serde(default)]
267279
pub logs: LogLimitsSection,
@@ -281,6 +293,13 @@ impl ModuleLimits {
281293
self.memory_bytes.unwrap_or(DEFAULT_MEMORY_LIMIT)
282294
}
283295

296+
/// Resolved chain response size cap (override or default).
297+
pub fn chain_response_max_bytes(&self) -> usize {
298+
self.chain
299+
.response_max_bytes
300+
.unwrap_or(DEFAULT_CHAIN_RESPONSE_MAX_BYTES)
301+
}
302+
284303
/// Resolved outbound HTTP limits (overrides or defaults).
285304
pub fn http(&self) -> OutboundHttpLimits {
286305
OutboundHttpLimits {
@@ -370,6 +389,19 @@ pub struct HttpLimitsSection {
370389
pub response_body_max_bytes: Option<u64>,
371390
}
372391

392+
/// `[limits.chain]` chain JSON-RPC response size limit. Optional;
393+
/// omitted values resolve to the built-in 1 MiB default.
394+
///
395+
/// ```toml
396+
/// [limits.chain]
397+
/// response_max_bytes = 1_048_576
398+
/// ```
399+
#[derive(Debug, Default, Deserialize)]
400+
pub struct ChainLimitsSection {
401+
/// Cap on one chain JSON-RPC response body, in bytes.
402+
pub response_max_bytes: Option<usize>,
403+
}
404+
373405
/// Resolved outbound HTTP limits the wasi:http gate enforces per
374406
/// request. Built by [`ModuleLimits::http`].
375407
#[derive(Debug, Clone, Copy)]

crates/nexum-runtime/src/host/impls/chain.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,40 @@ fn resolve_method(method: &str) -> Result<ChainMethod, ChainError> {
2424
})
2525
}
2626

27+
/// Return an error if `body` exceeds `cap` bytes. The check is applied
28+
/// host-side before the response is copied into the guest, so an
29+
/// oversized node response cannot saturate the guest heap.
30+
fn check_response_cap(
31+
body: &str,
32+
cap: usize,
33+
chain_id: u64,
34+
method: &str,
35+
) -> Result<(), ChainError> {
36+
if body.len() > cap {
37+
tracing::warn!(
38+
chain_id,
39+
method,
40+
body_bytes = body.len(),
41+
cap_bytes = cap,
42+
"chain response exceeds size cap — rejecting before guest copy"
43+
);
44+
metrics::counter!(
45+
"shepherd_chain_response_capped_total",
46+
"chain_id" => chain_id.to_string(),
47+
"method" => method.to_owned(),
48+
)
49+
.increment(1);
50+
return Err(ChainError::Fault(
51+
crate::bindings::nexum::host::types::Fault::InvalidInput(format!(
52+
"chain response ({} bytes) exceeds the configured cap ({} bytes)",
53+
body.len(),
54+
cap,
55+
)),
56+
));
57+
}
58+
Ok(())
59+
}
60+
2761
impl<T: RuntimeTypes> nexum::host::chain::Host for HostState<T> {
2862
async fn request(
2963
&mut self,
@@ -57,7 +91,11 @@ impl<T: RuntimeTypes> nexum::host::chain::Host for HostState<T> {
5791
.chain
5892
.request(chain, method, params)
5993
.await
60-
.map_err(ChainError::from);
94+
.map_err(ChainError::from)
95+
.and_then(|body| {
96+
check_response_cap(&body, self.chain_response_max_bytes, chain_id, name)?;
97+
Ok(body)
98+
});
6199
tracing::trace!(elapsed_ms = ?start.elapsed(), "chain::request done");
62100
let outcome = if result.is_ok() { "ok" } else { "err" };
63101
metrics::counter!(
@@ -269,4 +307,26 @@ mod tests {
269307
));
270308
assert!(resolved[2].is_ok());
271309
}
310+
311+
// ── response size cap tests (#154) ──
312+
313+
#[test]
314+
fn response_at_cap_is_accepted() {
315+
let body = "x".repeat(10);
316+
assert!(
317+
check_response_cap(&body, 10, 1, "eth_call").is_ok(),
318+
"body exactly at cap should pass"
319+
);
320+
}
321+
322+
#[test]
323+
fn response_over_cap_returns_invalid_input() {
324+
let body = "x".repeat(11);
325+
let err =
326+
check_response_cap(&body, 10, 1, "eth_call").expect_err("over-cap body should fail");
327+
assert!(
328+
matches!(err, ChainError::Fault(Fault::InvalidInput(_))),
329+
"expected InvalidInput fault, got {err:?}"
330+
);
331+
}
272332
}

crates/nexum-runtime/src/host/state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ pub struct HostState<T: RuntimeTypes> {
3838
pub ext: T::Ext,
3939
/// `chain` backend - per-chain alloy `DynProvider` pool.
4040
pub chain: T::Chain,
41+
/// Host-enforced cap on a single chain JSON-RPC response body.
42+
/// Responses larger than this are rejected before they reach the guest.
43+
pub chain_response_max_bytes: usize,
4144
/// `local-store` backend - per-module handle with pre-computed
4245
/// keccak256 namespace prefix.
4346
pub store: Handle<T>,

crates/nexum-runtime/src/supervisor.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,9 @@ struct LoadedModule<T: RuntimeTypes> {
176176
/// Cached for restart: outbound HTTP limits baked into the
177177
/// `HostState` we rebuild on each re-instantiation.
178178
http_limits: OutboundHttpLimits,
179+
/// Cached for restart: chain response size cap baked into the
180+
/// `HostState` we rebuild on each re-instantiation.
181+
chain_response_max_bytes: usize,
179182
/// Set to `false` when `on_event` traps. Dead modules are
180183
/// excluded from dispatch until `next_attempt` is in the past.
181184
/// Modules whose `init` failed have `alive = false`
@@ -299,6 +302,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
299302
http_limits: OutboundHttpLimits,
300303
memory_limit: usize,
301304
fuel: u64,
305+
chain_response_max_bytes: usize,
302306
clocks: Option<&WasiClockOverride>,
303307
) -> Result<HostStore<T>> {
304308
let namespace: &str = &run.module;
@@ -351,6 +355,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
351355
log_router: router,
352356
ext: components.ext.clone(),
353357
chain: components.chain.clone(),
358+
chain_response_max_bytes,
354359
store: module_store,
355360
},
356361
);
@@ -436,6 +441,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
436441
limits_cfg.http(),
437442
limits_cfg.memory(),
438443
limits_cfg.fuel(),
444+
limits_cfg.chain_response_max_bytes(),
439445
clocks,
440446
)?;
441447
let bindings = EventModule::instantiate_async(&mut store, &component, linker)
@@ -508,6 +514,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
508514
init_config: config,
509515
http_allowlist: loaded_manifest.http_allowlist.clone(),
510516
http_limits: limits_cfg.http(),
517+
chain_response_max_bytes: limits_cfg.chain_response_max_bytes(),
511518
failure_timestamps: std::collections::VecDeque::new(),
512519
poisoned: false,
513520
})
@@ -602,6 +609,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
602609
module.http_limits,
603610
module.memory_limit,
604611
module.fuel_per_event,
612+
module.chain_response_max_bytes,
605613
clocks.as_ref(),
606614
)?;
607615
let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker)

0 commit comments

Comments
 (0)