Running Untrusted Plugins Server‑Side with WASM Component Model and WASI Preview 2
A concise architecture note showing how to isolate third‑party code using capability‑based WASI imports, minimal host surface, and observable resource limits.
15 Oct 2025, 16:15 UTC

Problem: Isolating third‑party code without heavyweight processes
Many SaaS platforms need to execute user‑supplied plugins (e.g., custom functions, filters, or extensions) while guaranteeing that the plugin cannot read arbitrary files, open network connections, or consume unbounded CPU or memory. Traditional approaches rely on OS‑level sandboxing (containers, VMs) or language‑specific interpreters, each adding start‑up latency, memory overhead, or complex configuration. The goal is to achieve strong isolation inside a single host process, with explicit, auditable boundaries and deterministic resource limits.
Useful takeaway
By adopting the WebAssembly Component Model together with WASI Preview 2, you can expose only the exact capabilities a plugin needs through typed WIT interfaces. Each plugin runs in its own WASM instance with separate linear memory, no shared globals, and host‑provided capability handles. The runtime enforces memory and fuel metering, traps on out‑of‑bounds accesses, and lets you observe traps and host‑call latency for observability.
Requirements
- Strong isolation: no ambient access to host filesystem, network, or process unless explicitly imported.
- Deterministic resource limits: configurable CPU (fuel) and memory caps per instance.
- Observable execution: ability to log traps, host‑call latency, and resource usage.
- Upgrade safety: plugin updates must be gated by interface version compatibility.
- Minimal host surface: expose only narrow capabilities such as read‑only data access, logging, and time.
Smallest suitable design
The design consists of a single host runtime process that:
- Loads each plugin as a standalone WASM component.
- Instantiates the component with its own linear memory and table.
- Provides imports exclusively via WIT‑defined capabilities (e.g.,
logging: log,data: read-only-buffer,time: monotonic-clock). - Never shares globals or mutable memory between instances.
- Applies per‑instance fuel metering and memory limits configured by the host.
This satisfies the isolation requirement while keeping the host footprint low: only the runtime and a thin capability dispatcher reside in the host address space.
Trust and data boundaries
The runtime sandbox enforces that a component can only interact with the host through the imported capabilities. Data crossing the boundary occurs via:
- Primitive WIT values (integers, floats, booleans, strings, buffers) that are copied or validated by the host.
- Opaque handles that refer to host‑owned objects (e.g., a file descriptor wrapped in a read‑only stream). The host validates any operation on the handle before forwarding it to the underlying resource.
No raw host pointers are ever passed into the WASM linear memory, eliminating a class of memory‑corruption attacks. If a component attempts to use an capability that was not imported, the trap occurs inside the WASM instance and the host sees only a standard trap notification.
Operational checks
To verify that the design behaves as intended, you can perform the following checks:
- WIT conformance: compile the component against the WIT package and ensure the linker reports no missing imports.
- Resource metering: run a component that allocates memory in a loop until the configured limit is reached; expect a memory‑out trap logged by the runtime.
- Fuel limits: execute a tight WebAssembly loop that consumes fuel; verify that the runtime stops execution after the fuel budget and reports a fuel‑exhausted trap.
- Capability leakage test: attempt to import a capability (e.g.,
fs: read-write) that the host did not provide; the component should fail to instantiate. - Observability: enable runtime logging of trap codes and host‑call latency; confirm that each trap yields a distinct, traceable identifier.
Failure modes
- Out‑of‑memory trap: triggered when a component exceeds its memory limit; the host can terminate the instance and log the event.
- Fuel exhaustion: infinite loops are halted after the fuel budget, preventing CPU starvation.
- Invalid WIT data: if the host passes malformed strings or buffers, the component may trap; host‑side validation mitigates this.
- Capability misuse: a buggy host that over‑grants a capability (e.g., giving write access when only read was intended) effectively widens the trust boundary.
When the design would change
Reconsider this architecture if any of the following become true:
- Plugins need to share mutable state with each other (e.g., a shared cache). The current design isolates memory; sharing would require a new capability or a move to separate OS processes.
- Fine‑grained multi‑tenancy demands separate OS‑level isolation (different UIDs, seccomp profiles) beyond what the WASM sandbox provides.
- Compute‑bound workloads suffer noticeably from WASM overhead and require near‑native performance; then a native sandbox (e.g., eBPF or a JIT‑compiled native module) might be preferable.
- Regulatory or compliance mandates call for formally proven isolation; in that case, hardware enclaves or verified microkernels could be considered.
Concrete example: minimal host in pseudo‑Rust
The following snippet shows how a host might set up a component that only needs logging and read‑only access to a configuration blob. No actual output is claimed; the code illustrates the pattern.
use wasmtime::*;
use wasmtime_wasi::preview2::WasiCtxBuilder;
// Define the WIT world as generated from wit-bindgen
struct HostState {
logger: Logger, // implements the `logging: log` WIT interface
config: Vec, // read‑only configuration data
}
impl HostState {
fn log(&mut self, msg: &str) -> Result<(), Trap> {
self.logger.log(msg);
Ok(())
}
fn read_config(&mut self, offset: u32, size: u32) -> Result, Trap> {
let end = (offset as usize).checked_add(size as usize)
.ok_or_else(|| Trap::new("offset overflow"))?;
if end > self.config.len() {
return Err(Trap::new("config read out of bounds"));
}
Ok(self.config[offset as usize..end].to_vec())
}
}
fn main() -> Result<(), Box> {
let engine = Engine::default();
let mut linker = Linker::new(&engine);
// Import the WIT-generated bindings for our world
wasi_preview2::add_to_linker(&mut linker, |state: &mut HostState| &mut state.wasi)?;
// Assume `plugin_world::add_to_linker` is generated from the WIT package
plugin_world::add_to_linker(&mut linker, |state: &mut HostState| state)?;
let mut store = Store::new(&engine, HostState {
logger: SimpleLogger::new(),
config: b"{\"threshold\":42}".to_vec(),
});
let component = Component::from_file(&engine, "plugin.wasm")?;
let instance = linker.instantiate(&mut store, &component)?;
// Call an exported function from the component, e.g., `run`
let run = instance.get_typed_func::<(), (), _>(&mut store, "run")?;
// This will trap if the plugin attempts any unimported capability
let _ = run.call(&mut store);
Ok(())
}
Key points illustrated:
- The host provides only two capabilities:
logging: logand a customread-configfunction that returns a copy of the configuration buffer. - The plugin’s linear memory is isolated; it cannot directly read
state.configwithout going through the host‑provided accessor. - If the plugin tries to import, say,
fs: open, the linker will report a missing import and instantiation fails. - Fuel and memory limits can be set via
Store::set_fuel_consume_callback andStore::set_epoch_deadline(or runtime‑specific equivalents) before instantiation.
Limitations and practical verification
While the Component Model gives strong guarantees, be aware of the following limits:
- WIT serialization copies data; large blobs (e.g., multi‑MB images) incur noticeable overhead. For streaming high‑throughput data, consider passing a host‑owned stream handle instead of copying the whole buffer.
- Fuel metering and memory growth policies differ between runtimes (Wasmtime, Wasmer, WAVM). Test limits on the exact runtime you plan to deploy.
- The Component Model is still evolving; ensure your runtime version advertises support for both the Component Model proposal and WASI Preview 2.
To verify the isolation in a staging environment:
- Run the component with a deliberately malformed WIT string (e.g., non‑UTF‑8 bytes) and confirm the host logs a trap before any host‑side processing.
- Deploy a plugin that loops allocating 1 MiB chunks until the configured memory limit is hit; check that the runtime emits a memory‑out trap and the host can safely discard the instance.
- Introduce a version bump in the WIT interface (e.g., change the logging method signature) and ensure the host refuses to load an outdated component unless explicitly allowed.
These steps give confidence that the trust boundary is enforced and that operational checks fire as expected.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.