Skip to content

Commit 2926098

Browse files
committed
security(wasm): bound sandbox resources and remove host-side panic/DoS vectors
Hardens the WASM host runtime against a hostile or compromised plugin (area: WASM sandbox limits). - Panic vectors: guest-controlled pointer/length slice reads use saturating_add so a negative/oversized len fails the existing bounds check instead of panicking on an inverted range. Cache and rate-limiter Instant/Duration arithmetic is overflow/underflow-safe (checked/ saturating + duration_since), including the cleanup paths that underflow early in process uptime. - Memory bounds: the response cache caps its entry count (evicting expired-then-soonest-to-expire); the rate limiter clamps the plugin-supplied quota and window and caps the partition table, failing closed when saturated with active keys. - Upstream body cap: the buffered plugin HTTP-call path reads at most BARBACANE_MAX_UPSTREAM_RESPONSE_BYTES (default 16 MiB) via a chunked, content-length-aware read, bounding host memory. - Wall-clock backstop: epoch interruption with a background ticker traps a guest that runs past its time budget, complementing fuel-based CPU limiting. - Broker hardening: Kafka/NATS connections enforce the SSRF guard (honoring BARBACANE_ALLOW_INTERNAL_EGRESS), connect/publish timeouts, and bounded connection caches. Runtime build errors propagate instead of panicking. Docs and CHANGELOG updated for the new env var and broker egress default.
1 parent 34d5dba commit 2926098

11 files changed

Lines changed: 615 additions & 125 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- **security**: WASM plugin capability enforcement — plugins may only import host functions covered by the capabilities declared in `plugin.toml`.
1616
- **security**: `jwt-auth` performs real signature verification via the host `verify_signature` capability (inline JWK).
1717
- **security**: a security testing framework — adversarial integration suite (`crates/barbacane-test/tests/security/`) and `cargo-fuzz` targets (`fuzz/`).
18+
- **security**: WASM sandbox resource limits — buffered plugin HTTP responses are capped (`BARBACANE_MAX_UPSTREAM_RESPONSE_BYTES`, default 16 MiB); the host cache and rate limiter bound their entry/partition counts and clamp plugin-supplied TTL/window/quota; a wall-clock epoch deadline backstops fuel-based CPU limiting.
1819
- **docs**: [Configuration & environment variables](reference/configuration.md) reference.
1920

2021
### Changed (breaking, secure-by-default)
2122

2223
- The control plane refuses to start without `BARBACANE_CONTROL_ADMIN_TOKEN`, and all API routes (except `/health` and the data-plane WebSocket) require the bearer token.
2324
- `file://` secret references require `BARBACANE_SECRETS_DIR` and are confined to it.
2425
- MCP requires a valid session for non-`initialize` requests.
25-
- Plugin HTTP egress to internal/metadata addresses is blocked by default.
26+
- Plugin egress to internal/metadata addresses is blocked by default — this now covers Kafka/NATS broker connections in addition to plugin HTTP calls.
2627

2728
### Fixed
2829

2930
- **security**: fail-open middleware short-circuit downgrade in the WASM chain.
3031
- **security**: panic on hostile `x-request-id` / `traceparent`; unbounded Prometheus path-label cardinality on unmatched routes.
32+
- **security**: panic vectors in WASM host functions — guest-controlled pointer/length slice reads now use saturating arithmetic, and cache/rate-limiter time arithmetic is overflow/underflow-safe.
3133
- **deps**: bump `anyhow` to 1.0.103 (RUSTSEC-2026-0190).
3234

3335
## [0.7.0] - 2026-05-05

crates/barbacane-wasm/src/broker.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,48 @@ pub enum BrokerError {
2424

2525
#[error("timeout")]
2626
Timeout,
27+
28+
#[error("broker target blocked by SSRF policy: {0}")]
29+
Blocked(String),
30+
}
31+
32+
/// Split a broker address into its host and port, stripping any `scheme://`
33+
/// prefix and IPv6 brackets. Used to feed the SSRF guard. Falls back to
34+
/// `default_port` when no port is present.
35+
pub(crate) fn split_host_port(addr: &str, default_port: u16) -> (String, u16) {
36+
// Drop a leading scheme such as `nats://` or `tls://`.
37+
let addr = addr
38+
.split_once("://")
39+
.map(|(_, rest)| rest)
40+
.unwrap_or(addr)
41+
.trim();
42+
// Drop any path/query that may follow the authority.
43+
let authority = addr
44+
.split(['/', '?'])
45+
.next()
46+
.unwrap_or(addr)
47+
.trim_end_matches('.');
48+
49+
// Bracketed IPv6: [::1]:4222 or [::1]
50+
if let Some(rest) = authority.strip_prefix('[') {
51+
if let Some((host, after)) = rest.split_once(']') {
52+
let port = after
53+
.strip_prefix(':')
54+
.and_then(|p| p.parse::<u16>().ok())
55+
.unwrap_or(default_port);
56+
return (host.to_string(), port);
57+
}
58+
}
59+
60+
// host:port — but only treat the last colon as a port separator when what
61+
// follows parses as a port (avoids mangling a bare unbracketed IPv6).
62+
if let Some((host, port_str)) = authority.rsplit_once(':') {
63+
if let Ok(port) = port_str.parse::<u16>() {
64+
return (host.to_string(), port);
65+
}
66+
}
67+
68+
(authority.to_string(), default_port)
2769
}
2870

2971
/// A message to publish to a broker.
@@ -215,6 +257,30 @@ mod tests {
215257
assert_eq!(deserialized.offset, Some(42));
216258
}
217259

260+
#[test]
261+
fn split_host_port_variants() {
262+
assert_eq!(split_host_port("kafka:9092", 9092), ("kafka".into(), 9092));
263+
assert_eq!(
264+
split_host_port("nats://example.com:4222", 4222),
265+
("example.com".into(), 4222)
266+
);
267+
assert_eq!(
268+
split_host_port("broker.internal", 9092),
269+
("broker.internal".into(), 9092)
270+
);
271+
assert_eq!(split_host_port("[::1]:4222", 4222), ("::1".into(), 4222));
272+
assert_eq!(split_host_port("[fe80::1]", 4222), ("fe80::1".into(), 4222));
273+
assert_eq!(
274+
split_host_port("tls://10.0.0.1:9093/path", 9092),
275+
("10.0.0.1".into(), 9093)
276+
);
277+
// Bare IPv6 without brackets: no colon is treated as a port.
278+
assert_eq!(
279+
split_host_port("169.254.169.254:9092", 9092),
280+
("169.254.169.254".into(), 9092)
281+
);
282+
}
283+
218284
#[test]
219285
fn broker_error_display() {
220286
assert_eq!(

crates/barbacane-wasm/src/cache.rs

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ use std::collections::HashMap;
88
use std::sync::Arc;
99
use std::time::{Duration, Instant};
1010

11+
/// Upper bound on an entry's TTL. The TTL is plugin-controlled, so it is clamped
12+
/// before being fed into `Instant + Duration` arithmetic to avoid an
13+
/// overflow panic on an absurd value (1 year is well beyond any sane cache TTL).
14+
const MAX_TTL_SECS: u64 = 366 * 24 * 60 * 60;
15+
16+
/// Upper bound on the number of cached entries. A plugin can write arbitrary
17+
/// keys, so the table is capped to bound host memory; inserts past the cap evict
18+
/// expired entries first, then the entry closest to expiry.
19+
const MAX_CACHE_ENTRIES: usize = 10_000;
20+
1121
/// A cached response entry.
1222
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1323
pub struct CacheEntry {
@@ -97,8 +107,8 @@ impl ResponseCache {
97107

98108
let mut entry = internal.entry.clone();
99109
entry.metadata = Some(CacheMetadata {
100-
created_at: now_unix - internal.created_at.elapsed().as_secs(),
101-
expires_at: now_unix + ttl_remaining,
110+
created_at: now_unix.saturating_sub(internal.created_at.elapsed().as_secs()),
111+
expires_at: now_unix.saturating_add(ttl_remaining),
102112
ttl_remaining,
103113
});
104114

@@ -119,7 +129,12 @@ impl ResponseCache {
119129
/// Set a cache entry with TTL.
120130
pub fn set(&self, key: &str, entry: CacheEntry, ttl_secs: u64) {
121131
let now = Instant::now();
122-
let expires_at = now + Duration::from_secs(ttl_secs);
132+
// Clamp the plugin-controlled TTL and use checked arithmetic so a huge
133+
// value can't overflow-panic `Instant + Duration`.
134+
let ttl_secs = ttl_secs.min(MAX_TTL_SECS);
135+
let expires_at = now
136+
.checked_add(Duration::from_secs(ttl_secs))
137+
.unwrap_or(now);
123138

124139
let internal = InternalEntry {
125140
entry,
@@ -129,6 +144,20 @@ impl ResponseCache {
129144
};
130145

131146
let mut entries = self.entries.write();
147+
if entries.len() >= MAX_CACHE_ENTRIES && !entries.contains_key(key) {
148+
// Make room: drop expired entries first.
149+
entries.retain(|_, v| v.expires_at > now);
150+
// Still full of live entries? Evict the one closest to expiry.
151+
if entries.len() >= MAX_CACHE_ENTRIES {
152+
if let Some(victim) = entries
153+
.iter()
154+
.min_by_key(|(_, v)| v.expires_at)
155+
.map(|(k, _)| k.clone())
156+
{
157+
entries.remove(&victim);
158+
}
159+
}
160+
}
132161
entries.insert(key.to_string(), internal);
133162
}
134163

@@ -221,6 +250,26 @@ mod tests {
221250
assert_eq!(cached.body, Some(b"test body".to_vec()));
222251
}
223252

253+
#[test]
254+
fn cache_entry_count_is_bounded() {
255+
let cache = ResponseCache::new();
256+
let entry = CacheEntry {
257+
status: 200,
258+
headers: HashMap::new(),
259+
body: None,
260+
metadata: None,
261+
};
262+
// Write more distinct keys than the cap, all with a live TTL.
263+
for i in 0..(MAX_CACHE_ENTRIES + 100) {
264+
cache.set(&format!("key-{i}"), entry.clone(), 3600);
265+
}
266+
assert!(
267+
cache.stats().total_entries <= MAX_CACHE_ENTRIES,
268+
"cache grew past the cap: {}",
269+
cache.stats().total_entries
270+
);
271+
}
272+
224273
#[test]
225274
fn test_cache_invalidate() {
226275
let cache = ResponseCache::new();

crates/barbacane-wasm/src/engine.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,21 @@
33
//! This module provides the core wasmtime engine with settings optimized
44
//! for the Barbacane plugin runtime.
55
6+
use std::sync::atomic::{AtomicBool, Ordering};
7+
use std::sync::Arc;
8+
use std::thread::JoinHandle;
9+
use std::time::Duration;
10+
611
use wasmtime::{Config, Engine, Module, OptLevel};
712

813
use crate::error::WasmError;
914
use crate::limits::PluginLimits;
1015

16+
/// Wall-clock granularity of the epoch ticker. The per-call epoch deadline is
17+
/// expressed as a count of these ticks, so it bounds the slack between a
18+
/// plugin's configured execution budget and when it is actually interrupted.
19+
pub(crate) const EPOCH_TICK: Duration = Duration::from_millis(1);
20+
1121
/// A compiled WASM module ready for instantiation.
1222
#[derive(Clone)]
1323
pub struct CompiledModule {
@@ -31,6 +41,19 @@ impl CompiledModule {
3141
pub struct WasmEngine {
3242
engine: Engine,
3343
limits: PluginLimits,
44+
/// Signals the epoch ticker thread to stop on drop.
45+
epoch_stop: Arc<AtomicBool>,
46+
/// Handle to the background epoch ticker thread.
47+
epoch_ticker: Option<JoinHandle<()>>,
48+
}
49+
50+
impl Drop for WasmEngine {
51+
fn drop(&mut self) {
52+
self.epoch_stop.store(true, Ordering::Relaxed);
53+
if let Some(handle) = self.epoch_ticker.take() {
54+
let _ = handle.join();
55+
}
56+
}
3457
}
3558

3659
impl WasmEngine {
@@ -49,6 +72,13 @@ impl WasmEngine {
4972
// Enable fuel consumption for execution time limiting
5073
config.consume_fuel(true);
5174

75+
// Enable epoch interruption as a wall-clock backstop. Fuel bounds the
76+
// number of instructions executed, but not wall-clock time (e.g. on a
77+
// slow host, or if fuel is miscalibrated). A background thread ticks the
78+
// engine epoch and each call sets a deadline, trapping a guest that runs
79+
// past its time budget.
80+
config.epoch_interruption(true);
81+
5282
// Configure memory settings
5383
config.max_wasm_stack(limits.max_stack_bytes);
5484

@@ -66,7 +96,29 @@ impl WasmEngine {
6696

6797
let engine = Engine::new(&config).map_err(|e| WasmError::EngineCreation(e.to_string()))?;
6898

69-
Ok(Self { engine, limits })
99+
// Spawn the epoch ticker: it increments the engine epoch every
100+
// `EPOCH_TICK` so per-call epoch deadlines fire on a wall-clock basis.
101+
let epoch_stop = Arc::new(AtomicBool::new(false));
102+
let epoch_ticker = {
103+
let engine = engine.clone();
104+
let stop = Arc::clone(&epoch_stop);
105+
std::thread::Builder::new()
106+
.name("wasm-epoch-ticker".into())
107+
.spawn(move || {
108+
while !stop.load(Ordering::Relaxed) {
109+
std::thread::sleep(EPOCH_TICK);
110+
engine.increment_epoch();
111+
}
112+
})
113+
.map_err(|e| WasmError::EngineCreation(e.to_string()))?
114+
};
115+
116+
Ok(Self {
117+
engine,
118+
limits,
119+
epoch_stop,
120+
epoch_ticker: Some(epoch_ticker),
121+
})
70122
}
71123

72124
/// Get a reference to the underlying wasmtime engine.

0 commit comments

Comments
 (0)