Skip to content
Closed
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
8 changes: 6 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

73 changes: 73 additions & 0 deletions crates/nexum-runtime/src/engine_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,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;

Expand Down Expand Up @@ -251,6 +257,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
Expand All @@ -268,6 +277,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,
Expand All @@ -287,6 +299,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 {
Expand Down Expand Up @@ -376,6 +399,20 @@ pub struct HttpLimitsSection {
pub response_body_max_bytes: Option<u64>,
}

/// `[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<u64>,
}

/// Resolved outbound HTTP limits the wasi:http gate enforces per
/// request. Built by [`ModuleLimits::http`].
#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -687,6 +724,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
Expand Down
98 changes: 96 additions & 2 deletions crates/nexum-runtime/src/host/impls/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,40 @@ fn resolve_method(method: &str) -> Result<ChainMethod, ChainError> {
})
}

/// 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<T: RuntimeTypes> nexum::host::chain::Host for HostState<T> {
async fn request(
&mut self,
Expand Down Expand Up @@ -57,7 +91,11 @@ impl<T: RuntimeTypes> nexum::host::chain::Host for HostState<T> {
.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!(
Expand Down Expand Up @@ -90,10 +128,44 @@ impl<T: RuntimeTypes> nexum::host::chain::Host for HostState<T> {
// 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<RpcResult>` 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)),
}
}
Expand Down Expand Up @@ -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:?}"
);
}
}
3 changes: 3 additions & 0 deletions crates/nexum-runtime/src/host/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ pub struct HostState<T: RuntimeTypes> {
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<T>,
Expand Down
8 changes: 8 additions & 0 deletions crates/nexum-runtime/src/supervisor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,9 @@ struct LoadedModule<T: RuntimeTypes> {
/// 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`
Expand Down Expand Up @@ -299,6 +302,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
http_limits: OutboundHttpLimits,
memory_limit: usize,
fuel: u64,
chain_response_max_bytes: usize,
clocks: Option<&WasiClockOverride>,
) -> Result<HostStore<T>> {
let namespace: &str = &run.module;
Expand Down Expand Up @@ -351,6 +355,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
log_router: router,
ext: components.ext.clone(),
chain: components.chain.clone(),
chain_response_max_bytes,
store: module_store,
},
);
Expand Down Expand Up @@ -436,6 +441,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
limits_cfg.http(),
limits_cfg.memory(),
limits_cfg.fuel(),
limits_cfg.chain_response_max_bytes(),
clocks,
)?;
let bindings = EventModule::instantiate_async(&mut store, &component, linker)
Expand Down Expand Up @@ -508,6 +514,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
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,
})
Expand Down Expand Up @@ -635,6 +642,7 @@ impl<T: RuntimeTypes> Supervisor<T> {
module.http_limits,
module.memory_limit,
module.fuel_per_event,
module.chain_response_max_bytes,
clocks.as_ref(),
)?;
let bindings = EventModule::instantiate_async(&mut store, &module.component, &linker)
Expand Down
Loading
Loading