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 — allocate size bytes, return pointer
  • init() -> (i32, i32) — return JSON PluginManifest as (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 limitset_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 variablesBEXT_PLUGIN_NAME and BEXT_STORAGE_DIR set 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:

  1. Shebang — reads first line of script (#!/usr/bin/env python3)
  2. Extension fallback.pypython3, .jsnode, .rbruby, .lualua, .plperl, .sh/bin/sh
  3. 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:

  1. Deny-by-default networking — no URLs allowed unless explicitly listed
  2. Storage scoping — each plugin's storage is isolated by plugin_id subdirectory
  3. Key sanitization.., /, \, null bytes rejected in storage keys
  4. Rate limiting — token bucket on HTTP fetches (refills every 60 s)
  5. 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 LifecyclePlugin adapter (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/RwLock swap) — 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