Extism Plugin Security: Host/Guest Trust Boundaries and Capability Isolation
Problem
Extism is a plugin framework built on WebAssembly. It allows applications to load and execute user-provided or third-party WASM modules — plugins — within the host application’s process. The WebAssembly sandbox provides memory isolation by default: a plugin cannot read host memory it was not explicitly given access to.
The security model breaks when:
- Host functions expose too much. Extism’s power comes from host functions — functions the host exposes to plugins. A host function that reads arbitrary files (
read_file(path)), executes shell commands, or performs unrestricted HTTP requests turns a sandboxed plugin into an application-level backdoor. The WASM sandbox isolates memory, but host functions are native code executing outside the sandbox. - Plugin I/O is not validated. Plugins receive input via the Extism PDK (Plugin Development Kit) and return output. If the host passes user-controlled data to a plugin as input without validation, and the host trusts plugin output without validation, a malicious plugin can exploit both directions.
- Plugin binaries are not verified. The host loads a plugin from a path or URL. If that path can be modified (supply chain attack, path traversal, race condition), or if the URL can be redirected (DNS spoofing, MITM), a different WASM binary executes with the same host function access as the legitimate plugin.
- Concurrent plugins share state via host. Multiple plugin instances running concurrently in different goroutines/threads access a shared host-side state (e.g., a database connection pool or a shared configuration map). Without synchronisation and access control, one plugin can influence another’s behaviour through the shared state.
- Resource limits absent. A plugin that allocates unbounded memory or enters an infinite loop consumes host process resources. Extism provides per-call timeout support, but it requires explicit configuration.
Target systems: Extism 1.0+ (Go, Rust, Python, Node.js hosts); Zellij (terminal multiplexer plugin system, Extism-based); Wasm-based plugin architectures using Extism PDK; self-hosted Extism plugin registries.
Threat Model
- Adversary 1 — Malicious plugin via supply chain: An attacker substitutes a legitimate plugin binary with a malicious one — through a compromised plugin registry, a path traversal vulnerability in the plugin loader, or a DNS spoofing attack against a URL-based plugin source. The malicious plugin calls host functions to exfiltrate data or execute code.
- Adversary 2 — Host function abuse: A plugin (user-provided or compromised) calls host functions that were intended for internal use. A
log(message)host function that writes to the filesystem can be used to write arbitrary content to arbitrary paths if the path is not restricted. - Adversary 3 — Plugin output injection: A malicious plugin returns crafted output that the host processes without sanitisation. If the host uses plugin output in SQL queries, shell commands, or HTML templates, it becomes an injection vector.
- Adversary 4 — Cross-plugin information leakage: Two plugins running concurrently access shared host state. Plugin A (untrusted, user-provided) reads state written by Plugin B (trusted, internal) by timing host function calls or by exploiting a race condition in shared state management.
- Adversary 5 — Resource exhaustion via plugin: A plugin allocates all available memory or runs an infinite computation. The host process is OOM-killed or becomes unresponsive, affecting all users of the application.
- Access level: Adversaries 1 and 2 need the ability to supply or influence the loaded plugin. Adversary 3 needs the ability to run a plugin. Adversaries 4 and 5 need plugin execution access.
- Objective: Execute arbitrary code on the host, exfiltrate data, inject malicious output, deny service.
- Blast radius: A plugin with access to unrestricted host functions has the same access as the host application — filesystem, network, memory. A malicious plugin in this position is a full application compromise.
Configuration
Step 1: Principle of Least Privilege for Host Functions
Only expose the minimum set of host functions a plugin needs. Each host function is a potential attack surface:
// Go host — registering host functions for plugins.
package main
import (
"context"
"fmt"
extism "github.com/extism/go-sdk"
)
// GOOD: Expose only specific, scoped functions.
func buildRestrictedHostFunctions() []extism.HostFunction {
return []extism.HostFunction{
// Allow plugins to log messages — but only to the application logger,
// not to arbitrary file paths.
extism.NewHostFunctionWithStack(
"log_message",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
message, _ := p.ReadString(stack[0])
// Sanitize: strip control characters, truncate.
message = sanitizeLogMessage(message)
if len(message) > 1024 {
message = message[:1024] + "...[truncated]"
}
appLogger.Info("plugin log", "message", message)
},
[]extism.ValueType{extism.ValueTypeI64}, // Input: message offset.
[]extism.ValueType{}, // No output.
),
// Allow plugins to make HTTP requests — but only to approved hosts.
extism.NewHostFunctionWithStack(
"http_get",
func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) {
urlStr, _ := p.ReadString(stack[0])
// ENFORCE: only allow approved hosts.
if !isApprovedHost(urlStr) {
p.SetError(fmt.Errorf("http_get: host not in allowlist: %s", urlStr))
stack[0] = 0
return
}
// Perform the request.
response, err := restrictedHTTPClient.Get(urlStr)
// ... write response to plugin memory.
},
[]extism.ValueType{extism.ValueTypeI64},
[]extism.ValueType{extism.ValueTypeI64},
),
}
}
// BAD: Do not expose these host functions.
var dangerousHostFunctions = []string{
"exec_command", // Shell execution from plugin.
"read_file", // Arbitrary filesystem read.
"write_file", // Arbitrary filesystem write.
"open_tcp_socket", // Unrestricted network.
"eval_javascript", // JS eval from plugin.
"get_all_env_vars", // Exposes all environment variables.
}
Step 2: Plugin Binary Verification
Verify plugin binaries before loading:
// plugin_loader/loader.go
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
extism "github.com/extism/go-sdk"
)
type PluginManifest struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
ExpectedSHA256 string `yaml:"sha256"`
AllowedFunctions []string `yaml:"allowed_functions"`
}
func LoadVerifiedPlugin(manifest PluginManifest, hostFunctions []extism.HostFunction) (*extism.Plugin, error) {
// 1. Verify binary hash.
f, err := os.Open(manifest.Path)
if err != nil {
return nil, fmt.Errorf("plugin not found: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err = io.Copy(h, f); err != nil {
return nil, err
}
actualHash := hex.EncodeToString(h.Sum(nil))
if actualHash != manifest.ExpectedSHA256 {
return nil, fmt.Errorf(
"plugin integrity check failed: %s\nexpected: %s\nactual: %s",
manifest.Path, manifest.ExpectedSHA256, actualHash,
)
}
// 2. Filter host functions to only those the plugin is allowed to use.
allowedSet := make(map[string]struct{})
for _, fn := range manifest.AllowedFunctions {
allowedSet[fn] = struct{}{}
}
var filteredFunctions []extism.HostFunction
for _, fn := range hostFunctions {
if _, ok := allowedSet[fn.Name]; ok {
filteredFunctions = append(filteredFunctions, fn)
}
}
// 3. Load plugin with restricted host functions.
ctx := context.Background()
plugin, err := extism.NewPlugin(
ctx,
extism.Manifest{
Wasm: []extism.Wasm{
extism.WasmFile{Path: manifest.Path},
},
},
extism.PluginConfig{
EnableWasi: false, // Disable WASI unless explicitly needed.
},
filteredFunctions,
)
return plugin, err
}
Step 3: Per-Call Timeouts and Memory Limits
// Enforce resource limits per plugin call.
func callPluginWithLimits(
plugin *extism.Plugin,
functionName string,
input []byte,
timeoutMs int,
maxOutputBytes int,
) ([]byte, error) {
ctx, cancel := context.WithTimeout(
context.Background(),
time.Duration(timeoutMs)*time.Millisecond,
)
defer cancel()
// Call the plugin function.
exit, output, err := plugin.CallWithContext(ctx, functionName, input)
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("plugin call timed out after %dms", timeoutMs)
}
return nil, fmt.Errorf("plugin call failed (exit=%d): %w", exit, err)
}
// Enforce output size limit.
if len(output) > maxOutputBytes {
return nil, fmt.Errorf(
"plugin output exceeds limit: %d bytes (max %d)",
len(output), maxOutputBytes,
)
}
return output, nil
}
// Plugin configuration with memory limits.
plugin, err := extism.NewPlugin(
ctx,
extism.Manifest{
Wasm: []extism.Wasm{extism.WasmFile{Path: pluginPath}},
Memory: &extism.MemoryOptions{
MaxPages: 256, // 256 × 64KiB = 16 MiB maximum.
},
},
extism.PluginConfig{
EnableWasi: false,
},
hostFunctions,
)
Step 4: Input and Output Validation
Never trust plugin input or output without validation:
// Always validate input before passing to plugins.
type PluginInput struct {
UserID string `json:"user_id" validate:"required,uuid4"`
Query string `json:"query" validate:"required,max=1000"`
Timestamp int64 `json:"ts" validate:"required,gt=0"`
}
func processWithPlugin(plugin *extism.Plugin, rawInput []byte) ([]byte, error) {
// 1. Validate input structure.
var input PluginInput
if err := json.Unmarshal(rawInput, &input); err != nil {
return nil, fmt.Errorf("invalid input: %w", err)
}
if err := validate.Struct(input); err != nil {
return nil, fmt.Errorf("input validation failed: %w", err)
}
// 2. Marshal the validated input (prevents passing raw user input).
validatedInput, _ := json.Marshal(input)
// 3. Call plugin with validated input and timeout.
output, err := callPluginWithLimits(plugin, "process", validatedInput, 5000, 1_000_000)
if err != nil {
return nil, err
}
// 4. Validate and sanitize plugin output before use.
var pluginOutput PluginOutput
if err := json.Unmarshal(output, &pluginOutput); err != nil {
return nil, fmt.Errorf("plugin returned invalid output: %w", err)
}
if err := validate.Struct(pluginOutput); err != nil {
return nil, fmt.Errorf("plugin output validation failed: %w", err)
}
// 5. Sanitise text fields before using in downstream contexts.
pluginOutput.ResultText = html.EscapeString(pluginOutput.ResultText)
return json.Marshal(pluginOutput)
}
Step 5: Plugin Isolation — One Instance Per Tenant
Never share a plugin instance between tenants. Each plugin instance has its own memory space; sharing enables cross-tenant data leakage if host functions access tenant-scoped state:
// plugin_pool/pool.go — per-tenant plugin instances.
import "sync"
type TenantPluginPool struct {
mu sync.RWMutex
instances map[string]*extism.Plugin // tenantID → plugin instance.
manifest PluginManifest
hostFuncs []extism.HostFunction
}
func (p *TenantPluginPool) GetOrCreate(tenantID string) (*extism.Plugin, error) {
p.mu.RLock()
if plugin, ok := p.instances[tenantID]; ok {
p.mu.RUnlock()
return plugin, nil
}
p.mu.RUnlock()
p.mu.Lock()
defer p.mu.Unlock()
// Double-check after acquiring write lock.
if plugin, ok := p.instances[tenantID]; ok {
return plugin, nil
}
// Create per-tenant host functions with tenant context baked in.
// This ensures host functions can only access this tenant's data.
tenantHostFuncs := buildTenantScopedHostFunctions(tenantID, p.hostFuncs)
plugin, err := LoadVerifiedPlugin(p.manifest, tenantHostFuncs)
if err != nil {
return nil, err
}
p.instances[tenantID] = plugin
return plugin, nil
}
func buildTenantScopedHostFunctions(tenantID string, base []extism.HostFunction) []extism.HostFunction {
// Replace generic host functions with tenant-scoped versions.
// The tenant ID is captured in the closure — the plugin cannot
// access other tenants' data through these functions.
var scoped []extism.HostFunction
for _, fn := range base {
scoped = append(scoped, scopeToTenant(fn, tenantID))
}
return scoped
}
Step 6: Approved HTTP Allowlist for Plugin Network Access
If plugins need HTTP access, implement a strict allowlist:
// host_functions/http.go
var approvedHosts = map[string]bool{
"api.openai.com": true,
"api.internal.example.com": true,
"lookup.example.com": true,
}
func isApprovedHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
// Must be HTTPS.
if u.Scheme != "https" {
return false
}
// Host must be in the allowlist (no wildcards).
return approvedHosts[u.Hostname()]
}
// Restricted HTTP client: short timeout, no redirects to unapproved hosts.
var restrictedHTTPClient = &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if !isApprovedHost(req.URL.String()) {
return fmt.Errorf("redirect to unapproved host: %s", req.URL.Host)
}
return nil
},
}
Step 7: Plugin Registry with Signature Verification
For production plugin distribution, use a signed plugin registry:
// plugin_registry/registry.go
import (
"github.com/sigstore/cosign/v2/pkg/cosign"
"github.com/sigstore/cosign/v2/pkg/oci/remote"
)
type PluginRegistry struct {
baseURL string
publicKey string // Cosign public key for signature verification.
httpClient *http.Client
}
func (r *PluginRegistry) FetchAndVerify(pluginName, version string) ([]byte, error) {
// 1. Download plugin binary.
url := fmt.Sprintf("%s/plugins/%s/%s/plugin.wasm", r.baseURL, pluginName, version)
resp, err := r.httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
pluginBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MiB limit.
if err != nil {
return nil, err
}
// 2. Download signature bundle.
sigURL := url + ".bundle"
// ... fetch sigBundle ...
// 3. Verify cosign signature.
verifier, err := cosign.LoadPublicKey(context.Background(), r.publicKey)
if err != nil {
return nil, fmt.Errorf("load verifier: %w", err)
}
// Verify blob signature.
// On failure: do not return the plugin bytes.
if err := cosign.VerifyBlobSignature(context.Background(), pluginBytes, sigBundle, verifier); err != nil {
return nil, fmt.Errorf("plugin signature verification failed for %s@%s: %w", pluginName, version, err)
}
return pluginBytes, nil
}
Step 8: Telemetry
extism_plugin_calls_total{plugin, function, status} counter
extism_plugin_call_duration_ms{plugin, function} histogram
extism_plugin_timeout_total{plugin, function} counter
extism_plugin_memory_pages_used{plugin, tenant} gauge
extism_host_function_calls_total{function, plugin, status} counter
extism_plugin_integrity_failures_total{plugin} counter
extism_plugin_output_oversized_total{plugin} counter
extism_plugin_blocked_host_requests_total{plugin, host} counter
Alert on:
extism_plugin_integrity_failures_totalnon-zero — a plugin binary failed verification; stop loading the plugin and investigate the source.extism_plugin_timeout_totalspike — plugins are timing out; either a runaway computation or an upstream service the plugin calls is slow.extism_plugin_blocked_host_requests_totalnon-zero — a plugin attempted to call a host not on the allowlist; investigate for data exfiltration attempt.extism_plugin_memory_pages_usedapproaching limit — plugin is near its memory limit; may indicate malicious behaviour or a memory leak.extism_host_function_calls_total{status="error"}spike — host functions are failing for a plugin; may indicate a plugin attempting to abuse host functions beyond permitted scope.
Expected Behaviour
| Signal | Unconfigured Extism | Hardened Extism |
|---|---|---|
| Plugin reads host filesystem | Possible via host function | No read_file host function; blocked |
| Plugin makes outbound HTTP to C2 | Possible via host function | HTTP host function enforces allowlist; blocked |
| Tampered plugin binary loaded | Executed without detection | SHA256 or cosign check fails; load rejected |
| Plugin runs infinite loop | Host process hangs | Per-call timeout kills execution after N ms |
| Cross-tenant data access via shared plugin | Possible if instance shared | Per-tenant instances with scoped host functions |
Trade-offs
| Aspect | Benefit | Cost | Mitigation |
|---|---|---|---|
| Per-tenant plugin instances | Memory isolation between tenants | Higher memory usage; instance startup overhead | Pool instances with LRU eviction; pre-warm common plugins |
| No WASI | Removes WASI-provided capabilities (filesystem, network, env) | Plugins cannot use WASI stdlib | Use host functions for specific approved I/O |
| Input/output validation | Prevents injection via plugin I/O | Validation overhead; schemas must be maintained | Define plugin I/O schemas at plugin development time |
| HTTP allowlist in host function | Blocks C2 and exfiltration | Requires updating allowlist for new approved hosts | Store allowlist in configuration; reload without restart |
Failure Modes
| Failure | Symptom | Detection | Recovery |
|---|---|---|---|
| Plugin binary hash mismatch after legitimate update | Plugin fails to load after deployment | extism_plugin_integrity_failures_total alert; deployment fails |
Update expected hash alongside binary in deployment; use signed registry |
| Memory limit too low for complex plugin | Plugin exits with OOM inside WASM | Plugin call fails with memory error | Profile plugin memory usage in staging; increase MaxPages |
| Timeout too short for slow upstream | Plugin times out when calling approved external API | extism_plugin_timeout_total spike |
Increase timeout for that plugin; investigate upstream latency |
| Host function allowlist outdated | Plugin cannot reach required new endpoint | Connection error in plugin; blocked request alert | Add endpoint to allowlist; review security implications |
| Plugin instance pool memory leak | Host process OOM over time | Memory growth metric; eventual OOM kill | Add TTL to plugin instances; refresh pool periodically |