WasmEdge Security: Sandboxing AI Inference, Plugins, and Serverless Functions
Problem
WasmEdge is a high-performance WebAssembly runtime optimised for edge, serverless, and AI workloads. Its plugin architecture supports WASI-NN (Neural Network API) for running models like LLaMA, Whisper, and Stable Diffusion inside a WASM sandbox, and WASI-Socket for networking. This makes WasmEdge a useful execution environment for untrusted or semi-trusted code: third-party functions, user-provided models, or externally-sourced WASM modules.
The WebAssembly sandbox provides a default-deny capability model — a module cannot access the filesystem, network, or host environment unless capabilities are explicitly granted. However, misconfigured WasmEdge deployments routinely grant more capability than needed:
- Overpermissive WASI preopen directories. Granting
--dir /:/(full filesystem access) is common in examples and persists into production. A malicious WASM module can read/etc/passwd, environment variables, and mounted secrets. - WASI-Socket without restriction. Enabling WASI-Socket without restricting permitted addresses allows a WASM module to make outbound connections to arbitrary hosts — data exfiltration from the sandbox.
- Unverified WASM modules. The
.wasmbinary being executed is not verified against a known-good hash or signature. A supply chain attack that replaces the WASM module is not detected. - AI model weight exposure. WASI-NN loads model weights from the host filesystem. If the preopen directory includes the model weight path, and the module is malicious, it can exfiltrate the model weights.
- Plugin loading without verification. WasmEdge plugins (native
.sofiles) run outside the WASM sandbox. A malicious or compromised plugin has full host access. Plugin provenance is often not checked. - Resource limits absent. A WASM module that enters an infinite loop or allocates unbounded memory can exhaust host resources. Without per-execution resource limits, a single runaway module affects all co-located workloads.
Target systems: WasmEdge 0.13+ (WASI-NN GA, WasiSocket); WASI-NN with llama.cpp, Whisper.cpp, and GGML backends; WasmEdge in Kubernetes via containerd-shim-wasmEdge; serverless platforms using WasmEdge (Fermyon Spin on WasmEdge, Knative + WasmEdge).
Threat Model
- Adversary 1 — Malicious WASM module via supply chain: An attacker compromises the registry or build pipeline that produces
.wasmartefacts. The replacement module is executed by WasmEdge with the same capabilities as the original — filesystem access, network, WASI-NN access. - Adversary 2 — Sandbox escape via overpermissive capabilities: A WASM module is granted filesystem access it doesn’t need (e.g.,
--dir /:/). The module reads host secrets, environment variables, or other tenants’ data mounted on the same host. - Adversary 3 — Network exfiltration via WASI-Socket: A malicious WASM module uses WASI-Socket to exfiltrate data to an attacker-controlled endpoint. Without socket restrictions, any outbound connection is permitted from inside the sandbox.
- Adversary 4 — AI model weight exfiltration: A WASM module executing inference has read access to the model weight directory. The module exfiltrates weights (which may be proprietary) over WASI-Socket.
- Adversary 5 — Plugin compromise: A native plugin loaded by WasmEdge is compromised or malicious. Plugins execute outside the WASM sandbox with full host process permissions — complete host compromise.
- Adversary 6 — Resource exhaustion: A tenant or attacker submits a WASM module that allocates all available memory or enters an infinite loop, causing OOM kills or CPU starvation for other tenants.
- Access level: Adversaries 1 and 5 require supply chain access. Adversaries 2, 3, and 4 exploit misconfiguration and only need to run code on the platform. Adversary 6 is a denial-of-service requiring only function submission access.
- Objective: Exfiltrate data or model weights, achieve host access via plugin, deny service to other tenants.
- Blast radius: An overpermissive capability grant exposes all filesystem content visible to the WasmEdge process. A compromised plugin has host process access. Resource exhaustion affects all co-located WASM workloads.
Configuration
Step 1: Minimal Capability Grants
WasmEdge capabilities are granted at execution time. Deny all by default and add only what the specific module requires:
# BAD: Full filesystem access — do not use in production.
wasmedge --dir /:/ module.wasm
# GOOD: Only the specific directories the module needs.
# For a function that reads from /data/input and writes to /data/output:
wasmedge \
--dir /data/input:/data/input:readonly \
--dir /data/output:/data/output \
module.wasm
# For AI inference with WASI-NN: only the model weight directory, readonly.
wasmedge \
--dir /models/llama:/models/llama:readonly \
--env NN_PRELOAD="default:GGML:AUTO:/models/llama/llama-2-7b.gguf" \
--wasi-nn-preload default:GGML:AUTO:/models/llama/llama-2-7b.gguf \
inference.wasm
# No network access by default.
# WASI-Socket requires --allow-af-inet or --allow-af-inet6 to enable.
# Grant only if the module explicitly requires outbound network access.
Capability matrix — grant only the row relevant to each module:
| Module Type | --dir |
WASI-Socket | WASI-NN | --env |
|---|---|---|---|---|
| Pure compute | None | No | No | No |
| File transformer | Input+output only, readonly input | No | No | No |
| AI inference (local) | Model dir readonly | No | Yes | Model path only |
| Webhook handler | None | Specific IPs only | No | Config only |
| Multi-tenant function | Per-tenant tmpdir | No | No | Tenant ID only |
Step 2: WASI-Socket Restrictions
If a module requires network access, restrict permitted addresses using WasmEdge’s socket capability controls:
// In Rust: use the wasmedge-wasi-helper or wasi-sdk socket bindings.
// The host controls which addresses are reachable — application code cannot
// open sockets to addresses not in the allow-list.
// Host-side: WasmEdge 0.13+ supports TCP/UDP address restrictions via
// the --allow-ip flag or via configuration file.
# wasmedge-config.toml — capability configuration file.
[wasi]
# Restrict socket access to specific addresses.
allowed_socket_addresses = [
"10.0.1.50:8080", # Internal inference gateway only.
"10.0.1.51:8080", # Fallback inference node.
]
# Block all other socket connections.
allow_all_socket_addresses = false
[resource_limits]
memory_pages_max = 65536 # 4 GiB (65536 * 64KiB pages) maximum per execution.
fuel_limit = 10000000000 # Instruction count limit (prevents infinite loops).
# Use the config file at execution time.
wasmedge --config wasmedge-config.toml module.wasm
Step 3: Verify WASM Module Integrity
Never execute unverified WASM modules. Verify the module against a known-good digest or a Sigstore signature before execution:
# Generate SHA-256 digest at build time and store in a trusted location.
sha256sum inference.wasm > inference.wasm.sha256
# Store inference.wasm.sha256 in a separate trusted path, not alongside the WASM.
# At execution time, verify before running.
verify_and_run() {
local MODULE=$1
local EXPECTED_SHA256=$2
ACTUAL=$(sha256sum "$MODULE" | awk '{print $1}')
if [ "$ACTUAL" != "$EXPECTED_SHA256" ]; then
echo "INTEGRITY CHECK FAILED: $MODULE"
echo "Expected: $EXPECTED_SHA256"
echo "Actual: $ACTUAL"
exit 1
fi
wasmedge --config /etc/wasmedge/config.toml "$MODULE"
}
verify_and_run inference.wasm "$(cat /etc/wasmedge/inference.wasm.sha256)"
For supply chain provenance, use Sigstore:
# Sign the WASM module at build time (CI pipeline).
cosign sign-blob \
--key cosign.key \
--bundle inference.wasm.bundle \
inference.wasm
# Verify signature before execution.
cosign verify-blob \
--key cosign.pub \
--bundle inference.wasm.bundle \
inference.wasm || { echo "Signature verification failed"; exit 1; }
Step 4: Plugin Security
WasmEdge plugins are native shared libraries that extend the runtime. They execute outside the sandbox. Manage plugins with the same care as host binaries:
# List installed plugins.
wasmedge --list-plugins
# Verify plugin integrity.
sha256sum /usr/local/lib/wasmedge/*.so | sort > /etc/wasmedge/plugin-manifest.sha256
# At startup or before execution:
sha256sum --check /etc/wasmedge/plugin-manifest.sha256 || {
echo "Plugin integrity check failed";
exit 1;
}
# Use only plugins from the official WasmEdge release.
# Do not load community or third-party plugins without source review.
# Plugin directory should be read-only at runtime.
chmod 444 /usr/local/lib/wasmedge/*.so
chown root:root /usr/local/lib/wasmedge/*.so
Restrict which plugins load in the configuration:
# wasmedge-config.toml
[plugins]
# Allowlist: only load specific plugins by exact path.
allowed_plugins = [
"/usr/local/lib/wasmedge/libwasmedgePluginWasiNN.so",
]
# All other plugins are rejected even if present on disk.
Step 5: Resource Limits
Enforce per-execution memory and instruction limits to prevent resource exhaustion:
# wasmedge-config.toml
[resource_limits]
# Memory: 64KiB pages. 16384 pages = 1 GiB.
memory_pages_max = 16384
# Fuel: instruction counter. Execution halts when fuel exhausted.
# 1e10 instructions ≈ several seconds of computation — tune per workload.
fuel_limit = 10000000000
# Execution timeout (seconds). Enforced at the host level.
# Combine with OS-level timeout (timeout(1) or systemd-run --timeout).
# Enforce wall-clock timeout in addition to instruction limit.
timeout 30 wasmedge \
--config /etc/wasmedge/config.toml \
inference.wasm
# Exits with code 124 if timeout exceeded.
# In Kubernetes: set resource limits on the pod.
# containerd-shim-wasmedge respects pod resource limits.
# kubernetes/wasmedge-pod.yaml
apiVersion: v1
kind: Pod
spec:
runtimeClassName: wasmedge # Uses containerd-shim-wasmedge.
containers:
- name: inference
image: ghcr.io/example/inference:sha256-abc123
resources:
limits:
memory: "1Gi"
cpu: "2"
requests:
memory: "512Mi"
cpu: "500m"
securityContext:
runAsNonRoot: true
runAsUser: 65534
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
Step 6: Multi-Tenant Isolation
When running WASM modules from multiple tenants on the same host, enforce per-tenant isolation:
# wasmedge_runner.py — multi-tenant execution controller.
import subprocess
import tempfile
import os
from pathlib import Path
def run_tenant_module(
tenant_id: str,
wasm_path: str,
expected_sha256: str,
input_data: bytes,
) -> bytes:
# 1. Verify module integrity.
import hashlib
actual = hashlib.sha256(Path(wasm_path).read_bytes()).hexdigest()
if actual != expected_sha256:
raise ValueError(f"Integrity check failed for {wasm_path}")
# 2. Create per-tenant tmpdir — no shared state between tenants.
with tempfile.TemporaryDirectory(prefix=f"tenant-{tenant_id}-") as tmpdir:
input_path = Path(tmpdir) / "input"
output_path = Path(tmpdir) / "output"
input_path.write_bytes(input_data)
# 3. Run WasmEdge with minimal permissions.
result = subprocess.run(
[
"wasmedge",
"--config", "/etc/wasmedge/config.toml",
"--dir", f"{tmpdir}:{tmpdir}", # Only tenant tmpdir.
"--env", f"TENANT_ID={tenant_id}",
wasm_path,
],
capture_output=True,
timeout=30, # Wall-clock timeout.
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"WASM execution failed: {result.stderr.decode()}")
return output_path.read_bytes() if output_path.exists() else b""
Step 7: Containerd RuntimeClass for Kubernetes
Use the official WasmEdge containerd shim for Kubernetes workloads:
# Install containerd-shim-wasmedge on nodes.
apt-get install wasmedge-containerd-shim
# Configure containerd runtime.
cat >> /etc/containerd/config.toml << 'EOF'
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.wasmedge]
runtime_type = "io.containerd.wasmedge.v1"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.wasmedge.options]
ConfigPath = "/etc/wasmedge/config.toml"
EOF
systemctl restart containerd
# kubernetes/runtimeclass-wasmedge.yaml
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: wasmedge
handler: wasmedge
scheduling:
nodeSelector:
wasmedge: "true" # Only schedule on nodes with the shim installed.
tolerations:
- key: "wasmedge"
operator: "Exists"
effect: "NoSchedule"
Apply Kyverno policy to enforce WasmEdge pods use restricted security contexts:
# kyverno/wasmedge-security-policy.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: wasmedge-security-policy
spec:
validationFailureAction: Enforce
rules:
- name: require-non-root
match:
any:
- resources:
kinds: ["Pod"]
selector:
matchLabels:
runtime: "wasmedge"
validate:
message: "WasmEdge pods must run as non-root."
pattern:
spec:
containers:
- securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
Step 8: Telemetry
wasmedge_executions_total{tenant, module, status} counter
wasmedge_execution_duration_seconds{tenant, module} histogram
wasmedge_memory_pages_used{tenant, module} gauge
wasmedge_fuel_consumed{tenant, module} counter
wasmedge_integrity_check_failures_total{module} counter
wasmedge_socket_connections_total{tenant, destination} counter
wasmedge_plugin_load_total{plugin, status} counter
wasmedge_resource_limit_exceeded_total{tenant, limit_type} counter
Alert on:
wasmedge_integrity_check_failures_totalnon-zero — a WASM module failed verification; do not execute; investigate the module source.wasmedge_resource_limit_exceeded_total— a module hit its memory or fuel limit; may indicate a runaway computation or deliberate resource exhaustion.wasmedge_socket_connections_totalwith unexpecteddestination— a module attempted a connection to an address not in the allow-list; investigate for data exfiltration.wasmedge_plugin_load_total{status="failed"}— a plugin failed its integrity check or was not on the allowlist; investigate before restarting the runtime.wasmedge_execution_duration_secondsP99 spikes — a module is taking significantly longer than baseline; combined with resource limit monitoring, indicates possible abuse.
Expected Behaviour
| Signal | Unconfigured WasmEdge | Hardened WasmEdge |
|---|---|---|
Malicious module reads host /etc |
Succeeds if --dir /:/ granted |
Blocked: only permitted directories accessible |
| Module exfiltrates data over network | Succeeds if WASI-Socket enabled | Blocked: only specific IPs permitted, or socket disabled |
| Tampered WASM module executed | Executed without detection | Integrity check fails; execution aborted |
| AI model weights exfiltrated | Readable if model dir in preopen | Model dir mounted readonly; socket disabled for inference module |
| Compromised plugin loaded | Loads and executes with host access | Plugin allowlist blocks non-approved plugins; hash check at load |
| Runaway module exhausts host memory | OOM kill affects all tenants | Fuel and memory limits halt the module; other modules unaffected |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Minimal capability grants | Strong sandbox; limits exfiltration | Requires knowing exact capability needs per module | Document capability requirements at module build time |
| Socket address allowlist | Prevents arbitrary outbound connections | Must update allowlist when upstream endpoints change | Manage via configuration file; allowlist changes require review |
| Module integrity verification | Detects supply chain tampering | Build pipeline must produce and distribute hashes/signatures | Integrate with Sigstore via CI; use OCI artifact signing |
| Plugin allowlist | Prevents loading malicious plugins | Restricts extending the runtime | Only add plugins after code review; pin to specific versions |
| Resource limits | Prevents exhaustion; enables multi-tenancy | Must be tuned per workload; too-low fuel limit breaks valid modules | Profile execution in staging; set fuel at 10× observed maximum |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Integrity check blocks legitimate update | Module fails verification after CI update | wasmedge_integrity_check_failures_total alert; execution blocked |
Update the expected hash alongside the module in the deployment pipeline |
| Fuel limit too low for large model | Inference fails with FuelExhausted error |
Execution failure logs; wasmedge_resource_limit_exceeded_total |
Increase fuel limit; profile the model’s instruction count in staging |
| WASI-Socket allowlist missing required endpoint | Module fails to connect to upstream API | Execution logs show connection refused; wasmedge_socket_connections_total shows drop |
Add endpoint to allowlist; review why module needs the connection |
| Plugin integrity check fails after host update | WasmEdge refuses to load plugin | wasmedge_plugin_load_total{status="failed"} |
Regenerate plugin manifest after planned update; alert on unplanned changes |
| Memory limit OOM on large batch | Module killed mid-inference; partial output | wasmedge_resource_limit_exceeded_total{limit_type="memory"} |
Increase memory limit or split batch; add input size validation upstream |