Wazero Hardening for Go Embedders: Resource Limits, WASI Capabilities, and Plugin Isolation
Problem
Wazero is a WebAssembly runtime written entirely in Go, with no CGo and no external dependencies. By 2026 it is the default runtime for the Go ecosystem’s growing list of WASM-embedding projects: Tetragon (eBPF policy enforcement), Cilium (CNI plugins), Trivy (custom scanners), k6 (load-test extensions), dapr (state-store extensions), Open Policy Agent’s wasm-target execution, and many internal Go services that use WASM as a plugin mechanism.
Wazero’s design choices differ meaningfully from Wasmtime’s:
- Pure Go. No CGo. The runtime is the same Go process; a Go program embeds the runtime as a library.
- Interpreter and compiler modes. Interpreter is universally portable; the compiler emits machine code at runtime for x86-64 and arm64. Compiler mode is faster but has larger memory footprint.
- No JIT runtime dependency. Unlike V8 or Wasmtime+Cranelift, Wazero compiles to Go-managed memory, so the host’s memory protections (
mprotect, W^X) apply via Go’s runtime. - WASI Preview 1 and Preview 2. Both supported; Preview 2’s component-model integration is via a separate package (
github.com/tetratelabs/wazero/experimental/sys).
The defaults are user-friendly: imports work, WASI is available, modules execute cleanly. They are also wider than production deployments need:
- No CPU bound. A loop in a module hangs the embedding goroutine.
- No memory cap beyond Wazero’s 4 GiB linear-memory ceiling.
- Default
WithRandSource(rand.Reader)provides cryptographic randomness — generally desired but not always. - WASI default config inherits stdio and (with
WithFSConfig) the host filesystem at the embedder’s discretion. Carelessly callingWithFSConfig(wazero.NewFSConfig().WithDirMount("/", "/"))exposes the host root.
This article covers Wazero’s RuntimeConfig and ModuleConfig knobs for resource limits, WASI capability scoping, the closer-to-context-cancellation deadline pattern, and operational metrics for Go embedders.
Target systems: Wazero v1.8+ (the version with stable Preview 2 sys-experimental). Go 1.22+. Embeds well into any Go service.
Threat Model
- Adversary 1 — Untrusted plugin author: uploads a
.wasmplugin to a Go-embedding service (Trivy custom scanner, Tetragon policy, Cilium plugin). Wants to escape the WASM sandbox or exhaust resources. - Adversary 2 — Resource exhaustion in legitimate plugin: a plugin with a memory leak or infinite loop that brings down the embedding service.
- Adversary 3 — Embedded service treats plugin as trusted: the host calls plugin functions assuming bounded execution; an unbounded plugin starves the host.
- Access level: Plugin upload for adversary 1; running plugin in production for 2 and 3.
- Objective: Read or modify host data; consume CPU/memory until the host service crashes; pivot through the host’s identity to its dependencies.
- Blast radius: Bounded by the embedder’s resource and capability decisions. A correctly-configured Wazero embedding contains a malicious module to its allotted memory and CPU; an incorrect one lets the module access whatever the host process can.
Configuration
Step 1: Configure the Runtime with Bounded Compilation
package main
import (
"context"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
func newRuntime(ctx context.Context) wazero.Runtime {
runtimeConfig := wazero.NewRuntimeConfig().
WithCompilationCache(wazero.NewCompilationCacheWithDir("/var/cache/wazero")).
WithCloseOnContextDone(true). // critical — see Step 4
WithDebugInfoEnabled(false). // smaller compiled binary, no debug surface
WithCustomSections(false). // ignore unused custom sections
WithMemoryLimitPages(1024). // 1024 * 64 KiB = 64 MiB max linear memory
WithMemoryCapacityFromMax(false) // grow on demand; do not pre-allocate
rt := wazero.NewRuntimeWithConfig(ctx, runtimeConfig)
wasi_snapshot_preview1.MustInstantiate(ctx, rt)
return rt
}
WithMemoryLimitPages(1024) caps every module’s linear memory at 64 MiB. WithCloseOnContextDone(true) makes Wazero respect Go context cancellation — the cleanest way to enforce a deadline.
Step 2: Bound CPU via Context Deadline
Wazero does not have fuel-style accounting (yet); the deadline mechanism is context.Context cancellation. The runtime checks the context at WASM operation boundaries.
func executeWithBudget(rt wazero.Runtime, wasmBytes []byte, input string) error {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
mod, err := rt.Instantiate(ctx, wasmBytes)
if err != nil {
return err
}
defer mod.Close(ctx)
fn := mod.ExportedFunction("run")
if fn == nil {
return errors.New("module missing 'run' export")
}
_, err = fn.Call(ctx)
return err // context.DeadlineExceeded if the module ran too long
}
The 100ms deadline is enforced via context. WithCloseOnContextDone(true) in the runtime config tells Wazero to honor the cancellation and return promptly. Without that flag the runtime continues running until the next operation boundary that polls the context.
For long-running modules (workers, servers), use a longer or open-ended context but enforce per-call deadlines on each fn.Call(ctx) invocation. The pattern: long-lived module instance, short-lived per-call contexts.
Step 3: WASI Capability Allowlist
WASI in Wazero is configured via ModuleConfig. Default is empty; explicit grants add capabilities.
import (
"io"
"github.com/tetratelabs/wazero/sys"
)
func makeModuleConfig(tenantID string) wazero.ModuleConfig {
workdir := fmt.Sprintf("/var/lib/plugins/%s/workdir", tenantID)
fsConfig := wazero.NewFSConfig().
WithDirMount(workdir, "/work").
WithReadOnlyDirMount("/usr/share/plugin-assets", "/assets")
return wazero.NewModuleConfig().
WithFSConfig(fsConfig).
WithStdin(io.NopCloser(strings.NewReader(""))). // no input
WithStdout(&boundedWriter{cap: 64 * 1024}). // cap stdout
WithStderr(&boundedWriter{cap: 64 * 1024}).
WithRandSource(rand.Reader).
// No environment variables, no command-line args.
WithSysWalltime(). // wall clock allowed
WithSysNanotime(). // monotonic clock allowed
WithStartFunctions("_start") // explicit entry
}
The boundedWriter is a custom io.Writer that errors after cap bytes — a misbehaving module cannot exhaust host memory by writing unbounded log lines.
type boundedWriter struct {
written int
cap int
}
func (w *boundedWriter) Write(p []byte) (int, error) {
if w.written >= w.cap {
return 0, errors.New("output cap exceeded")
}
n := len(p)
if w.written+n > w.cap {
n = w.cap - w.written
}
w.written += n
return n, nil
}
For modules that need network, Wazero’s WASI Preview 2 sys-experimental package supports socket capabilities:
import "github.com/tetratelabs/wazero/experimental/sys"
netConfig := sys.NewSocketConfig().
AllowOutboundTCP(func(host string, port uint16) bool {
// Allowlist by host:port.
return host == "192.168.10.5" && port == 8080
})
mc := wazero.NewModuleConfig().
WithSocketConfig(netConfig).
// ... rest of config
Step 4: Per-Plugin Resource Accounting
Track resource use per plugin instance. Wazero exposes counters:
type Plugin struct {
Name string
rt wazero.Runtime
mod api.Module
}
func (p *Plugin) Stats() PluginStats {
return PluginStats{
MemoryPages: p.mod.Memory().Size() / 65536,
// For more, instrument the host functions yourself.
}
}
// Instrument a host function.
func instrumentedHostFn(name string) api.GoModuleFunc {
return func(ctx context.Context, mod api.Module, params []uint64) []uint64 {
start := time.Now()
defer func() {
metricHostCallDuration.With(prometheus.Labels{
"fn": name,
"module": mod.Name(),
}).Observe(time.Since(start).Seconds())
}()
// host function body
return nil
}
}
Wire into Prometheus:
wazero_plugin_invocations_total{plugin} counter
wazero_plugin_duration_seconds{plugin} histogram
wazero_plugin_traps_total{plugin, kind} counter
wazero_plugin_memory_pages{plugin} gauge
wazero_host_call_duration_seconds{fn, plugin} histogram
Alert on wazero_plugin_traps_total{kind="context_canceled"} rises (deadline exhausted), kind="oom" (memory cap), or unexpected kind="bad_function" (likely incompatible plugin).
Step 5: Compilation Mode Choice
Wazero supports compiler mode (machine-code emission) and interpreter mode. The choice affects performance and security surface.
// Compiler mode: faster, larger memory footprint per instance.
runtimeConfig := wazero.NewRuntimeConfig() // compiler is default
// Interpreter mode: portable, smaller memory.
runtimeConfig := wazero.NewRuntimeConfigInterpreter()
For embedders running thousands of small plugin instances (one per request, for example), interpreter mode often wins on total memory. For a few long-lived instances per process, compiler mode is faster.
The security difference is small but real: compiler mode generates executable Go-managed memory; the runtime relies on Go’s process protections to prevent code injection. Interpreter mode has no executable WASM-derived memory, slightly reducing the JIT-related attack surface.
For environments where generated code is a concern (FIPS-strict, locked-down kernels with W^X enforcement at process level), use interpreter mode.
Step 6: Module Validation
Wazero validates by default, but reject features you do not support upfront:
runtimeConfig := wazero.NewRuntimeConfig().
WithCoreFeatures(api.CoreFeatureV2 |
api.CoreFeatureSignExtensionOps |
api.CoreFeatureNonTrappingFloatToIntConversion |
api.CoreFeatureBulkMemoryOperations).
// Explicitly do NOT enable: WebAssembly threads, multi-memory, GC.
The module fails to compile if it requires a disabled feature. Lock the feature set per environment; bumping it is a security review event.
Step 7: Plugin Lifecycle Management
For embedders running multiple plugins, isolate them by closing modules promptly:
type PluginManager struct {
rt wazero.Runtime
plugins map[string]api.Module
mu sync.Mutex
}
func (pm *PluginManager) Load(ctx context.Context, name string, wasm []byte) error {
pm.mu.Lock()
defer pm.mu.Unlock()
if existing, ok := pm.plugins[name]; ok {
existing.Close(ctx)
}
mod, err := pm.rt.InstantiateWithConfig(ctx, wasm,
makeModuleConfig(name))
if err != nil {
return err
}
pm.plugins[name] = mod
return nil
}
func (pm *PluginManager) Unload(ctx context.Context, name string) error {
pm.mu.Lock()
defer pm.mu.Unlock()
if mod, ok := pm.plugins[name]; ok {
mod.Close(ctx)
delete(pm.plugins, name)
}
return nil
}
Each module’s resources (linear memory, host-function bindings) are released on Close. Plugins kept alive longer than necessary leak.
Expected Behaviour
| Signal | Default Wazero | Hardened |
|---|---|---|
| Plugin loops indefinitely | Hangs the calling goroutine | Returns context.DeadlineExceeded after the configured timeout |
| Plugin allocates 200 MB | Succeeds (host memory permitting) | Trap; module’s memory.grow returns -1 |
| Plugin tries to read /etc/passwd | Succeeds if WASI inherits FS | EACCES (no FS mount for that path) |
| Plugin opens TCP socket | Succeeds if Preview 2 sockets enabled with default | Refused unless allowlist matches |
| Plugin writes 1 GB to stdout | Buffered into host memory | Bounded writer rejects after cap |
Plugin uses wasm threads |
Loaded if feature enabled | Refused at compile time |
Verify behavior:
// Test: confirm context deadline aborts an infinite loop.
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
_, err := mod.ExportedFunction("loop_forever").Call(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
// Test: confirm filesystem capability is scoped.
err = pm.Run(ctx, "plugin", "read /etc/passwd")
require.ErrorContains(t, err, "permission denied")
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Pure-Go runtime | No CGo; reproducible builds; no external library to audit | Slightly slower than Wasmtime in some benchmarks | Acceptable for plugin workloads; embedder typically dominates. |
| Compiler mode | Faster runtime | Larger memory per instance, generated code surface | Use interpreter mode for high-instance-count embeddings or FIPS environments. |
| Context-deadline CPU bound | Cleanest Go pattern; integrates with existing cancellation | Coarse-grained (operation boundary, not per-instruction) | Sufficient for most workloads; for billing-grade accounting, use a separate token bucket. |
| WASI capability via FSConfig | Filesystem isolation per host directory | Module sees logical paths; debugging maps differently | Document the path mapping per plugin; provide a debug build that logs FS access. |
| Bounded io.Writer | Prevents stdout flood | Plugins lose visibility when capped | Surface a metric for “plugin output truncated”; alert on cap hits. |
| Compilation cache | Cold-start speedup | Disk usage; stale-cache risk on Wazero version upgrade | Versioned cache directory; clean on version change. |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
Context not propagated to fn.Call |
Plugin hangs goroutine forever | Goroutine count rises; deadlines never fire | Always pass a derived ctx with deadline to fn.Call. Use linting rules to enforce. |
WithCloseOnContextDone(false) (default) |
Cancellation doesn’t promptly abort plugin | Plugins continue past deadline | Always WithCloseOnContextDone(true) in production. |
| Memory cap too low | Plugins fail under modest load | wazero_plugin_traps_total{kind="oom"} rises |
Profile representative plugins; raise cap to 1.5x observed peak. |
| Compiler mode generates faulty code | Plugin executes incorrectly | Subtle bugs that don’t appear in interpreter mode | Switch to interpreter mode; file a Wazero bug. |
| Module Close not called | Memory leak across plugin upgrades | RSS grows monotonically | Use defer mod.Close(ctx) rigorously; the manager pattern in Step 7. |
| WASI FSConfig leaks host filesystem | Plugin reads files outside the intended directory | strace or bpftrace shows opens outside the allowlist |
Use WithDirMount, never WithDirMount("/", "/"). Audit the embedder’s FS configuration code. |