Sandboxing WebAssembly Filesystems with WASI: A Practical Engineering Decision
Learn how to expose only the files a WebAssembly module needs, using WASI’s capability model. A step‑by‑step example shows how to pre‑open directories with wasmtime, the differences between Preview 1 and 0.2, and the trade‑offs of using WASI over POSIX.
16 Dec 2025, 18:58 UTC

The Problem
When you run third‑party WebAssembly (WASM) code in a server or embedded environment, you want to keep it from reading or writing arbitrary files on the host. The naive approach is to give the module full access and hope for the best. That works only if you trust the code, which defeats the purpose of sandboxing.
Why Capability‑Based Security Matters
WASI (WebAssembly System Interface) replaces the old “ambient authority” model—where a module could freely call any host function that the runtime exposed—with an explicit, revocable capability model. The runtime decides which host resources to hand to a module and gives the module only the file descriptors that correspond to those resources. If the module tries to open a file that isn’t mapped, the call fails immediately. This makes it trivial to grant read‑only or write‑only access to a specific sub‑tree.
Filesystem Access in WASI Preview 1
Preview 1 of WASI is the most widely supported version. It exposes a flat set of imports in C‑style function names such as fd_write and path_open. The runtime passes a list of pre‑opened directories at launch time. Each directory is mapped to a file descriptor (FD) that the module can use.
Example: a runtime might expose the current working directory as FD 3. Inside the module, the code can call path_open(3, "log.txt") to open log.txt relative to that directory. If the module tries to open ../../etc/passwd, the runtime rejects the request because the path resolves outside the pre‑opened subtree.
Moving to WASI 0.2 and the Component Model
WASI 0.2 and the Component Model replace the flat import list with typed WIT (WebAssembly Interface Types) interfaces. Instead of calling by name, the host and guest exchange structured types and named functions. This improves ergonomics for languages other than C, and allows the toolchain to generate language‑specific bindings automatically.
For example, wit-bindgen can generate a Rust trait from a WIT definition. The runtime then implements that trait, passing the actual host implementation to the module. The guest code calls the trait methods, and the runtime handles the file descriptor mapping behind the scenes.
Practical Example: Running a Rust WASI Module with Preopened Directories
- Write a tiny Rust program that reads a file. Create
src/main.rs:use std::fs::File; use std::io::Read; fn main() { let mut file = File::open("data/input.txt").expect("cannot open file"); let mut contents = String::new(); file.read_to_string(&mut contents).expect("cannot read file"); println!("File contents: {}", contents); } - Compile for WASI Preview 1. Run in the project root:
This producescargo build --target wasm32-wasi --releasetarget/wasm32-wasi/release/your_app.wasm. - Prepare a directory structure. Assume the host has a folder
/srv/appcontainingdata/input.txt. - Run with wasmtime, pre‑opening the directory. Use the
--dirflag to map/srv/appto FD 3:wasmtime --dir /srv/app:3 target/wasm32-wasi/release/your_app.wasm- Permissions. The runtime process needs read permission on
/srv/appand its contents. - Expected outcome. The WASM module prints the file contents. If you change the flag to
--dir /srv/app:3:ro, the module can only read files; attempts to write will fail withEPERM. - Risk. Granting the wrong directory or omitting
rocan expose sensitive data or allow accidental writes.
- Permissions. The runtime process needs read permission on
- Try a path outside the pre‑opened subtree. Inside the WASM code, change the filename to
../secret.txt. Running again will produce a trap:openat: no such file or directory. This demonstrates the capability model in action.
Trade‑offs and Limitations
- Not POSIX. WASI’s file API is simpler: no
statflags, no sockets, no process spawning. If you port a native C program that relies onopenatflags orfcntl, you’ll need to rewrite or shim those calls. - Path handling. WASI normalizes paths differently from POSIX. Symlink traversal and relative path resolution can behave unexpectedly if your code assumes POSIX semantics.
- Component Model maturity. WASI 0.2 and the Component Model are still evolving. Toolchains may change, so long‑term API stability is not guaranteed across runtimes.
- Browser support. Browsers currently do not expose WASI filesystem or socket capabilities. Server‑side or embedded runtimes like
wasmtime,wasmer, orWASI‑CLIare required for system access.
What to Do Next
- Check your runtime’s WASI support. Run
wasmtime --versionorwasmer -vand look for the WASI version. Confirm it supports the capability model you need (Preview 1 or 0.2). - Define minimal capabilities. For each plugin, decide which directories and environment variables it truly needs. Use
--dirwith theroorrwflag to enforce read‑only or read‑write access. - Test with a sample module. Compile a small Rust or C program that exercises the filesystem API. Run it under the runtime with the intended pre‑opened directories and verify that out‑of‑bounds accesses trap.
- Monitor for API changes. Subscribe to releases of the runtime you use and read the WASI RFCs. If you depend on the Component Model, keep an eye on the
wit-bindgenupdates. - Document the capability contract. In your deployment pipeline, record which modules receive which file descriptors and why. This makes troubleshooting and security reviews easier.
By treating WASI as a capability‑based sandbox, you can safely run untrusted WebAssembly plugins while giving them exactly the filesystem access they require. The key is to keep the runtime minimal, pre‑open only the necessary directories, and test thoroughly before shipping to production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.