Security model
View SourceThis page tells you what the runtime protects you against and what it leaves to you. Read it before you put a module you did not write in front of user traffic. The second list is the important one.
What holds the boundary
A module cannot reach outside its sandbox, for five reasons:
- Validation proves every instruction is type-correct and every index is in range, before anything runs. The interpreter never re-checks what validation established.
- Linear memory is bounds-checked on every access, in arbitrary-precision integers, so a base near 2^32 plus a large offset cannot wrap into a valid address.
- Imports are the only way out. A module can do nothing but compute unless you hand it a function.
- Nothing a guest supplies becomes an atom. Not the module bytes, and not
the strings it passes across the WASI boundary: host names, service names and
paths. The atom table is node-wide and never reclaimed, so a guest able to
mint one atom per call is a permanent leak and eventually a node kill,
immune to every limit here. Both halves are asserted, because for a while
only the first was true:
sock_getaddrinforesolved a guest-chosen service name throughbinary_to_atom/2while the decoder's property test passed next door.wasm_prop_SUITEnow covers both, and asserts that atoms do not scale with the number of distinct strings a guest sends. - Nothing raises. Malformed input, ill-typed modules, traps and exhausted limits are all values. Property tests over random binaries, and over valid modules mutated byte by byte, verify it.
None of this depends on processes. Your inline call is exactly as sandboxed as a call inside a worker.
What limits bound
| limit | bounds |
|---|---|
fuel | execution work, charged at calls and loop back-edges |
max_depth | WebAssembly call depth |
max_heap_words | Erlang term allocation, applied by the owning process |
max_host_calls | calls out through an import, per invocation |
max_memory_pages | every memory one instance can reach, imports included |
| node page budget | linear memory across every instance |
max_memory_pages counts an imported memory, because a module that imports one
can address every page of it, and it counts a memory once however many import
slots name it. A memory two instances share grows only as far as the stricter
of their ceilings allows.
fuel, max_depth and max_host_calls belong to the invocation the embedder
started, not to each entry into the interpreter. A guest that recurses by
calling out through an import and back in is bounded by the same allowance as
one that recursed directly.
What you are still responsible for
Stated plainly, because "each instance is in a process, so it is isolated" is the most tempting wrong claim available about this design. A process is a fault and lifecycle boundary, not a security boundary.
Linear memory is invisible to
max_heap_size. It isatomics, which is off-heap. A module can exhaust node memory without its process heap moving. This is why page accounting is explicit and node-wide, and why you should setwasm_engine:set_page_limit/1.Per-instance limits do not bound concurrency. No single instance can monopolise a scheduler, because the interpreter is preemptible: every dispatch step is an Erlang function call and consumes a reduction. But ten thousand well-behaved instances will still saturate every scheduler. Bound that with a pool.
Host functions are the real attack surface. Limits constrain WebAssembly, not the Erlang you supply as an import. A host function that reads any file, allocates without bound, or blocks forever is outside all of this. The import set is part of your sandbox, and the part the runtime cannot check for you.
fuelbounds work, not time. A blocking host call consumes none. You need a timeout as well, and a timeout needs a process to enforce it. The same goes for a guest that parks inmemory.atomic.waitwith a negative timeout, which the specification defines as waiting forever: it consumes no fuel and nothing here will interrupt it.Timing and microarchitectural side channels are not addressed. Nothing here stops a co-tenant module measuring shared cache behaviour. A shared BEAM node is not a boundary against that class of attack; only OS-level or hardware separation is.
An inline call cannot be interrupted.
wasm:call/3runs in your process. A module looping withfuel => infinityhangs you and cannot be killed without killing you. Use a worker for anything untrusted.WASI path resolution has a residual race without the NIF. On the
fallbackbackend, a component can be swapped for a symlink between the check and the open. Checkwasi_fs:backend(); onfallback, do not point a preopen at a directory a hostile party can write to concurrently.A preopen is also a directory rather than a name on the native backend, opened once when the capability is granted. Replacing the host directory underneath a running instance used to redirect every path it held.
This is only now true of what a guest does.
path_openresolved the path and opened it as two separate steps whichever backend was present, so the native one guarded nothing a guest could reach and every deployment had the fallback's race. A guest open goes throughwasi_fsnow, andwasi_SUITEasserts it by a behaviour only the native backend has.
What a network grant does not cover
A net grant says which addresses an instance may reach. That is all it says,
and the gap between that and "the module cannot do harm over the network" is
yours to close.
A granted peer gets whatever the module sends it. The grant is an address filter, not a protocol filter: nothing inspects, limits or understands the bytes. If a module can reach a host, it can send that host anything it has, including data it read from a preopened directory.
There are no implicit denials.
~"0.0.0.0/0"really does include loopback, link-local, RFC 1918 space and the cloud metadata endpoint at169.254.169.254. That is deliberate: a hidden deny list makes a grant mean something other than what it says, and the day it is incomplete is the day somebody relies on it. Name what you mean, and prefer a CIDR to a wildcard.wasi_net_escape_SUITEasserts this, so it stays true or this document gets corrected with it.Nothing here authenticates an inbound connection. A
listengrant opens a socket; whoever reaches it is whoever can route to it. The peer address is reported, never checked against theconnectrules, because inbound is not outbound. Authentication is your module's problem or your network's.A blocking socket call consumes no fuel. This is point 4 above with a much sharper edge: a module parked in
sock_recvdoes no work. Thetimeoutin the grant bounds a single call, not a sequence of them, so a module that reconnects in a loop waits forever in instalments. Use a worker with a timeout for anything untrusted.Descriptors are bounded, bandwidth is not.
max_socketscaps how many sockets an instance holds at once. Nothing caps how much it sends through them, how fast it opens and closes them, or how many instances you run.Resolution is a lookup, not a permission.
resolve => allowlets a module ask your resolver questions, which is an outbound capability of a kind: it reaches your DNS servers, and the names it asks for are visible to them. What it cannot do is widen aconnectgrant, because grants name addresses andsock_connectchecks the address it is handed.The socket extension is not a specification. Preview 1 standardised four socket calls; everything a client needs is a WasmEdge extension bound under the same module name, and its structure layouts are somebody else's decision. They are implemented from WasmEdge's headers, and they can change there. See wasi.md.
Run untrusted code
{ok, Mod} = wasm:load_file("plugin.wasm"),
{ok, W} = wasm_worker:start_link(Mod, #{limits => wasm_limits:untrusted(),
isolation => fresh,
imports => MinimalImports}),
{ok, R} = wasm_worker:call(W, ~"handle", [Req], 500).Check all five before you ship it:
isolation => fresh, so no state survives a request- a timeout that kills the worker rather than merely giving up on it
- the smallest import set that does the job
wasm_engine:set_page_limit/1set for the node- a bounded pool, so concurrency is capped
Reporting
Found something? Open an issue marked security, or contact the maintainer directly.