WebAssembly Platform Extensions: Security Model for WASM Plugin Systems
Why Platforms Choose WASM for Extensions
Platform engineering tools need extension points. A developer portal needs to render custom scorecards. A CI/CD orchestrator needs to run customer-supplied build steps. A policy engine needs user-defined rules. The traditional approach — native plugins loaded as shared libraries — is a security disaster. A .so loaded into the same process as the platform tool runs with full host privileges: same memory space, same file descriptors, same credentials.
WASM solves the portability and isolation problems simultaneously. A WASM plugin compiles once and runs on any supported host OS and CPU architecture. The runtime enforces a hard memory boundary: the plugin’s linear memory is a byte array allocated by the runtime; the plugin cannot address host memory outside that array through any WASM instruction. There are no native system calls. Every resource access — reading a file, opening a socket, calling a time API — requires a host function import that the platform explicitly provides.
This is not theoretical. Envoy’s filter system runs WASM. Kubernetes admission controllers can evaluate WASM policy modules. Internal developer platforms built on Backstage are adding WASM extension APIs. The Extism framework makes embedding a WASM plugin host a few lines of code in Go, Rust, Python, or Ruby.
The sandbox is real, but it is not complete. The isolation boundary is the border between WASM instructions and the host function call interface. Everything granted through that interface — network access, filesystem paths, environment variable reads, inter-plugin communication channels — is fully accessible to the plugin code once granted. A platform that hands WASM plugins unrestricted WASI filesystem access has the same security posture as one running native shared libraries. The capability model only holds if capabilities are deliberately restricted.
The WASM Sandbox Boundary
The WASM specification provides one hard isolation guarantee out of the box: linear memory isolation. A module’s linear memory is a contiguous byte array indexed by i32 offsets. Accessing index N where N >= memory.size traps — it does not read adjacent host memory. The runtime validates this at the instruction level; there is no way to construct a WASM instruction sequence that escapes linear memory without a runtime bug.
Everything else requires an explicit grant:
| Capability | Default | How granted |
|---|---|---|
| Read host filesystem | Denied | WASI wasi:filesystem/preopens with specific directory |
| Write host filesystem | Denied | WASI wasi:filesystem/preopens with write permission |
| Open TCP/UDP sockets | Denied | WASI wasi:sockets/tcp or custom host function |
| Read environment variables | Denied | WASI wasi:cli/environment or selective host function |
| Execute processes | Denied | WASI wasi:cli/subprocess (almost never grant this) |
| Read host memory | Denied (by design) | Not grantable; must copy through linear memory |
| HTTP requests | Denied | WASI HTTP or custom host function with allowlist |
| Monotonic clock | Denied | WASI wasi:clocks/monotonic-clock |
The implication is that the platform engineer controls the capability surface completely. A WASM plugin that is not linked against a function cannot call it. If the host linker does not provide fd_write pointing to a real file descriptor, the plugin cannot write to one. This is the security-relevant difference between WASM and native plugins: the attack surface is enumerable and explicit.
The WASI component model formalises this through typed interfaces. Rather than exposing raw POSIX-like file descriptors, a component host grants capability handles — objects that carry the authority to perform specific operations on specific resources. A directory handle for /tmp/plugin-scratch confers read and write access to that directory and nothing else.
WASI Capability Restriction with the Component Model
The WASI Preview 2 component model replaces the coarse WasiCtx configuration of WASI Preview 1 with typed world interfaces. A platform that uses Wasmtime can construct a WasiCtxBuilder that grants exactly the capabilities the plugin needs and nothing more.
use wasmtime_wasi::{WasiCtxBuilder, DirPerms, FilePerms};
use wasmtime::component::{Linker, ResourceTable};
use std::path::Path;
fn build_plugin_wasi_ctx(
plugin_scratch_dir: &Path,
allow_http: bool,
) -> anyhow::Result<wasmtime_wasi::WasiCtx> {
let mut builder = WasiCtxBuilder::new();
// Monotonic clock is safe to grant — plugins need timing.
builder.monotonic_clock();
// Stdout/stderr mapped to structured logger, not host fd.
builder.stdout(wasmtime_wasi::pipe::MemoryOutputPipe::new(64 * 1024));
builder.stderr(wasmtime_wasi::pipe::MemoryOutputPipe::new(16 * 1024));
// Grant access to plugin scratch directory only.
// DirPerms::all() | FilePerms::all() scoped to this one directory.
builder.preopened_dir(
plugin_scratch_dir,
".",
DirPerms::all(),
FilePerms::all(),
)?;
// Do NOT call:
// builder.inherit_env() — exposes all host environment variables
// builder.inherit_stdio() — exposes host stdin/stdout/stderr
// builder.inherit_network() — grants unrestricted network
// builder.allow_tcp(true) — use explicit allowlists instead
// Selective environment variables only.
builder.env("PLUGIN_ENV", "production");
builder.env("PLUGIN_ID", "analytics-v2");
// Not: API keys, database credentials, internal service URLs.
if allow_http {
// wasi-http is granted selectively; the platform wraps it
// with an allowlist enforced in the host HTTP implementation.
builder.allow_http()?;
}
Ok(builder.build())
}
The allow_http path deserves special attention. Granting wasi-http gives the plugin the ability to make arbitrary outbound HTTP requests unless the host intercepts and filters them. Implement the wasi:http/outgoing-handler host binding yourself rather than delegating to the default implementation:
// Wrap the WASI HTTP outgoing handler to enforce an allowlist.
struct AllowlistedHttpHandler {
allowed_hosts: std::collections::HashSet<String>,
inner: wasmtime_wasi_http::WasiHttpCtx,
}
impl AllowlistedHttpHandler {
fn check_host(&self, url: &str) -> anyhow::Result<()> {
let parsed = url::Url::parse(url)?;
let host = parsed.host_str().unwrap_or("").to_string();
if parsed.scheme() != "https" {
anyhow::bail!("only HTTPS is permitted for plugin outbound requests");
}
if !self.allowed_hosts.contains(&host) {
anyhow::bail!("host '{}' is not in the plugin outbound allowlist", host);
}
Ok(())
}
}
Host API Design: Principle of Least Authority
Every function the host linker exposes to a WASM plugin is attack surface. A plugin that obtains a reference to an over-scoped host function can use it for any purpose — data exfiltration, lateral movement, denial of service.
Apply least authority at the linker level. Before a plugin is instantiated, parse its declared imports using wasmparser and compare them against the approved import set for that plugin’s trust tier. Refuse instantiation if the plugin declares imports it is not permitted to use.
use wasmparser::{Parser, Payload};
struct ImportPolicy {
allowed: std::collections::HashSet<String>,
}
impl ImportPolicy {
fn validate_module(&self, wasm: &[u8]) -> anyhow::Result<()> {
let parser = Parser::new(0);
for payload in parser.parse_all(wasm) {
if let Payload::ImportSection(reader) = payload? {
for import in reader {
let import = import?;
let key = format!("{}/{}", import.module, import.name);
if !self.allowed.contains(&key) {
anyhow::bail!(
"plugin declares unauthorized import: {} — load rejected",
key
);
}
}
}
}
Ok(())
}
}
// Community plugins get a restricted tier.
fn community_plugin_policy() -> ImportPolicy {
ImportPolicy {
allowed: [
"env/log_message",
"env/get_timestamp",
"env/read_platform_config",
]
.iter()
.map(|s| s.to_string())
.collect(),
}
}
// Internal plugins get an expanded tier.
fn internal_plugin_policy() -> ImportPolicy {
ImportPolicy {
allowed: [
"env/log_message",
"env/get_timestamp",
"env/read_platform_config",
"env/write_artifact",
"env/emit_metric",
"env/fetch_secret",
]
.iter()
.map(|s| s.to_string())
.collect(),
}
}
When implementing host functions, treat all arguments from the plugin as untrusted input. Validate pointer and length pairs against current linear memory bounds before dereferencing. Copy arguments into host-owned memory before validation to prevent TOCTOU races in shared-memory configurations. Apply maximum length checks before reading strings from linear memory.
See WASM Host Function Security for the complete pointer validation implementation.
Plugin Supply Chain: Signing and Load-Time Verification
A WASM plugin that arrives at the host without provenance verification is a supply chain attack waiting to happen. CI/CD artifact stores, OCI registries, and HTTP plugin download endpoints are all targets. A compromised registry or a poisoned cache delivers a different binary than the one the operator approved.
The correct approach is to sign plugins at build time using cosign and verify the signature at load time before executing a single WASM instruction.
Sign a plugin at build time:
# Build the WASM plugin in the CI pipeline.
cargo build --target wasm32-unknown-unknown --release
cp target/wasm32-unknown-unknown/release/analytics_plugin.wasm \
dist/analytics-plugin-1.4.2.wasm
# Sign using a keyless Sigstore identity (OIDC-based, no key management).
cosign sign-blob \
--bundle analytics-plugin-1.4.2.wasm.bundle \
dist/analytics-plugin-1.4.2.wasm
# Or sign with an explicit key for air-gapped environments.
cosign sign-blob \
--key cosign.key \
--bundle analytics-plugin-1.4.2.wasm.bundle \
dist/analytics-plugin-1.4.2.wasm
# Publish binary and bundle together — both must be present at load time.
Verify at load time before instantiation:
use std::process::Command;
fn verify_plugin_signature(
wasm_path: &str,
bundle_path: &str,
expected_identity: &str,
expected_issuer: &str,
) -> anyhow::Result<Vec<u8>> {
// Verify signature using cosign CLI (or use the cosign Rust crate).
let status = Command::new("cosign")
.args([
"verify-blob",
"--bundle", bundle_path,
"--certificate-identity", expected_identity,
"--certificate-oidc-issuer", expected_issuer,
wasm_path,
])
.status()?;
if !status.success() {
anyhow::bail!(
"plugin signature verification failed for {}: refusing to load",
wasm_path
);
}
// Only read and return the bytes after successful verification.
let bytes = std::fs::read(wasm_path)?;
Ok(bytes)
}
For platforms that distribute plugins via OCI registries, push the WASM module as an OCI artifact and use cosign to sign the artifact digest:
# Push WASM as an OCI artifact.
oras push registry.example.com/platform-plugins/analytics:1.4.2 \
--artifact-type application/vnd.wasm.module \
analytics-plugin-1.4.2.wasm:application/vnd.wasm.content.layer.v1+wasm
# Sign the artifact in the registry (signs the manifest digest).
cosign sign \
--key cosign.key \
registry.example.com/platform-plugins/analytics:1.4.2
# Verify before pulling.
cosign verify \
--key cosign.pub \
registry.example.com/platform-plugins/analytics:1.4.2
The plugin load path in the platform must be: verify signature → load bytes → validate imports → instantiate. Any failure before instantiation must abort loading and log a security event. Do not load an unverified plugin even in development mode; the habit creates gaps in production deployments.
For more on WASM component supply chain controls, see WASM Component Supply Chain.
Runtime Resource Limits
A WASM plugin that enters an infinite loop or allocates unbounded memory is a denial of service attack on the platform. WASM provides no built-in resource limits; the runtime must enforce them.
Wasmtime provides fuel-based CPU metering. Fuel is an instruction budget: each WASM instruction consumes one or more units of fuel, and when the budget is exhausted, the module traps rather than continuing to execute.
use wasmtime::{Config, Engine, Store};
fn build_metered_engine() -> anyhow::Result<Engine> {
let mut config = Config::new();
// Enable fuel consumption tracking.
config.consume_fuel(true);
// Enable epoch-based interruption as a fallback for wall-clock timeouts.
config.epoch_interruption(true);
Engine::new(&config)
}
fn run_plugin_with_budget(
engine: &Engine,
module: &wasmtime::Module,
linker: &wasmtime::Linker<HostState>,
state: HostState,
fuel_budget: u64,
wall_clock_deadline_ms: u64,
) -> anyhow::Result<()> {
let mut store = Store::new(engine, state);
// Set CPU instruction budget.
store.set_fuel(fuel_budget)?;
// Set epoch deadline for wall-clock timeout (requires epoch_interruption).
store.set_epoch_deadline(1);
// Run the plugin function.
let instance = linker.instantiate(&mut store, module)?;
let func = instance.get_typed_func::<(), ()>(&mut store, "run")?;
func.call(&mut store, ())?;
Ok(())
}
// Recommended budgets by plugin trust tier.
const FUEL_COMMUNITY_PLUGIN: u64 = 100_000_000; // ~100M instructions
const FUEL_INTERNAL_PLUGIN: u64 = 500_000_000; // ~500M instructions
// Configure the epoch incrementor to fire on wall-clock ticks.
// Start a background thread that increments the engine epoch.
fn start_epoch_ticker(engine: Engine, tick_interval_ms: u64) {
std::thread::spawn(move || {
loop {
std::thread::sleep(std::time::Duration::from_millis(tick_interval_ms));
engine.increment_epoch();
}
});
}
Memory limits are set at module instantiation time by limiting the maximum number of WASM memory pages (each page is 64 KiB):
use wasmtime::{MemoryType, Limits};
// Enforce memory limit at instantiation.
// Override the module's declared maximum if necessary.
fn instantiate_with_memory_limit(
store: &mut Store<HostState>,
module: &wasmtime::Module,
linker: &wasmtime::Linker<HostState>,
max_pages: u64,
) -> anyhow::Result<wasmtime::Instance> {
// Wasmtime respects the module's memory type; to enforce a cap,
// configure the engine's memory limits in the pooling allocator.
// For simpler deployments, configure max_wasm_stack and pool limits
// in Config before compilation.
linker.instantiate(store, module)
}
// In engine config:
fn build_memory_limited_config() -> Config {
let mut config = Config::new();
// Limit linear memory to 64 MiB per instance (1024 pages × 64 KiB).
config.static_memory_maximum_size(64 * 1024 * 1024);
config.static_memory_guard_size(2 * 1024 * 1024);
// Limit stack depth.
config.max_wasm_stack(512 * 1024);
config
}
Extism Plugin Framework Security
Extism provides a higher-level abstraction over Wasmtime for plugin systems. It includes a manifest-based configuration for resource restrictions that is well-suited for platform extension systems.
# plugin-manifest.toml — per-plugin resource configuration
[plugin.analytics-v2]
path = "/plugins/analytics-v2.wasm"
sha256 = "a3f8c1d9e2b74f0a5c6d8e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
# Network: restrict outbound HTTP to specific hosts.
allowed_hosts = [
"api.datapipeline.internal",
"metrics.platform.internal",
]
# Filesystem: restrict to plugin scratch directory.
allowed_paths = {
"/var/platform/plugin-scratch/analytics" = "/scratch"
}
# Memory: maximum linear memory in bytes.
memory_max = 67108864 # 64 MiB
[plugin.analytics-v2.config]
# Static configuration values injected at load time.
# Plugins read these via extism_get_config(); they are not environment variables.
environment = "production"
metric_prefix = "platform.analytics"
# Do not include: secrets, credentials, tokens.
# Fetch secrets through a dedicated host function with access control.
Loading this manifest in Go:
package main
import (
"context"
"fmt"
"os"
extism "github.com/extism/go-sdk"
)
func loadPlatformPlugin(manifestPath string) (*extism.Plugin, error) {
manifestBytes, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("read manifest: %w", err)
}
manifest := extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{
Path: "/plugins/analytics-v2.wasm",
// Hash is verified by the Extism runtime at load time.
Hash: "a3f8c1d9e2b74f0a5c6d8e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
},
},
AllowedHosts: []string{
"api.datapipeline.internal",
"metrics.platform.internal",
},
AllowedPaths: map[string]string{
"/var/platform/plugin-scratch/analytics": "/scratch",
},
Memory: &extism.MemoryOptions{
MaxPages: 1024, // 64 MiB
},
Config: map[string]string{
"environment": "production",
"metric_prefix": "platform.analytics",
},
}
ctx := context.Background()
plugin, err := extism.NewPlugin(
ctx,
manifest,
extism.PluginConfig{
EnableWasi: true,
// Do not enable: RuntimeConfig.AllowMultipleWasiInstances
// unless you understand the shared state implications.
},
buildPlatformHostFunctions(),
)
if err != nil {
return nil, fmt.Errorf("plugin load failed: %w", err)
}
return plugin, nil
}
The AllowedHosts restriction in Extism enforces that the Extism HTTP host function can only reach listed hostnames. If a plugin calls the built-in HTTP function with a URL pointing to an unlisted host, the call returns an error without making a network connection. This does not restrict custom host functions you expose — those must enforce allowlists in their own implementation.
For per-call execution limits:
func callPlugin(plugin *extism.Plugin, fn string, input []byte) ([]byte, error) {
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
exit, output, err := plugin.CallWithContext(ctx, fn, input)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("plugin %q timed out", fn)
}
return nil, fmt.Errorf("plugin call failed (exit %d): %w", exit, err)
}
if len(output) > 4*1024*1024 {
return nil, fmt.Errorf("plugin output exceeds 4 MiB limit")
}
return output, nil
}
Third-Party Plugin Vetting
When platform users can contribute or install community plugins, the vetting process cannot be optional. A community plugin that passes the technical sandbox controls but performs malicious logic through granted capabilities — reading config keys it was given access to, exfiltrating data through an allowed HTTP endpoint — requires review that the runtime cannot automate.
Before allowing a community plugin into the platform:
1. Static analysis of the WASM binary. Decompile the WASM module and examine the import list, memory access patterns, and data segments. Tools like wasm-decompile (part of wabt), wasm-objdump, and purpose-built WASM static analysis tools can surface suspicious patterns: base64-encoded strings in data segments, obfuscated function names, imports inconsistent with the stated plugin purpose.
# List all imports to compare against stated purpose.
wasm-objdump -x community-plugin.wasm | grep Import
# Decompile to readable form for manual review.
wasm-decompile community-plugin.wasm -o community-plugin.dcmp
# Check for suspicious data segments (encoded payloads, embedded URLs).
wasm-objdump -s community-plugin.wasm | grep -A2 'Data\[' | head -80
2. Build reproducibility check. Require plugin authors to provide a reproducible build: a Dockerfile or CI workflow that produces a bit-for-bit identical WASM binary from source. Build the plugin independently and compare SHA-256 digests. A plugin that cannot be built reproducibly from its stated source is a red flag. See WASM Component Supply Chain for the full reproducible build process.
3. SBOM inspection. Require a Software Bill of Materials for the plugin. Community Rust plugins built with cargo-component can generate an SBOM with cargo cyclonedx. Inspect the dependency tree for known-vulnerable versions, unusual dependencies (cryptomining libraries, network scanning tools), and dependencies that do not match the plugin’s stated functionality.
4. Capability audit against stated purpose. What the plugin declares it needs must match what it actually does. An analytics plugin that imports wasi:sockets/tcp or requests a host function for arbitrary HTTP should be rejected or the imports must be justified. Apply the per-module import policy as a hard gate, but also review whether the approved imports are proportionate to the plugin’s stated function.
5. Sandbox behaviour testing. Run the plugin in a test environment with:
- No network access (remove all HTTP host functions and WASI socket capability)
- Read-only filesystem with synthetic data
- Fuel budget set to 10% of the production limit
- All host function calls logged
Examine the logs for unexpected host function call patterns. A plugin that calls read_platform_config 10,000 times in a single invocation is worth investigating.
6. Ongoing monitoring after approval. Approval is not permanent. Track the following metrics per plugin in production:
platform_plugin_http_requests_total{plugin, host, status}
platform_plugin_host_fn_calls_total{plugin, function}
platform_plugin_fuel_consumed{plugin}
platform_plugin_memory_pages_peak{plugin}
platform_plugin_output_bytes{plugin}
platform_plugin_timeouts_total{plugin}
platform_plugin_blocked_hosts_total{plugin, host}
Alert on deviations from baseline for approved plugins. A plugin that starts calling a previously-unused host function or making requests to a new host after a version update requires re-review before the update is allowed in production.
Integrating with Internal Developer Platform Security
Platform extension security does not operate in isolation. The Internal Developer Platform Security context matters: who can publish plugins, who approves them, and how plugin execution is audited in the broader IDP security model.
For zero-trust platform deployments — where the extension host itself must attest to plugin execution — the pattern in WASM Edge Zero Trust Auth applies: the platform runtime generates a signed execution receipt after each plugin invocation, including the plugin’s signature digest, the capabilities granted, and the execution outcome. This receipt feeds into the platform’s audit trail and can trigger policy re-evaluation if anomalies are detected.
Minimum IDP-level controls for WASM plugin systems:
| Control | Mechanism |
|---|---|
| Plugin publication requires approval | PR review + policy gate in CI/CD |
| Plugins are signed by CI identity | cosign keyless signing in build pipeline |
| Signature verified before load | Platform loader rejects unsigned plugins |
| Capability grants documented | Manifest reviewed in approval process |
| Plugin version pinned | Platforms do not auto-update plugins; updates require re-approval |
| Execution audited | Host function calls logged with plugin identity and tenant |
| Anomaly alerting | Metric deviation from baseline triggers review |
The platform loader should enforce that the plugin manifest is itself version-controlled and reviewed. A plugin that updates its AllowedHosts list without a corresponding review bypass a key control. Treat the manifest as a capability grant document with the same review requirements as code that changes security-sensitive configuration.
Summary
The WASM sandbox gives platform extension systems a real isolation boundary that native plugins never had. Memory isolation is enforced at the hardware level through the runtime’s linear memory model; there is no instruction the plugin can execute to read host memory outside its allocation. But the sandbox boundary is the host function interface, and that interface is entirely under the platform engineer’s control.
Secure WASM extension systems follow four principles:
-
Explicit capability grants only. Never inherit capabilities from the host. Build a
WasiCtxBuilderthat grants exactly the WASI interfaces the plugin needs. Implement HTTP host functions with enforced allowlists rather than passing WASI sockets through unfiltered. -
Import-level access control. Parse WASM imports before instantiation. Reject plugins that declare imports outside their approved tier. Enforce this as a hard gate, not a warning.
-
Sign everything, verify on load. Sign WASM plugins in CI using cosign. Verify signatures before loading any bytes into the runtime. Log and alert on verification failures.
-
Budget CPU, memory, and time. Set fuel limits proportionate to the plugin’s expected computation. Set memory page limits. Set wall-clock timeouts. Test these limits in staging; a plugin that OOM-traps in staging should be fixed before production.
Third-party plugins require vetting at approval time and monitoring in production. The sandbox is a technical control; the vetting process is the organizational control. Both are required.