WASI Preview 2 Capability-Based Security: filesystem, sockets, http, and the Component Model
Problem
WASI Preview 1 (the original system interface from 2019) modeled the world as a small flat namespace of imports — path_open, fd_read, sock_connect, etc. — that the embedder either provided or did not. The unit of authority was the entire interface. If a module needed any filesystem access, it imported path_open and got the ability to open any path the embedder chose to expose, with the embedder distinguishing only via preopened directory handles passed in opaquely.
Preview 2 (stabilized through 2024–2025 and now the default for new WASM toolchains) is a different model:
- Capability handles, not flat imports. Filesystem access is a
wasi:filesystem/types/descriptorresource passed in by the host. A module that wants to read the descriptor needs the descriptor to be passed to it; it cannot conjure one. - Interfaces are versioned and scoped. A component declares
import wasi:filesystem/types@0.2.0explicitly. A component without that import has no filesystem access at all — the runtime cannot give it one. - Component model boundaries. Multiple components compose into an application. Capability passing across component boundaries is explicit; one component cannot grant a capability it does not itself hold.
- Standard interfaces with semantic meaning.
wasi:http/incoming-handlerfor serving HTTP,wasi:sockets/tcpfor raw TCP,wasi:cli/environmentfor env vars. Each has a defined contract and a defined scope.
For embedders, this changes the security story:
- The host enumerates capabilities at component instantiation, not at every host call. The decision of “what can this component do” is made once, up front, and the runtime enforces.
- A component’s WIT (WebAssembly Interface Type) definition is a precise enumeration of its host-call surface — auditable at build time without running the code.
- Capabilities are passed by value to functions; a component can sub-delegate to other components only if the capability is in its own world.
- Interfaces compose: a “logger” component can hold
wasi:filesystem(to write logs) and expose only alog(msg: string)interface to others — those others get the logging capability without being able to write arbitrary files.
This article covers the WIT type system from a security perspective, the world / interface model, embedder-side capability enumeration, sub-delegation patterns, and the migration story from Preview 1.
Target systems: Wasmtime 22+ (full Preview 2 support), Spin 2.0+, wasmCloud 1.0+, JCO (JavaScript component runner), Wasmer 4.3+. Toolchains producing components: cargo component (Rust), wit-bindgen (multi-language), componentize-py (Python), componentize-js (JS).
Threat Model
- Adversary 1 — Component author with hidden imports: an adversary submits a component whose WIT signature looks innocuous but which uses capabilities the embedder did not intend to grant. (Preview 2 makes this impossible by design — but only if the embedder enumerates imports against an allowlist.)
- Adversary 2 — Capability over-grant during composition: a benign component is composed with another component that requires more permissions, and the composer naively grants the union. The benign component now runs with capabilities it never asked for.
- Adversary 3 — Capability leakage via interface design: a component takes a capability handle and stores it in shared state where another, less-trusted code path retrieves it.
- Access level: Adversary 1 has component-upload capability. Adversary 2 has compose-time control. Adversary 3 has source access to the host harness.
- Objective: Obtain a capability that grants more I/O, network access, or environmental visibility than the component should have.
- Blast radius: Bounded to the capabilities the runtime hands out at instantiation. A correctly-configured Preview 2 host with strict allowlists makes blast radius proportional to the smallest set of capabilities the workflow legitimately needs.
Configuration
Step 1: Audit the WIT World of a Component
Every component declares a “world” — the set of imports it requires and exports it provides. Inspect with wasm-tools:
wasm-tools component wit ./payments.wasm
Output for a hypothetical payments component:
package myorg:payments@1.0.0;
world payments {
// Imports: capabilities this component needs.
import wasi:filesystem/types@0.2.0;
import wasi:filesystem/preopens@0.2.0;
import wasi:io/streams@0.2.0;
import wasi:io/error@0.2.0;
import wasi:clocks/wall-clock@0.2.0;
import wasi:http/outgoing-handler@0.2.0;
// Exports: interfaces this component provides.
export wasi:http/incoming-handler@0.2.0;
}
The WIT world is the audit boundary. Imports tell you exactly what host capabilities the component will request; exports tell you what services the component provides. There are no other host calls — the toolchain refuses to compile a component that uses an interface not declared in its world.
For policy enforcement at upload time, parse the WIT and compare against an allowlist:
// validate_component.rs
use wit_parser::{Resolve, WorldId};
const ALLOWED_IMPORTS: &[&str] = &[
"wasi:filesystem/types@0.2.0",
"wasi:filesystem/preopens@0.2.0",
"wasi:io/streams@0.2.0",
"wasi:io/error@0.2.0",
"wasi:clocks/wall-clock@0.2.0",
"wasi:clocks/monotonic-clock@0.2.0",
"wasi:http/outgoing-handler@0.2.0",
"wasi:cli/stdout@0.2.0",
"wasi:cli/stderr@0.2.0",
];
const FORBIDDEN_IMPORTS: &[&str] = &[
"wasi:sockets/tcp@0.2.0", // raw TCP not allowed; must use wasi:http
"wasi:sockets/udp@0.2.0",
"wasi:cli/environment@0.2.0", // no env-var visibility
"wasi:random/insecure@0.2.0", // explicitly require secure RNG
];
fn validate(component: &[u8]) -> anyhow::Result<()> {
let mut resolve = Resolve::new();
let pkg = resolve.push_path(component)?;
let world: WorldId = resolve.select_world(pkg.0, None)?;
for (key, _) in &resolve.worlds[world].imports {
let name = format!("{}", resolve.name_world_key(key));
if FORBIDDEN_IMPORTS.contains(&name.as_str()) {
anyhow::bail!("component imports forbidden interface: {name}");
}
if !ALLOWED_IMPORTS.contains(&name.as_str()) {
anyhow::bail!("component imports unapproved interface: {name}");
}
}
Ok(())
}
Run this at component upload, before any execution. A component that imports an unapproved interface is rejected.
Step 2: Embedder-Side Capability Construction
In Wasmtime’s Preview 2 host, the embedder constructs WasiCtx and WasiHttpCtx (and any custom interfaces) and links them into the component. The component cannot construct these; it receives handles.
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::preview2::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};
use wasmtime_wasi_http::{WasiHttpCtx, WasiHttpView};
struct Host {
table: ResourceTable,
wasi: WasiCtx,
http: WasiHttpCtx,
}
impl WasiView for Host {
fn table(&mut self) -> &mut ResourceTable { &mut self.table }
fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
}
impl WasiHttpView for Host {
fn ctx(&mut self) -> &mut WasiHttpCtx { &mut self.http }
fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}
fn build_host_for_tenant(tenant_id: &str) -> anyhow::Result<Host> {
let mut wasi = WasiCtxBuilder::new();
// Filesystem: only the tenant's working dir.
let workdir = format!("/var/lib/wasm-tenants/{tenant_id}/workdir");
let dir = cap_std::fs::Dir::open_ambient_dir(
&workdir, cap_std::ambient_authority())?;
wasi.preopened_dir(dir, wasmtime_wasi::DirPerms::all(),
wasmtime_wasi::FilePerms::all(), "/work")?;
// No env vars. No CLI args.
// Only monotonic clock, no wall clock.
wasi.allow_blocking_current_thread(false);
// HTTP: outbound only to specific endpoints.
let mut http = WasiHttpCtx::default();
http.allowed_request_targets = vec![
"https://api.internal.example.com".into(),
"https://payment-processor.example.com".into(),
];
Ok(Host {
table: ResourceTable::new(),
wasi: wasi.build(),
http,
})
}
The two key decisions:
wasi:filesystem/typesis granted viapreopened_dir. The component sees only/work; the path is a logical name inside the WASM world, not a host path.wasi:http/outgoing-handleris granted with an explicit target allowlist. Outbound HTTP requests to anywhere else trap with a permission error before the connection is initiated.
For a component that should not have any filesystem access at all, omit preopened_dir. The component’s world declares the import, but the runtime hands it an empty list of preopens. Attempts to open anything return ENOENT.
Step 3: Composition and Capability Containment
Components compose. A “platform” component holds capabilities; “tenant” components run inside the platform’s world and only access what the platform exposes.
// platform.wit
world platform {
// Platform imports the world's full WASI surface.
import wasi:filesystem/types@0.2.0;
import wasi:http/outgoing-handler@0.2.0;
import wasi:io/streams@0.2.0;
// Platform exposes a narrower interface to tenants.
export myorg:platform/storage@1.0.0;
export myorg:platform/api-call@1.0.0;
}
// tenant.wit
world tenant {
// Tenant imports only the platform's narrow surface.
import myorg:platform/storage@1.0.0;
import myorg:platform/api-call@1.0.0;
export wasi:http/incoming-handler@0.2.0;
}
// myorg:platform/storage interface
package myorg:platform@1.0.0;
interface storage {
resource bucket {
get: func(key: string) -> result<list<u8>, error>;
put: func(key: string, value: list<u8>) -> result<unit, error>;
}
variant error {
not-found,
unauthorized,
quota-exceeded,
}
open-bucket: func(name: string) -> result<bucket, error>;
}
The tenant component never holds a wasi:filesystem descriptor. It can only call storage.open-bucket(name), which the platform component implements by translating to filesystem operations under a constrained path. A bug in the tenant cannot reach the host filesystem; only the platform’s bug surface matters.
Compose with wasm-tools:
wasm-tools compose tenant.wasm \
--definitions platform.wasm \
--output composed.wasm
The composed artifact has the platform’s capabilities, with the tenant safely embedded. The tenant’s WIT world cannot import any WASI interface unless the platform explicitly re-exports it.
Step 4: Resource Lifetime and Handle Hygiene
Capability handles in Preview 2 are typed resources with explicit lifetime. Use with blocks (Rust) or finalizers (other languages) to ensure handles are dropped when no longer needed.
// Use a borrowed reference; drop ends the lifetime.
let bucket = storage::open_bucket("payments")?;
let value = bucket.get("user-123")?;
// bucket dropped here; the host can free the resource entry.
A component that retains capability handles indefinitely accumulates resource-table entries on the host. The runtime caps this:
let mut table = ResourceTable::new();
table.set_max_entries(1024); // Per-instance handle limit.
Exceeding the limit returns an error to the component, which can fail-safely.
Step 5: Migration from Preview 1
Many existing modules are Preview 1. The migration:
# Convert a Preview 1 module to a Preview 2 component.
wasm-tools component new \
--adapt wasi_snapshot_preview1=adapter.wasm \
-o component.wasm \
module.wasm
The adapter (adapter.wasm from https://github.com/bytecodealliance/wasmtime/releases) wraps Preview 1 imports with Preview 2 implementations. The result is a component that runs in a Preview 2 host while preserving Preview 1 semantics.
For new development, prefer native Preview 2 — the language toolchains (cargo component, componentize-py, componentize-js) generate components directly without going through Preview 1.
Expected Behaviour
| Signal | Preview 1 | Preview 2 |
|---|---|---|
| Module’s syscall-level audit | All Preview 1 imports listed flat | WIT world enumerates explicit capability imports |
| Filesystem access without an explicit grant | Module fails at runtime if no preopens are given | Module fails to instantiate if wasi:filesystem is in the world but no preopens given |
| Network access | sock_connect import either present or absent |
wasi:sockets/tcp and wasi:http/outgoing-handler separately importable; HTTP can be allowed without raw sockets |
| Composition | Modules link via Linker; everything is in one namespace | Components compose via WIT; capabilities contained in component boundaries |
| Auditing component imports | Inspect the import section of the binary | wasm-tools component wit produces a structured WIT document |
| Sub-delegation of capabilities | Not modeled | Explicit; one component cannot pass a capability it does not have |
Verify a component’s world matches expectations:
wasm-tools component wit ./payments.wasm | diff - expected-world.wit
# (no diff = component imports match the policy-approved world)
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Strict import allowlist | Components cannot use capabilities they did not declare | Tooling must validate at upload time | Bake the validator into the upload pipeline; reject before storage. |
| Per-tenant WasiCtx | Capabilities scoped to the tenant’s intended environment | More host-side configuration per request | Generate WasiCtxBuilder from a tenant-config object; reuse infrastructure across tenants. |
| Component composition | Tenants can ride on a hardened platform component | Composition tooling is newer than module-level linking | Stick with current wasm-tools versions; pin to a known-good release. |
| Resource lifetime | Resource-table entries bounded; no leak from runaway handles | Components must drop handles when done | Idiomatic in Rust (RAII); for languages without RAII, ensure component bindings provide a drop pattern. |
| Migration overhead | Preview 1 modules need adapter or rewrite | Adapter adds startup cost; rewrite is engineering work | Use the adapter as a transition step. New work in Preview 2 only. |
| Interface stability | Preview 2 is stable; the world is auditable | Community is still defining semi-standard interfaces (e.g., wasi:keyvalue) |
Pin to standardized interfaces (wasi:filesystem, wasi:http, wasi:sockets); avoid pre-1.0 interfaces in security-critical paths. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Component imports forbidden interface | Upload rejected | Validation tooling fails the upload with the specific interface name | Refuse to deploy. Component author must remove or replace the import. |
| Capability handle leaked to long-lived state | Resource-table entry persists until process restart | Resource-table size grows over time; metrics show monotonic increase | Add lifetime bounds to handle storage; release on TTL or LRU. The component-level fix is to use scoped handles, not stored ones. |
| Composition grants over-broad capability | Composed artifact has more capabilities than the inner tenant declared | wasm-tools component wit of the composed artifact shows additional imports |
Review the platform component’s imports; constrain to what genuine tenants need. Tenants should not implicitly receive platform’s broader access. |
| WASI version mismatch | Component built against 0.2.0 runs in a host with 0.2.1 runtime |
At instantiation: type-check failure on resource shape | Pin runtime and component versions in deploy manifest. Upgrade in coordinated waves. |
| Preview 1 adapter behaves differently than expected | Module that worked in Preview 1 produces unexpected results in Preview 2 | Inconsistent behaviour for blocking I/O, errno values | Investigate the adapter version; some early adapters had subtle behaviour mismatches. Upgrade to the current adapter or rewrite as native Preview 2. |
| Capability passing in custom interfaces leaks | A custom interface that passes a borrow<descriptor> accidentally exposes filesystem to a sub-component |
Audit the WIT of intermediate components | Re-design the interface to take pre-resolved values rather than resource handles. |