05 — Plugin System & Sandboxing
The bext plugin system provides three sandbox tiers for executing user-supplied code safely in a multi-tenant server. Each tier trades off startup cost, expressiveness, and isolation strength.
┌─────────────────────────────────────────────────┐
│ Plugin Registry │
│ (LifecyclePlugin trait, priority-sorted) │
│ │
│ ┌─────────┐ ┌──────────┐ ┌──────────┐ │
│ │ WASM │ │ QuickJS │ │ nsjail │ │
│ │wasmtime │ │ rquickjs │ │ process │ │
│ │<1ms, 5MB│ │<1ms, 1MB │ │~10ms, 0MB│ │
│ │fuel-budg│ │mem-limit │ │namespace │ │
│ └────┬────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ LifecyclePlugin trait │ │
│ │ on_server_start, on_request_complete │ │
│ │ on_cache_write, on_cache_invalidate │ │
│ │ on_reload, cleanup │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
Sandbox Tiers at a Glance
| WASM (wasmtime) | QuickJS (rquickjs) | nsjail (process) | |
|---|---|---|---|
| Cold start | <1 ms (AOT cached) | <0.3 ms | ~10 ms |
| Memory per instance | 1–5 MB | <1 MB | ~0 (process) |
| Isolation model | Linear memory, fuel budget | Heap limit, interrupt handler | Separate process + timeout |
| Language | Rust, Go, C → .wasm |
JavaScript .js |
Any (Python, Ruby, shell, ...) |
| Host communication | Shared memory (ptr, len) | Embedded globals | JSON-over-stdio IPC |
| Network | Host-proxied via allowlist | Host-proxied via allowlist | Process has host network (layer nsjail/firejail for isolation) |
| Best for | High-perf compiled plugins | Tenant scripting, business logic | Full-environment, polyglot |
| Crate | bext-plugin-wasm |
bext-plugin-quickjs |
bext-plugin-nsjail |
| Feature gate | plugins |
plugins |
plugins |
1. WASM Sandbox (wasmtime)
How it works
WASM plugins are compiled modules (.wasm) loaded via wasmtime. The engine compiles them to native code on first load and caches the compiled artifact (AOT cache, SHA256-keyed, 256 MB cap with LRU eviction). Subsequent loads deserialize in <1 ms.
Each plugin gets its own Store with:
- Fuel budgeting — every WASM instruction costs fuel; each lifecycle hook has a fixed budget
- Epoch interruption — a background thread ticks every 50 ms; calls are capped at ~10 s wall-clock
- Store limits — 64 MB memory, 10K table elements, 1 instance per store
- PluginSandbox — rate-limited fetch, storage quotas, URL allowlists
Fuel budgets per lifecycle hook
| Hook | Fuel budget | ~Equivalent instructions |
|---|---|---|
on_server_start |
500,000,000 | Heavy init allowed |
on_request_complete |
100,000,000 | Hot path — keep lean |
on_cache_write |
50,000,000 | Post-write hook |
on_cache_invalidate |
50,000,000 | Invalidation hook |
on_reload |
200,000,000 | Bundle reload |
cleanup |
100,000,000 | Resource release |
Host functions (7)
All registered under the "bext" WASM module. Complex types use JSON-encoded (ptr, len) pairs. Negative len on return signals error.
| Function | Signature | Description |
|---|---|---|
bext.log |
(level_ptr, level_len, msg_ptr, msg_len) |
Structured logging |
bext.storage-get |
(key_ptr, key_len) -> (ptr, len) |
Read scoped storage |
bext.storage-set |
(key_ptr, key_len, val_ptr, val_len) -> i32 |
Write (quota enforced) |
bext.storage-delete |
(key_ptr, key_len) -> i32 |
Delete key |
bext.http-fetch |
(req_ptr, req_len) -> (ptr, len) |
HTTP request (rate-limited, URL-checked) |
bext.get-config |
() -> (ptr, len) |
Plugin config JSON |
bext.emit-metric |
(name_ptr, name_len, value: f64, tags_ptr, tags_len) |
Metric emission |
http-fetch request/response format:
// Request
{"url": "https://api.example.com/v1", "method": "POST", "headers": [["Authorization", "Bearer ..."]], "body": "{\"key\":1}"}
// Response
{"status": 200, "headers": [["content-type", "application/json"]], "body": "{\"ok\":true}"}
Configuration
[[plugins.wasm]]
name = "custom-analytics"
path = "plugins/custom-analytics.wasm"
priority = 1000 # Lower = runs first
[plugins.wasm.permissions]
allowed_urls = ["https://analytics.example.com/*"]
storage_quota_kb = 512
max_fetch_per_minute = 60
[plugins.wasm.config] # Arbitrary JSON, passed to on_server_start
api_key = "sk-..."
endpoint = "https://analytics.example.com/ingest"
Guest contract
The WASM module must export:
alloc(size: i32) -> i32— allocatesizebytes, return pointerinit() -> (i32, i32)— return JSONPluginManifestas(ptr, len)
Optional lifecycle exports:
on_server_start(config_ptr: i32, config_len: i32)on_server_stop()on_request_complete(event_ptr: i32, event_len: i32)on_cache_write(key_ptr: i32, key_len: i32, tags_ptr: i32, tags_len: i32)on_cache_invalidate(pattern_ptr: i32, pattern_len: i32, count: i32)on_reload()cleanup()
Key files
| File | Purpose |
|---|---|
crates/bext-plugin-wasm/src/runtime.rs |
Engine, linker, AOT cache, plugin loading |
crates/bext-plugin-wasm/src/adapter.rs |
LifecyclePlugin trait bridge |
crates/bext-plugin-wasm/src/sandbox.rs |
Rate limiter, storage quotas, URL allowlist |
crates/bext-plugin-wasm/src/host_functions.rs |
FetchRequest/FetchResponse types |
2. QuickJS Sandbox (rquickjs)
How it works
QuickJS plugins are plain JavaScript files evaluated in an embedded QuickJS interpreter (via rquickjs with parallel + allocator + loader features). Each plugin gets its own Runtime + Context with:
- Memory limit —
set_memory_limit(max_memory_mb * 1MB)on the runtime - Stack limit — 1 MB max stack size
- Wall-clock timeout — an interrupt handler checked every JS instruction compares
Instant::now()against a per-call deadline (default 10 s) - No filesystem/network — QuickJS has zero OS access by default; all I/O goes through
bext.*globals
JS API surface
Console:
console.log("message") // → tracing::info
console.warn("message") // → tracing::warn
console.error("message") // → tracing::error
console.info("message") // → tracing::info
console.debug("message") // → tracing::debug
Storage (file-backed, scoped per plugin, quota-enforced):
let value = bext.storage.get("my-key") // → String | null
let ok = bext.storage.set("my-key", "val") // → true | false (quota exceeded)
let ok = bext.storage.delete("my-key") // → true | false
Keys are sanitized: .., /, \, null bytes are rejected (path traversal prevention).
HTTP Fetch (SSRF-protected, rate-limited, URL-allowlisted):
// Returns JSON string: {"status": 200, "body": "..."}
let resp = JSON.parse(bext.fetch("https://api.example.com/data"))
let resp = JSON.parse(bext.fetch("https://api.example.com/data", "POST", '{"key":1}'))
Config (read-only object from TOML):
let markup = bext.config.markup_pct // From [plugins.quickjs.config]
Metrics:
bext.metric("cache_misses", 1.0, '{"route":"/products"}')
Plugin entry points
Define global functions matching the lifecycle hooks. All are optional.
// plugins/pricing-rules.js
function onServerStart(config) {
console.log("Pricing plugin started with config:", JSON.stringify(config));
}
function onRequestComplete(event) {
if (event.status >= 500) {
bext.metric("server_errors", 1.0);
}
}
function onCacheWrite(key, tags) {
console.debug("Cache write: " + key);
}
function onCacheInvalidate(pattern, count) {
console.info("Invalidated " + count + " entries matching " + pattern);
}
function onReload() {
console.info("Bundle reloaded");
}
function cleanup() {
console.info("Plugin shutting down");
}
Configuration
[[plugins.quickjs]]
name = "pricing-rules"
path = "plugins/pricing-rules.js"
priority = 1000
[plugins.quickjs.permissions]
allowed_urls = ["https://api.example.com/*"]
storage_quota_kb = 256
max_fetch_per_minute = 30
max_memory_mb = 32 # QuickJS heap limit (default: 64)
max_time_secs = 5 # Wall-clock timeout per call (default: 10)
[plugins.quickjs.config]
markup_pct = 15
vip_discount = 0.1
Key files
| File | Purpose |
|---|---|
crates/bext-plugin-quickjs/src/runtime.rs |
Runtime creation, memory/timeout setup, LifecyclePlugin adapter |
crates/bext-plugin-quickjs/src/api.rs |
console.* and bext.* global registration |
3. Process Sandbox (bext-plugin-nsjail)
How it works
Process-sandbox plugins are arbitrary scripts or binaries spawned as child processes. The host communicates lifecycle events via JSON-over-stdio IPC. No external binary dependency is required — it works everywhere (Linux, macOS, CI).
Each plugin gets:
- Separate process — inherent memory isolation from the host
- Piped stdio — stdin/stdout for IPC, stderr captured as logs
- Wall-clock timeout — enforced per lifecycle call via
send_lifecycle_event_timeout - Per-plugin storage directory — scoped by name under the shared
storage_dir - Environment variables —
BEXT_PLUGIN_NAMEandBEXT_STORAGE_DIRset automatically
Isolation strength depends on the platform. On a production Linux host, you can layer namespace isolation on top via the interpreter config (e.g., wrapping with firejail or bwrap). The crate itself provides process-level separation, not kernel-level sandboxing.
IPC protocol
One JSON line per message, delimited by newline.
Host → Guest (stdin):
{"method":"onServerStart","params":{"config":{...}}}
Guest → Host (stdout):
{"ok":true}
or
{"error":"something went wrong"}
Guest stderr → captured as log output
Methods: onServerStart, onServerStop, onRequestComplete, onCacheWrite, onCacheInvalidate, onReload, cleanup
Empty response or EOF is treated as OK (hooks are optional).
Example plugin (Python)
#!/usr/bin/env python3
"""plugins/data-export.py — process-sandbox lifecycle plugin."""
STORAGE = os.environ.get("BEXT_STORAGE_DIR", "/tmp")
def handle(request):
method = request["method"]
params = request.get("params", {})
if method == "onServerStart":
config = params.get("config", {})
print(json.dumps({"ok": True}), flush=True)
elif method == "onRequestComplete":
event = params.get("event", {})
if event.get("status", 0) >= 500:
# Write error to per-plugin scoped storage
with open(os.path.join(STORAGE, "errors.log"), "a") as f:
f.write(json.dumps(event) + "\n")
print(json.dumps({"ok": True}), flush=True)
else:
print(json.dumps({"ok": True}), flush=True)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
handle(request)
except Exception as e:
print(json.dumps({"error": str(e)}), flush=True)
Configuration
[[plugins.nsjail]]
name = "data-export"
path = "plugins/data-export.py"
priority = 1000
interpreter = "python3" # Optional — auto-detected from shebang or extension
[plugins.nsjail.permissions]
max_memory_mb = 128
max_time_secs = 30
storage_quota_kb = 10240 # 10 MB
[plugins.nsjail.config]
export_format = "csv"
Interpreter detection
If interpreter is omitted, it's auto-detected:
- Shebang — reads first line of script (
#!/usr/bin/env python3) - Extension fallback —
.py→python3,.js→node,.rb→ruby,.lua→lua,.pl→perl,.sh→/bin/sh - Default —
/bin/sh
Key files
| File | Purpose |
|---|---|
crates/bext-plugin-nsjail/src/runtime.rs |
Process spawning, LifecyclePlugin adapter, stderr drain |
crates/bext-plugin-nsjail/src/config.rs |
nsjail arg builder, interpreter detection |
crates/bext-plugin-nsjail/src/ipc.rs |
JSON-over-stdio protocol, param builders |
Shared Infrastructure
Plugin registry
All plugins — regardless of sandbox tier — register as Box<dyn LifecyclePlugin> in PluginRegistry. The registry is built once at startup, immutable, shared via Arc across all request handlers.
Lifecycle hooks fire asynchronously after the response is sent (fire-and-forget, best-effort). Errors are logged but don't affect the request.
Storage
All sandboxed plugins share a storage_dir root (default: plugin-storage/). Each plugin gets a subdirectory named after its name field. Storage is file-backed with per-plugin quotas.
Key sanitization prevents path traversal: .., /, \, and null bytes are rejected.
Permissions
| Permission | WASM | QuickJS | nsjail | Default |
|---|---|---|---|---|
allowed_urls |
URL glob for http-fetch | URL glob for bext.fetch | N/A (no network) | [] (deny all) |
storage_quota_kb |
Disk quota | Disk quota | Disk quota | 1024 KB |
max_fetch_per_minute |
Token bucket rate limit | Token bucket rate limit | N/A | 30 |
max_memory_mb |
Store limit (64 MB fixed) | QuickJS heap limit | cgroup memory cap | 64 MB |
max_time_secs |
Epoch deadline | Interrupt handler | nsjail --time_limit |
10 s |
Priority ordering
Plugins execute in priority order (lower = first). Built-in middleware priorities:
| Priority | Component |
|---|---|
| 100 | CORS |
| 200 | Rate limiter |
| 300 | Auth |
| 400 | Tenant resolver |
| 500 | Tracing |
| 600+ | Plugin middleware |
| 1000 | Default plugin priority |
Built-in Plugins
Four compile-time plugins ship with bext (no sandbox overhead):
| Plugin | Type | Config key | What it does |
|---|---|---|---|
| Analytics | Middleware + Lifecycle | plugins.analytics |
Per-request counters, JSON log or Prometheus export |
| Security Headers | Middleware | plugins.security_headers |
CSP with per-request nonces, HSTS, OWASP headers |
| Edge Rewrites | Middleware | plugins.edge_rewrites |
URL rewriting, redirects, A/B testing with sticky cookies |
| Image Pipeline | Standalone | plugins.image_optimization |
On-the-fly image resize/format with SSRF prevention |
Configuration Reference
Global plugin settings
[plugins]
wasm_dir = "plugins/" # Scan directory for .wasm files
storage_dir = "plugin-storage" # Shared storage root for all sandbox plugins
Full example
[plugins]
storage_dir = "plugin-storage"
# Built-in: analytics
[plugins.analytics]
enabled = true
export = "prometheus"
sample_rate = 1.0
# Built-in: security headers
[plugins.security_headers]
enabled = true
csp = "default-src 'self'; script-src 'self' 'nonce-{nonce}'"
# WASM plugin
[[plugins.wasm]]
name = "custom-analytics"
path = "plugins/custom-analytics.wasm"
priority = 900
[plugins.wasm.permissions]
allowed_urls = ["https://analytics.example.com/*"]
storage_quota_kb = 512
max_fetch_per_minute = 60
[plugins.wasm.config]
api_key = "sk-..."
# QuickJS plugin
[[plugins.quickjs]]
name = "pricing-rules"
path = "plugins/pricing-rules.js"
priority = 1000
[plugins.quickjs.permissions]
max_memory_mb = 32
max_time_secs = 5
[plugins.quickjs.config]
markup_pct = 15
# nsjail plugin
[[plugins.nsjail]]
name = "data-export"
path = "plugins/data-export.py"
priority = 1100
interpreter = "python3"
[plugins.nsjail.permissions]
max_memory_mb = 128
max_time_secs = 30
storage_quota_kb = 10240
Choosing a Sandbox Tier
| Scenario | Recommended tier | Why |
|---|---|---|
| High-performance plugin (>1K req/s) | WASM | Near-native speed, sub-ms overhead |
| Tenant-supplied business logic | QuickJS | Familiar JS, tiny footprint, safe defaults |
| Python/Ruby data processing | nsjail | Full language runtime, real filesystem |
| Untrusted third-party code | Process + firejail/nsjail wrapper | Strongest isolation (layer namespace isolation) |
| Config-driven transforms | QuickJS | Quick to write, no compilation step |
| Performance-critical middleware | WASM | Compiled code, deterministic fuel cost |
Security Model
Attack surfaces by tier
| Attack vector | WASM | QuickJS | Process sandbox |
|---|---|---|---|
| Memory corruption | Prevented (linear memory) | Prevented (managed heap) | Prevented (separate process) |
| Infinite loop | Fuel exhaustion → trap | Interrupt handler → abort | IPC timeout → error |
| Memory exhaustion | Store limits (64 MB) | set_memory_limit() |
OS process limits (informational max_memory_mb) |
| Filesystem access | None (host functions only) | None (globals only) | Full (scoped storage via BEXT_STORAGE_DIR env) |
| Network access | URL allowlist + SSRF block | URL allowlist + SSRF block | Full host network (layer firejail/nsjail for restriction) |
| Privilege escalation | WASM spec prevents | No OS access | Runs as same user (layer namespace tools for drop) |
| Side-channel (Spectre) | Theoretical (in-process) | Theoretical (in-process) | Mitigated (separate process) |
| Path traversal | Key sanitization | Key sanitization | Filesystem mount scoping |
Defense in depth
All three tiers enforce:
- Deny-by-default networking — no URLs allowed unless explicitly listed
- Storage scoping — each plugin's storage is isolated by
plugin_idsubdirectory - Key sanitization —
..,/,\, null bytes rejected in storage keys - Rate limiting — token bucket on HTTP fetches (refills every 60 s)
- Resource caps — memory, CPU time, storage quota, file descriptor count
Roadmap
Completed
- WASM runtime with AOT cache, fuel budgets, epoch interruption
- 7 host functions including
http-fetch(via ureq) - WASM
LifecyclePluginadapter (real wasmtime calls, not stubs) - QuickJS sandbox with memory/time limits and JS API surface
- nsjail process sandbox with namespace/cgroup/seccomp isolation
- JSON-over-stdio IPC protocol for nsjail plugins
- Shared plugin registry with all three tiers
- Server config + loading for all plugin types
- 4 built-in compile-time plugins (analytics, security, rewrites, images)
- KV store host function (SQLite-backed, namespace-scoped) —
kv.rs, 9 tests - Queue host functions (durable, at-least-once delivery) —
queue.rs, 10 tests - Plugin hot-reload (
ArcSwap/RwLockswap) —hot_reload.rs, 7 tests - Per-app plugin scoping —
scoped.rs, 9 tests
Planned
- Cache access host functions (ISR read/write/invalidate)
- Secrets host function (encrypted storage)
- Plugin SDK for Rust (proc macros, alloc helpers)
- Plugin SDK for JS (compile TS → WASM via wasm-bindgen)
- WASM middleware plugin support (
on_request/on_response) - Plugin marketplace / registry
File Reference
Path (relative to bext/) |
Purpose |
|---|---|
crates/bext-plugin-api/src/types.rs |
SandboxType, SandboxPermissions, WasmPluginPermissions, fuel budgets |
crates/bext-plugin-api/src/lifecycle.rs |
LifecyclePlugin trait (7 hooks) |
crates/bext-plugin-api/src/middleware.rs |
MiddlewarePlugin trait |
crates/bext-plugin-api/src/transform.rs |
TransformPlugin trait |
crates/bext-plugin-api/src/cache.rs |
CacheBackend trait |
crates/bext-plugin-wasm/src/runtime.rs |
wasmtime engine, linker, AOT cache, host function registration |
crates/bext-plugin-wasm/src/adapter.rs |
WASM → LifecyclePlugin bridge |
crates/bext-plugin-wasm/src/sandbox.rs |
Rate limiter, storage quota, URL allowlist |
crates/bext-plugin-wasm/src/host_functions.rs |
FetchRequest/FetchResponse types |
crates/bext-plugin-quickjs/src/runtime.rs |
QuickJS runtime + LifecyclePlugin adapter |
crates/bext-plugin-quickjs/src/api.rs |
JS globals: console., bext. |
crates/bext-plugin-nsjail/src/runtime.rs |
Process spawning + LifecyclePlugin adapter |
crates/bext-plugin-nsjail/src/config.rs |
nsjail arg builder, interpreter detection |
crates/bext-plugin-nsjail/src/ipc.rs |
JSON-over-stdio protocol |
crates/bext-core/src/plugin/registry.rs |
Central plugin coordinator |
crates/bext-core/src/plugin/builtin/ |
Security headers, analytics, rewrites, image pipeline |
crates/bext-server/src/config.rs |
Plugin config deserialization |
crates/bext-server/src/main.rs |
build_plugin_registry() — loads all plugin types |
crates/bext-server/src/middleware/plugin.rs |
actix-web middleware integration |
bext.config.example.toml |
Full configuration example |