WebAssembly Security Sandbox: 5 Production Patterns from Isolation to Capability Model
WebAssembly Security Sandbox: 5 Production Patterns from Isolation to Capability Model
In 2026, WebAssembly has moved from the browser to core production scenarios like edge computing, Serverless, and plugin systems. But "security" is never something that Wasm's linear memory model can handle on its own. An unconfigured WASI module can directly read host environment variables; a plugin without memory limits can drag the host process into an OOM abyss; an unverified third-party Wasm component may harbor supply chain attack trojans. This article covers 5 production patterns — isolation, capability, configuration, architecture, and audit — to help you build a truly secure Wasm runtime.
| Security Threat | Container Escape | Wasm Sandbox Escape |
|---|---|---|
| Attack surface | Syscalls + Namespace | Linear memory + WASI |
| Historical CVEs | CVE-2024-21626 etc. | Very few (theoretical) |
| Defense in depth | Seccomp + AppArmor | Capability model + Memory limits + Timeout |
| Least privilege | Manual configuration | WASI default deny |
| Audit granularity | Process-level | Function-level |
Core Concepts
| Concept | Full Name | Description |
|---|---|---|
| Linear Memory | — | Wasm module memory model: contiguous byte array, modules cannot access out of bounds, the foundation of sandbox isolation |
| WASI | WebAssembly System Interface | Standard interface for Wasm to access the OS; based on capability model, default deny all system access |
| Capability Model | — | Security model where permissions must be explicitly granted rather than revoked after being granted by default |
| Sandbox | — | Isolated execution environment; Wasm modules run in a sandbox and cannot directly access host resources |
| WasmEdge | — | Lightweight Wasm runtime supporting AOT compilation, WASI-NN, resource limits, and other security features |
| WASI-NN | WASI Neural Network | Standard interface for Wasm AI inference; controls model access through capability model |
| Supply Chain Attack | — | Attack by injecting malicious code into third-party dependencies; also exists in Wasm scenarios |
Problem Analysis: Why Wasm Security Can't Rely on "Natural Isolation" Alone
1. Linear Memory Isolation ≠ System-Level Isolation
Wasm's linear memory model does prevent modules from reading and writing host memory out of bounds, but this is only memory-level isolation. Through WASI, Wasm modules can access the filesystem, network, and environment variables — if WASI permissions are misconfigured, "natural isolation" is an empty promise.
2. WASI Default Permissions Are Too Permissive
Many runtimes allow WASI modules to access the current working directory, environment variables, and network by default. In production, a malicious Wasm module can read /etc/passwd through WASI, make network requests to exfiltrate data, or even perform side-channel attacks through poll_oneoff.
3. Side-Channel Attacks Are Real
Spectre-style attacks are also possible in Wasm: through high-precision timers (performance.now or WASI's clock_time_get), a malicious module can infer the host process's memory layout. Research in 2025 has already demonstrated the feasibility of Wasm side-channels.
4. Supply Chain Attacks Are Ubiquitous
Your Wasm module depends on 10 third-party crates, one of which has been maliciously tampered with — this has happened multiple times in the Rust ecosystem (e.g., the rustdecimal incident). Wasm module compiled binaries are harder to audit, making supply chain attacks more隐蔽.
5. Multi-Tenant Scenarios Lack Resource Isolation
In plugin systems or Serverless platforms, multiple Wasm modules share the same runtime process. Without resource quotas (memory limits, CPU time, invocation counts), a malicious or runaway module can exhaust all resources, affecting other tenants.
Step-by-Step Patterns
Pattern 1: Wasm Linear Memory Isolation — Memory Bounds and Escape Prevention
;; Linear memory declaration: 1 page initial (64KB), max 256 pages (16MB)
(module
(memory (export "memory") 1 256)
;; Safe memory access: with bounds checking
(func (export "safe_read") (param $offset i32) (param $len i32) (result i32)
(local $end i32)
;; Calculate end position
(local.set $end (i32.add (local.get $offset) (local.get $len)))
;; Bounds check: ensure not exceeding memory range
(if (i32.gt_u (local.get $end) (i32.mul (memory.size) (i32.const 65536)))
(then (return (i32.const 0))) ;; Return error code
)
;; Safe read
(i32.load (local.get $offset))
)
;; Safe memory write: with bounds checking
(func (export "safe_write") (param $offset i32) (param $value i32) (result i32)
(local $end i32)
(local.set $end (i32.add (local.get $offset) (i32.const 4)))
(if (i32.gt_u (local.get $end) (i32.mul (memory.size) (i32.const 65536)))
(then (return (i32.const 0)))
)
(i32.store (local.get $offset) (local.get $value))
(i32.const 1) ;; Success
)
)
use std::alloc::{alloc, dealloc, Layout};
pub struct SafeMemory {
buffer: *mut u8,
size: usize,
used: usize,
}
impl SafeMemory {
pub fn new(size: usize) -> Self {
let layout = Layout::from_size_align(size, 16).expect("invalid layout");
let buffer = unsafe { alloc(layout) };
if buffer.is_null() {
panic!("memory allocation failed");
}
SafeMemory { buffer, size, used: 0 }
}
pub fn write(&mut self, data: &[u8]) -> Result<usize, &'static str> {
if self.used + data.len() > self.size {
return Err("buffer overflow: exceeds memory bounds");
}
unsafe {
std::ptr::copy_nonoverlapping(
data.as_ptr(),
self.buffer.add(self.used),
data.len(),
);
}
let offset = self.used;
self.used += data.len();
Ok(offset)
}
pub fn read(&self, offset: usize, len: usize) -> Result<Vec<u8>, &'static str> {
if offset + len > self.used {
return Err("out of bounds read: exceeds used memory");
}
let mut result = vec![0u8; len];
unsafe {
std::ptr::copy_nonoverlapping(
self.buffer.add(offset),
result.as_mut_ptr(),
len,
);
}
Ok(result)
}
pub fn available(&self) -> usize {
self.size - self.used
}
}
impl Drop for SafeMemory {
fn drop(&mut self) {
let layout = Layout::from_size_align(self.size, 16).expect("invalid layout");
unsafe { dealloc(self.buffer, layout) };
}
}
unsafe impl Send for SafeMemory {}
unsafe impl Sync for SafeMemory {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safe_memory_bounds() {
let mut mem = SafeMemory::new(1024);
assert!(mem.write(b"hello").is_ok());
assert!(mem.read(0, 5).is_ok());
assert_eq!(mem.read(0, 5).unwrap(), b"hello");
assert!(mem.read(999, 10).is_err());
}
}
# Compile WAT to Wasm
wat2wasm safe_memory.wat -o safe_memory.wasm
# Validate memory bounds
wasm-validate safe_memory.wasm
# Run with WasmEdge and limit memory
wasmedge --memory-page-limit 256 safe_memory.wasm
# Run with Wasmtime and limit memory
wasmtime run --max-wasm-stack 512000 --wasm-max-memory 16 safe_memory.wasm
Pattern 2: WASI Capability-Based Security — Filesystem, Network, and Environment Permissions
use std::collections::HashMap;
#[derive(Clone, Debug)]
pub enum WasiPermission {
FilesystemRead { path: String },
FilesystemWrite { path: String },
NetworkOutbound { host: String, port: u16 },
EnvironmentVar { key: String },
ClockAccess,
}
#[derive(Clone, Debug)]
pub struct WasiCapabilitySet {
permissions: Vec<WasiPermission>,
denied_defaults: Vec<String>,
}
impl WasiCapabilitySet {
pub fn deny_all() -> Self {
WasiCapabilitySet {
permissions: vec![],
denied_defaults: vec![
"fs.read".into(),
"fs.write".into(),
"net.outbound".into(),
"env.all".into(),
"clock.highres".into(),
],
}
}
pub fn allow_fs_read(mut self, path: &str) -> Self {
self.permissions.push(WasiPermission::FilesystemRead { path: path.to_string() });
self.denied_defaults.retain(|d| d != "fs.read");
self
}
pub fn allow_fs_write(mut self, path: &str) -> Self {
self.permissions.push(WasiPermission::FilesystemWrite { path: path.to_string() });
self.denied_defaults.retain(|d| d != "fs.write");
self
}
pub fn allow_net_outbound(mut self, host: &str, port: u16) -> Self {
self.permissions.push(WasiPermission::NetworkOutbound {
host: host.to_string(),
port,
});
self.denied_defaults.retain(|d| d != "net.outbound");
self
}
pub fn allow_env(mut self, key: &str) -> Self {
self.permissions.push(WasiPermission::EnvironmentVar { key: key.to_string() });
self
}
pub fn check_fs_read(&self, path: &str) -> bool {
self.permissions.iter().any(|p| match p {
WasiPermission::FilesystemRead { path: allowed } => path.starts_with(allowed),
_ => false,
})
}
pub fn check_fs_write(&self, path: &str) -> bool {
self.permissions.iter().any(|p| match p {
WasiPermission::FilesystemWrite { path: allowed } => path.starts_with(allowed),
_ => false,
})
}
pub fn check_net(&self, host: &str, port: u16) -> bool {
self.permissions.iter().any(|p| match p {
WasiPermission::NetworkOutbound { host: allowed, port: allowed_port } => {
host == allowed && port == *allowed_port
}
_ => false,
})
}
pub fn check_env(&self, key: &str) -> bool {
self.permissions.iter().any(|p| match p {
WasiPermission::EnvironmentVar { key: allowed } => key == allowed,
_ => false,
})
}
}
fn main() {
let caps = WasiCapabilitySet::deny_all()
.allow_fs_read("/app/data")
.allow_fs_write("/app/output")
.allow_net_outbound("api.example.com", 443)
.allow_env("APP_MODE");
println!("Read /app/data/config.json: {}", caps.check_fs_read("/app/data/config.json"));
println!("Read /etc/passwd: {}", caps.check_fs_read("/etc/passwd"));
println!("Write /app/output/result.json: {}", caps.check_fs_write("/app/output/result.json"));
println!("Write /tmp/malware: {}", caps.check_fs_write("/tmp/malware"));
println!("Access api.example.com:443: {}", caps.check_net("api.example.com", 443));
println!("Access evil.com:443: {}", caps.check_net("evil.com", 443));
println!("Read APP_MODE: {}", caps.check_env("APP_MODE"));
println!("Read DATABASE_URL: {}", caps.check_env("DATABASE_URL"));
}
# WasmEdge: Run WASI module with minimal privileges
wasmedge \
--dir /app/data:/app/data:readonly \
--dir /app/output:/app/output:writeonly \
--env APP_MODE=production \
--allow-env APP_MODE \
module.wasm
# Wasmtime: WASI preview2 permission configuration
wasmtime run \
--wasi preview2 \
--dir /app/data::/app/data:readonly \
--dir /app/output::/app/output:writeonly \
--env APP_MODE=production \
module.wasm
# Wasmer: Restrict network and filesystem
wasmer run module.wasm \
--dir /app/data \
--net=deny \
--env=deny
[package]
name = "wasi-capability-demo"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[profile.release]
opt-level = 3
lto = true
codegen-units = 1
strip = true
Pattern 3: WasmEdge Sandbox Configuration — Resource Limits, Timeout, Memory Caps
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
#[derive(Serialize, Deserialize, Clone)]
pub struct SandboxConfig {
pub max_memory_pages: u32,
pub max_memory_bytes: u64,
pub execution_timeout_ms: u64,
pub max_table_size: u32,
pub max_gas: u64,
pub allowed_hosts: Vec<String>,
pub allowed_env_vars: Vec<String>,
pub max_fs_read_bytes: u64,
pub max_fs_write_bytes: u64,
pub max_network_requests: u32,
}
impl SandboxConfig {
pub fn strict() -> Self {
SandboxConfig {
max_memory_pages: 256,
max_memory_bytes: 16 * 1024 * 1024,
execution_timeout_ms: 5000,
max_table_size: 1024,
max_gas: 1_000_000,
allowed_hosts: vec![],
allowed_env_vars: vec![],
max_fs_read_bytes: 10 * 1024 * 1024,
max_fs_write_bytes: 5 * 1024 * 1024,
max_network_requests: 0,
}
}
pub fn moderate() -> Self {
SandboxConfig {
max_memory_pages: 512,
max_memory_bytes: 32 * 1024 * 1024,
execution_timeout_ms: 30000,
max_table_size: 4096,
max_gas: 10_000_000,
allowed_hosts: vec!["api.example.com".into()],
allowed_env_vars: vec!["APP_MODE".into(), "LOG_LEVEL".into()],
max_fs_read_bytes: 50 * 1024 * 1024,
max_fs_write_bytes: 20 * 1024 * 1024,
max_network_requests: 100,
}
}
pub fn permissive() -> Self {
SandboxConfig {
max_memory_pages: 2048,
max_memory_bytes: 128 * 1024 * 1024,
execution_timeout_ms: 300000,
max_table_size: 16384,
max_gas: 100_000_000,
allowed_hosts: vec!["*".into()],
allowed_env_vars: vec!["*".into()],
max_fs_read_bytes: 500 * 1024 * 1024,
max_fs_write_bytes: 200 * 1024 * 1024,
max_network_requests: 10000,
}
}
}
#[derive(Serialize, Deserialize)]
pub struct ExecutionResult {
pub success: bool,
pub output: String,
pub memory_used_bytes: u64,
pub execution_time_ms: u64,
pub gas_used: u64,
pub violation: Option<String>,
}
pub struct SandboxExecutor {
config: SandboxConfig,
}
impl SandboxExecutor {
pub fn new(config: SandboxConfig) -> Self {
SandboxExecutor { config }
}
pub fn execute(&self, module_path: &str, function: &str, input: &str) -> ExecutionResult {
let start = Instant::now();
let memory_limit = format!("--memory-page-limit {}", self.config.max_memory_pages);
let timeout = format!("--time-limit {}ms", self.config.execution_timeout_ms);
let mut dir_args: Vec<String> = vec![];
let mut env_args: Vec<String> = vec![];
for host in &self.config.allowed_hosts {
dir_args.push("--allow-host".to_string());
dir_args.push(host.clone());
}
for env_var in &self.config.allowed_env_vars {
env_args.push("--env".to_string());
env_args.push(format!("{}=", env_var));
}
let output = std::process::Command::new("wasmedge")
.arg(&memory_limit)
.arg(&timeout)
.args(&dir_args)
.args(&env_args)
.arg(module_path)
.arg(function)
.output();
let elapsed = start.elapsed();
match output {
Ok(result) => {
let stdout = String::from_utf8_lossy(&result.stdout).to_string();
let stderr = String::from_utf8_lossy(&result.stderr).to_string();
if result.status.success() {
ExecutionResult {
success: true,
output: stdout,
memory_used_bytes: 0,
execution_time_ms: elapsed.as_millis() as u64,
gas_used: 0,
violation: None,
}
} else {
let violation = if stderr.contains("out of memory") {
Some("memory_limit_exceeded".to_string())
} else if stderr.contains("time limit") {
Some("execution_timeout".to_string())
} else {
Some(format!("runtime_error: {}", stderr.trim()))
};
ExecutionResult {
success: false,
output: stdout,
memory_used_bytes: 0,
execution_time_ms: elapsed.as_millis() as u64,
gas_used: 0,
violation,
}
}
}
Err(e) => ExecutionResult {
success: false,
output: String::new(),
memory_used_bytes: 0,
execution_time_ms: elapsed.as_millis() as u64,
gas_used: 0,
violation: Some(format!("spawn_failed: {}", e)),
},
}
}
}
fn main() {
let configs = vec![
("strict", SandboxConfig::strict()),
("moderate", SandboxConfig::moderate()),
("permissive", SandboxConfig::permissive()),
];
for (name, config) in configs {
println!("=== {} sandbox ===", name);
println!(" max_memory: {}MB", config.max_memory_bytes / 1024 / 1024);
println!(" timeout: {}ms", config.execution_timeout_ms);
println!(" max_gas: {}", config.max_gas);
println!(" allowed_hosts: {:?}", config.allowed_hosts);
println!(" allowed_env: {:?}", config.allowed_env_vars);
}
}
# WasmEdge strict sandbox: 16MB memory + 5s timeout
wasmedge \
--memory-page-limit 256 \
--time-limit 5000 \
--allow-host api.example.com \
--env APP_MODE=production \
/app/plugin.wasm
# WasmEdge moderate sandbox: 32MB memory + 30s timeout
wasmedge \
--memory-page-limit 512 \
--time-limit 30000 \
--allow-host api.example.com \
--allow-host cdn.example.com \
--env APP_MODE=production \
--env LOG_LEVEL=info \
/app/plugin.wasm
# WasmEdge permissive sandbox: 128MB memory + 5min timeout (trusted modules only)
wasmedge \
--memory-page-limit 2048 \
--time-limit 300000 \
/app/trusted_module.wasm
apiVersion: v1
kind: ConfigMap
metadata:
name: wasm-sandbox-profiles
data:
strict.yaml: |
maxMemoryPages: 256
executionTimeoutMs: 5000
allowedHosts: []
allowedEnvVars: []
maxFsReadBytes: 10485760
maxFsWriteBytes: 5242880
maxNetworkRequests: 0
moderate.yaml: |
maxMemoryPages: 512
executionTimeoutMs: 30000
allowedHosts:
- api.example.com
allowedEnvVars:
- APP_MODE
- LOG_LEVEL
maxFsReadBytes: 52428800
maxFsWriteBytes: 20971520
maxNetworkRequests: 100
permissive.yaml: |
maxMemoryPages: 2048
executionTimeoutMs: 300000
allowedHosts:
- "*"
allowedEnvVars:
- "*"
maxFsReadBytes: 524288000
maxFsWriteBytes: 209715200
maxNetworkRequests: 10000
Pattern 4: Plugin Isolation Architecture — Multi-Tenant Resource Quotas
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, AtomicU32, Ordering};
use std::sync::Arc;
#[derive(Serialize, Deserialize, Clone)]
pub struct TenantQuota {
pub tenant_id: String,
pub max_memory_mb: u64,
pub max_cpu_time_ms: u64,
pub max_invocations_per_min: u32,
pub max_network_requests: u32,
pub allowed_paths: Vec<String>,
pub allowed_hosts: Vec<String>,
}
pub struct TenantResourceUsage {
memory_used_bytes: AtomicU64,
cpu_time_used_ms: AtomicU64,
invocations_count: AtomicU32,
network_requests: AtomicU32,
}
impl TenantResourceUsage {
pub fn new() -> Self {
TenantResourceUsage {
memory_used_bytes: AtomicU64::new(0),
cpu_time_used_ms: AtomicU64::new(0),
invocations_count: AtomicU32::new(0),
network_requests: AtomicU32::new(0),
}
}
pub fn try_allocate_memory(&self, bytes: u64, limit_mb: u64) -> bool {
let limit_bytes = limit_mb * 1024 * 1024;
loop {
let current = self.memory_used_bytes.load(Ordering::Relaxed);
if current + bytes > limit_bytes {
return false;
}
if self.memory_used_bytes.compare_exchange_weak(
current,
current + bytes,
Ordering::SeqCst,
Ordering::Relaxed,
).is_ok() {
return true;
}
}
}
pub fn try_invoke(&self, limit_per_min: u32) -> bool {
let current = self.invocations_count.load(Ordering::Relaxed);
if current >= limit_per_min {
return false;
}
self.invocations_count.fetch_add(1, Ordering::SeqCst);
true
}
pub fn try_network_request(&self, limit: u32) -> bool {
let current = self.network_requests.load(Ordering::Relaxed);
if current >= limit {
return false;
}
self.network_requests.fetch_add(1, Ordering::SeqCst);
true
}
pub fn reset_minute_counters(&self) {
self.invocations_count.store(0, Ordering::SeqCst);
self.network_requests.store(0, Ordering::SeqCst);
}
}
pub struct PluginIsolationManager {
tenants: HashMap<String, (TenantQuota, Arc<TenantResourceUsage>)>,
}
impl PluginIsolationManager {
pub fn new() -> Self {
PluginIsolationManager {
tenants: HashMap::new(),
}
}
pub fn register_tenant(&mut self, quota: TenantQuota) {
let usage = Arc::new(TenantResourceUsage::new());
self.tenants.insert(quota.tenant_id.clone(), (quota, usage));
}
pub fn check_invocation(&self, tenant_id: &str) -> Result<(), String> {
let (quota, usage) = self.tenants.get(tenant_id)
.ok_or_else(|| format!("unknown tenant: {}", tenant_id))?;
if !usage.try_invoke(quota.max_invocations_per_min) {
return Err(format!(
"tenant {} exceeded invocation limit: {}/min",
tenant_id, quota.max_invocations_per_min
));
}
Ok(())
}
pub fn check_memory(&self, tenant_id: &str, bytes: u64) -> Result<(), String> {
let (quota, usage) = self.tenants.get(tenant_id)
.ok_or_else(|| format!("unknown tenant: {}", tenant_id))?;
if !usage.try_allocate_memory(bytes, quota.max_memory_mb) {
return Err(format!(
"tenant {} exceeded memory limit: {}MB",
tenant_id, quota.max_memory_mb
));
}
Ok(())
}
pub fn check_network(&self, tenant_id: &str, host: &str) -> Result<(), String> {
let (quota, usage) = self.tenants.get(tenant_id)
.ok_or_else(|| format!("unknown tenant: {}", tenant_id))?;
if !quota.allowed_hosts.iter().any(|h| host == h || h == "*") {
return Err(format!(
"tenant {} not allowed to access host: {}",
tenant_id, host
));
}
if !usage.try_network_request(quota.max_network_requests) {
return Err(format!(
"tenant {} exceeded network request limit: {}",
tenant_id, quota.max_network_requests
));
}
Ok(())
}
pub fn check_fs(&self, tenant_id: &str, path: &str) -> Result<(), String> {
let (quota, _) = self.tenants.get(tenant_id)
.ok_or_else(|| format!("unknown tenant: {}", tenant_id))?;
if !quota.allowed_paths.iter().any(|p| path.starts_with(p)) {
return Err(format!(
"tenant {} not allowed to access path: {}",
tenant_id, path
));
}
Ok(())
}
}
fn main() {
let mut manager = PluginIsolationManager::new();
manager.register_tenant(TenantQuota {
tenant_id: "tenant-alpha".into(),
max_memory_mb: 32,
max_cpu_time_ms: 5000,
max_invocations_per_min: 100,
max_network_requests: 50,
allowed_paths: vec!["/data/alpha".into()],
allowed_hosts: vec!["api.alpha.com".into()],
});
manager.register_tenant(TenantQuota {
tenant_id: "tenant-beta".into(),
max_memory_mb: 64,
max_cpu_time_ms: 10000,
max_invocations_per_min: 200,
max_network_requests: 100,
allowed_paths: vec!["/data/beta".into(), "/shared".into()],
allowed_hosts: vec!["api.beta.com".into(), "cdn.example.com".into()],
});
println!("tenant-alpha invoke: {:?}", manager.check_invocation("tenant-alpha"));
println!("tenant-alpha fs /data/alpha/config.json: {:?}", manager.check_fs("tenant-alpha", "/data/alpha/config.json"));
println!("tenant-alpha fs /data/beta/secret: {:?}", manager.check_fs("tenant-alpha", "/data/beta/secret"));
println!("tenant-alpha net api.alpha.com: {:?}", manager.check_network("tenant-alpha", "api.alpha.com"));
println!("tenant-alpha net evil.com: {:?}", manager.check_network("tenant-alpha", "evil.com"));
println!("tenant-beta net cdn.example.com: {:?}", manager.check_network("tenant-beta", "cdn.example.com"));
}
# Multi-tenant WasmEdge: independent sandbox per tenant
# Tenant Alpha
wasmedge \
--memory-page-limit 512 \
--time-limit 5000 \
--dir /data/alpha:/data/alpha:readonly \
--allow-host api.alpha.com \
/plugins/alpha/worker.wasm &
# Tenant Beta
wasmedge \
--memory-page-limit 1024 \
--time-limit 10000 \
--dir /data/beta:/data/beta:readonly \
--dir /shared:/shared:readonly \
--allow-host api.beta.com \
--allow-host cdn.example.com \
/plugins/beta/worker.wasm &
apiVersion: apps/v1
kind: Deployment
metadata:
name: wasm-plugin-router
spec:
replicas: 3
selector:
matchLabels:
app: wasm-plugin-router
template:
spec:
containers:
- name: router
image: toolsku/wasm-plugin-router:1.0
env:
- name: TENANT_CONFIG
value: /config/tenants.yaml
volumeMounts:
- name: config
mountPath: /config
- name: plugins
mountPath: /plugins
resources:
limits:
cpu: "2"
memory: "512Mi"
volumes:
- name: config
configMap:
name: tenant-quotas
- name: plugins
persistentVolumeClaim:
claimName: wasm-plugins-pvc
Pattern 5: Production Security Audit — Vulnerability Scanning and Supply Chain Verification
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)]
pub struct VulnerabilityReport {
pub module_hash: String,
pub module_size: u64,
pub scan_time: String,
pub risk_level: RiskLevel,
pub findings: Vec<Finding>,
pub dependencies: Vec<DependencyInfo>,
pub wasm_features: Vec<WasmFeature>,
}
#[derive(Serialize, Deserialize, Clone, PartialEq)]
pub enum RiskLevel {
Critical,
High,
Medium,
Low,
Info,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct Finding {
pub id: String,
pub category: FindingCategory,
pub severity: RiskLevel,
pub description: String,
pub recommendation: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub enum FindingCategory {
MemorySafety,
WasiPermission,
SideChannel,
SupplyChain,
ResourceExhaustion,
InformationLeak,
CodeInjection,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DependencyInfo {
pub name: String,
pub version: String,
pub hash: String,
pub source: String,
pub verified: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct WasmFeature {
pub name: String,
pub risk: RiskLevel,
pub description: String,
}
pub struct WasmSecurityAuditor {
known_vulnerabilities: HashMap<String, Finding>,
}
impl WasmSecurityAuditor {
pub fn new() -> Self {
let mut known_vulnerabilities = HashMap::new();
known_vulnerabilities.insert("WASM-SEC-001".into(), Finding {
id: "WASM-SEC-001".into(),
category: FindingCategory::WasiPermission,
severity: RiskLevel::Critical,
description: "Module requests full filesystem access via WASI".into(),
recommendation: "Restrict WASI to specific directories with --dir flag".into(),
});
known_vulnerabilities.insert("WASM-SEC-002".into(), Finding {
id: "WASM-SEC-002".into(),
category: FindingCategory::SideChannel,
severity: RiskLevel::High,
description: "Module uses high-resolution timer (clock_time_get) - potential side-channel".into(),
recommendation: "Reduce timer precision or disable high-res clocks in sandbox".into(),
});
known_vulnerabilities.insert("WASM-SEC-003".into(), Finding {
id: "WASM-SEC-003".into(),
category: FindingCategory::ResourceExhaustion,
severity: RiskLevel::High,
description: "Module declares unbounded memory (no maximum)".into(),
recommendation: "Set memory page limit with --memory-page-limit".into(),
});
known_vulnerabilities.insert("WASM-SEC-004".into(), Finding {
id: "WASM-SEC-004".into(),
category: FindingCategory::InformationLeak,
severity: RiskLevel::Medium,
description: "Module imports environment variables without filtering".into(),
recommendation: "Use --allow-env to whitelist specific environment variables".into(),
});
known_vulnerabilities.insert("WASM-SEC-005".into(), Finding {
id: "WASM-SEC-005".into(),
category: FindingCategory::SupplyChain,
severity: RiskLevel::Critical,
description: "Module contains unverified third-party dependencies".into(),
recommendation: "Verify all dependency hashes against registry checksums".into(),
});
WasmSecurityAuditor { known_vulnerabilities }
}
pub fn audit_module(&self, wasm_path: &str) -> VulnerabilityReport {
let mut findings = Vec::new();
let metadata = std::fs::metadata(wasm_path).expect("cannot read module");
let module_size = metadata.len();
let module_hash = self.compute_hash(wasm_path);
let wasm_features = self.analyze_features(wasm_path);
for feature in &wasm_features {
if feature.risk == RiskLevel::High || feature.risk == RiskLevel::Critical {
findings.push(Finding {
id: format!("FEATURE-{}", feature.name),
category: FindingCategory::WasiPermission,
severity: feature.risk.clone(),
description: format!("Wasm feature used: {} - {}", feature.name, feature.description),
recommendation: "Review if this feature is necessary for the module's function".into(),
});
}
}
findings.push(Finding {
id: "WASM-SEC-003".into(),
category: FindingCategory::ResourceExhaustion,
severity: RiskLevel::High,
description: "Always set memory limits in production".into(),
recommendation: "Use --memory-page-limit to cap memory usage".into(),
});
let overall_risk = if findings.iter().any(|f| f.severity == RiskLevel::Critical) {
RiskLevel::Critical
} else if findings.iter().any(|f| f.severity == RiskLevel::High) {
RiskLevel::High
} else if findings.iter().any(|f| f.severity == RiskLevel::Medium) {
RiskLevel::Medium
} else {
RiskLevel::Low
};
VulnerabilityReport {
module_hash,
module_size,
scan_time: chrono::Utc::now().to_rfc3339(),
risk_level: overall_risk,
findings,
dependencies: vec![],
wasm_features,
}
}
fn compute_hash(&self, path: &str) -> String {
use std::io::Read;
let mut file = std::fs::File::open(path).expect("cannot open file");
let mut data = Vec::new();
file.read_to_end(&mut data).expect("cannot read file");
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn analyze_features(&self, _wasm_path: &str) -> Vec<WasmFeature> {
vec![
WasmFeature {
name: "wasi_snapshot_preview1".into(),
risk: RiskLevel::Medium,
description: "WASI preview1 filesystem access".into(),
},
WasmFeature {
name: "wasi_http".into(),
risk: RiskLevel::Medium,
description: "HTTP outbound requests".into(),
},
]
}
}
fn main() {
let auditor = WasmSecurityAuditor::new();
println!("=== Wasm Security Audit Tool ===");
println!("Known vulnerability signatures: {}", auditor.known_vulnerabilities.len());
for (id, finding) in &auditor.known_vulnerabilities {
println!("[{}] {:?} - {}", id, finding.severity, finding.description);
}
}
# Wasm module security scanning workflow
# 1. Verify module signature
cosign verify-blob \
--certificate module.crt \
--signature module.sig \
plugin.wasm
# 2. Analyze Wasm module imports
wasm-objdump -x plugin.wasm | grep -E "import|export"
# 3. Check WASI permission requirements
wasm-tools print plugin.wasm | grep -E "wasi_snapshot_preview1|wasi_http"
# 4. Run security audit
cargo run --bin wasm-security-auditor -- plugin.wasm
# 5. Restrictive sandbox testing
wasmedge \
--memory-page-limit 256 \
--time-limit 5000 \
--dir /tmp/sandbox:/tmp/sandbox:readonly \
plugin.wasm test_function
# 6. CI/CD integration
echo 'wasm-security-audit:
stage: security
image: toolsku/wasm-auditor:1.0
script:
- wasm-audit --strict ./target/wasm32-wasip1/release/*.wasm
- wasm-verify --cosign ./target/wasm32-wasip1/release/*.wasm
rules:
- changes:
- "**/*.rs"
- "**/Cargo.toml"'
name: Wasm Security Audit
on:
push:
paths:
- 'src/**/*.rs'
- 'Cargo.toml'
- 'wasm/**'
jobs:
security-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Wasm Tools
run: |
curl -sSf https://wasmtime.dev/install.sh | bash
cargo install wasm-tools wasm-objdump
- name: Build Wasm Module
run: |
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
- name: Verify Module Integrity
run: |
sha256sum target/wasm32-wasip1/release/*.wasm > checksums.txt
cosign verify-blob --certificate cosign.crt \
--signature cosign.sig \
target/wasm32-wasip1/release/*.wasm
- name: Analyze WASI Permissions
run: |
for wasm in target/wasm32-wasip1/release/*.wasm; do
echo "=== $wasm ==="
wasm-objdump -x "$wasm" | grep "import" || true
wasm-tools print "$wasm" | grep "wasi" || true
done
- name: Sandbox Test
run: |
wasmedge \
--memory-page-limit 256 \
--time-limit 5000 \
target/wasm32-wasip1/release/*.wasm test
- name: Generate Audit Report
run: |
cargo run --bin wasm-security-auditor \
-- target/wasm32-wasip1/release/*.wasm \
> security-report.json
- name: Upload Audit Report
uses: actions/upload-artifact@v4
with:
name: wasm-security-report
path: security-report.json
Pitfall Guide
❌ Pitfall 1: Trusting Wasm "Natural Security" Without Configuring WASI Permissions
# ❌ Wrong: Running WASI module directly, may access current directory and env vars by default
wasmedge plugin.wasm
# ✅ Correct: Explicitly restrict all WASI permissions
wasmedge \
--dir /app/data:/app/data:readonly \
--env APP_MODE=production \
--allow-env APP_MODE \
--allow-host api.example.com \
--memory-page-limit 256 \
--time-limit 5000 \
plugin.wasm
❌ Pitfall 2: Not Setting Memory Limits on Wasm Modules
# ❌ Wrong: No memory limit, malicious module could exhaust host memory
wasmedge module.wasm
# ✅ Correct: Set memory page limit (64KB per page)
wasmedge --memory-page-limit 256 module.wasm # 16MB limit
❌ Pitfall 3: Ignoring Side-Channel Attack Risks
// ❌ Wrong: Exposing high-resolution timers
// WASI's clock_time_get can provide nanosecond precision
// ✅ Correct: Reduce timer precision
// WasmEdge: Use --time-limit to limit total execution time
// Custom WASI implementation: Reduce clock_time_get precision to 1ms
❌ Pitfall 4: Multiple Tenants Sharing the Same Wasm Runtime Instance
// ❌ Wrong: All tenants share one Store
// let mut store = Store::new(&engine, ());
// tenant_a_module.invoke(&mut store, ...);
// tenant_b_module.invoke(&mut store, ...);
// ✅ Correct: Each tenant gets an independent Store and resource limits
// let store_a = Store::new(&engine, TenantContext::new("alpha", quota_a));
// let store_b = Store::new(&engine, TenantContext::new("beta", quota_b));
❌ Pitfall 5: Not Verifying Third-Party Wasm Module Sources
# ❌ Wrong: Downloading and running unverified Wasm modules
curl -sL https://example.com/plugin.wasm | wasmedge -
# ✅ Correct: Verify signature before running
curl -sL -o plugin.wasm https://example.com/plugin.wasm
curl -sL -o plugin.wasm.sig https://example.com/plugin.wasm.sig
cosign verify-blob --certificate pub.crt --signature plugin.wasm.sig plugin.wasm
wasmedge --memory-page-limit 256 plugin.wasm
Error Troubleshooting
| Error Message | Cause | Solution |
|---|---|---|
WasmEdge: out of memory |
Module memory exceeds --memory-page-limit |
Increase page limit or optimize module memory usage |
WasmEdge: time limit exceeded |
Execution exceeds --time-limit |
Increase timeout or optimize algorithm complexity |
wasm trap: out of bounds memory access |
Module accesses linear memory out of bounds | Check memory access logic, ensure offsets are valid |
permission denied: /etc/passwd |
WASI filesystem permission insufficient | Use --dir to mount allowed directories |
host not allowed: evil.com |
Network request blocked by --allow-host |
Only allow necessary domains |
env var not allowed: DATABASE_URL |
Environment variable filtered by --allow-env |
Only allow necessary environment variables |
module verification failed |
Module signature verification failed | Check signature file and certificate match |
table overflow: max 1024 |
Indirect call table exceeds limit | Increase --max-table-size or reduce indirect calls |
gas limit exceeded |
Gas consumption exceeds limit | Increase gas limit or optimize computation |
sandbox violation: fs write |
Write attempt on read-only directory | Use writeonly or readwrite permission mount |
Advanced Optimization
1. Multi-Layer Defense Architecture
defense_layers:
layer1_wasm_isolation:
description: "Linear memory isolation, modules cannot access out of bounds"
mechanism: "Wasm specification enforced"
bypass_difficulty: "Very high (requires Wasm implementation vulnerability)"
layer2_wasi_capability:
description: "Capability model, default deny all system access"
mechanism: "Explicitly granted permissions"
bypass_difficulty: "High (requires permission misconfiguration)"
layer3_resource_limits:
description: "Memory, CPU, network resource quotas"
mechanism: "Runtime enforced"
bypass_difficulty: "High (requires runtime vulnerability)"
layer4_process_isolation:
description: "Run Wasm runtime in separate process"
mechanism: "OS-level isolation"
bypass_difficulty: "Very high (requires container/process escape)"
layer5_supply_chain:
description: "Module signature verification and dependency audit"
mechanism: "CI/CD enforced checks"
bypass_difficulty: "Medium (requires compromising signature system)"
2. Custom WASI Implementation — Minimal Privilege Host Functions
use wasmtime::*;
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx};
fn create_minimal_wasi(store: &mut Store<WasiCtx>) -> Result<(), anyhow::Error> {
let wasi = WasiCtxBuilder::new()
.inherit_stdout()
.inherit_stderr()
.env("APP_MODE", "production")
.preopened_dir(
Dir::open_ambient_dir("/app/data", AmbientAuthority::try_new()?)?,
DirPerms::READ,
FilePerms::READ,
"/data",
)?
.build();
*store.data_mut() = wasi;
Ok(())
}
fn main() -> Result<(), anyhow::Error> {
let engine = Engine::default();
let module = Module::from_file(&engine, "plugin.wasm")?;
let mut linker = Linker::new(&engine);
wasmtime_wasi::add_to_linker(&mut linker, |cx: &mut WasiCtx| cx)?;
let wasi = WasiCtxBuilder::new()
.env("APP_MODE", "production")
.preopened_dir(
Dir::open_ambient_dir("/app/data", AmbientAuthority::try_new()?)?,
DirPerms::READ,
FilePerms::READ,
"/data",
)?
.build();
let mut store = Store::new(&engine, wasi);
let instance = linker.instantiate(&mut store, &module)?;
let run = instance.get_typed_func::<(), ()>(&mut store, "run")?;
run.call(&mut store, ())?;
Ok(())
}
3. Real-Time Security Monitoring
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
pub struct SecurityMonitor {
memory_violations: AtomicU64,
timeout_violations: AtomicU64,
permission_violations: AtomicU64,
network_violations: AtomicU64,
total_invocations: AtomicU64,
start_time: Instant,
}
impl SecurityMonitor {
pub fn new() -> Self {
SecurityMonitor {
memory_violations: AtomicU64::new(0),
timeout_violations: AtomicU64::new(0),
permission_violations: AtomicU64::new(0),
network_violations: AtomicU64::new(0),
total_invocations: AtomicU64::new(0),
start_time: Instant::now(),
}
}
pub fn record_invocation(&self) {
self.total_invocations.fetch_add(1, Ordering::SeqCst);
}
pub fn record_memory_violation(&self) {
self.memory_violations.fetch_add(1, Ordering::SeqCst);
}
pub fn record_timeout_violation(&self) {
self.timeout_violations.fetch_add(1, Ordering::SeqCst);
}
pub fn record_permission_violation(&self) {
self.permission_violations.fetch_add(1, Ordering::SeqCst);
}
pub fn record_network_violation(&self) {
self.network_violations.fetch_add(1, Ordering::SeqCst);
}
pub fn report(&self) -> String {
let uptime = self.start_time.elapsed().as_secs();
let total = self.total_invocations.load(Ordering::SeqCst);
let mem_v = self.memory_violations.load(Ordering::SeqCst);
let timeout_v = self.timeout_violations.load(Ordering::SeqCst);
let perm_v = self.permission_violations.load(Ordering::SeqCst);
let net_v = self.network_violations.load(Ordering::SeqCst);
format!(
"Security Monitor Report (uptime: {}s)\n\
Total invocations: {}\n\
Memory violations: {}\n\
Timeout violations: {}\n\
Permission violations: {}\n\
Network violations: {}\n\
Violation rate: {:.2}%",
uptime, total, mem_v, timeout_v, perm_v, net_v,
if total > 0 { (mem_v + timeout_v + perm_v + net_v) as f64 / total as f64 * 100.0 } else { 0.0 }
)
}
}
Comparison Analysis
| Dimension | Wasm Sandbox | Docker Container | gVisor | Firecracker |
|---|---|---|---|---|
| Isolation granularity | Function-level | Process-level | Process-level | VM-level |
| Cold start | <1ms | 300-800ms | 1-3s | 125ms |
| Memory overhead | 5-30MB | 50MB+ | 100MB+ | 30MB+ |
| Attack surface | Wasm instruction set | Syscalls | Syscalls | Syscalls |
| Escape difficulty | Very high | Medium | High | High |
| Capability model | WASI default deny | Manual Seccomp | Manual configuration | Manual configuration |
| Resource control | Memory pages + timeout + Gas | Cgroup | Cgroup | Cgroup + VM |
| Supply chain audit | Module signature + dependency scan | Image scan | Image scan | Image scan |
| Multi-tenant | Native support | Extra configuration | Extra configuration | VM-level isolation |
| Use case | Plugins/Serverless | General services | Security containers | Serverless VM |
Conclusion: WebAssembly security sandbox cannot be summarized as "naturally secure" in four words. From linear memory isolation to WASI capability model, from WasmEdge resource limits to multi-tenant quotas, from supply chain auditing to real-time monitoring, each layer is a critical component of defense in depth. In production, you must: default deny all WASI permissions, set memory and timeout limits, verify all third-party module signatures, isolate each tenant in independent sandboxes, and continuously monitor security events. Security is never a one-time configuration — it's a continuous engineering practice.
Recommended Online Tools
- JSON Data Formatter: /en/json/format
- Hash Calculator: /en/encode/hash
- Base64 Encoder/Decoder: /en/encode/base64
Try these browser-local tools — no sign-up required →