stablescripting included in bundle

wasm v0.3.0

Execute custom Wasm modules for high-performance request/response processing in any language.

Protocol v2 Features

As of v0.2.0, the WebAssembly agent supports protocol v2 with:

  • Capability negotiation: Reports supported features during handshake
  • Health reporting: Exposes health status for monitoring
  • Metrics export: Counter metrics for requests processed/blocked and wasm errors
  • gRPC transport: High-performance gRPC transport via --grpc-address
  • Lifecycle hooks: Graceful shutdown and drain handling

Overview

WebAssembly agent for Zentinel reverse proxy. Execute custom Wasm modules for high-performance request/response processing. Write modules in Rust, Go, C, AssemblyScript, or any language that compiles to WebAssembly.

Features

  • Language-Agnostic: Write modules in Rust, Go, C, AssemblyScript, etc.
  • High Performance: Fast wasmtime runtime with instance pooling
  • Strong Isolation: Secure Wasm sandboxing
  • JSON Data Exchange: Simple JSON-based communication
  • Header Manipulation: Add/remove headers on requests and responses
  • Audit Tags: Add tags for logging and analytics
  • Fail-Open Mode: Graceful error handling

Installation

The easiest way to install this agent is via the Zentinel bundle command:

# Install just this agent
zentinel bundle install wasm

# Or install all available agents
zentinel bundle install --all

The bundle command automatically downloads the correct binary for your platform and places it in ~/.zentinel/agents/.

Using Cargo

cargo install zentinel-agent-wasm

Configuration

Command Line

zentinel-wasm-agent --socket /var/run/zentinel/wasm.sock \
  --module /etc/zentinel/modules/security.wasm

Environment Variables

OptionEnv VarDescriptionDefault
--socketAGENT_SOCKETUnix socket path/tmp/zentinel-wasm.sock
--grpc-addressAGENT_GRPC_ADDRESSgRPC listen address (e.g., 0.0.0.0:50051)-
--moduleWASM_MODULEWasm module file (.wasm)(required)
--pool-sizeWASM_POOL_SIZEInstance pool size4
--verboseWASM_VERBOSEEnable debug loggingfalse
--fail-openFAIL_OPENAllow requests on module errorsfalse

Zentinel Configuration

agent "wasm" {
    socket "/var/run/zentinel/wasm.sock"
    timeout 50ms
    events ["request_headers" "response_headers"]
}

route {
    match { path-prefix "/" }
    agents ["wasm"]
    upstream "backend"
}

Writing Wasm Modules

Required ABI

Modules must export these functions:

// Memory allocation (required)
alloc(size: i32) -> i32          // Allocate bytes, return pointer
dealloc(ptr: i32, size: i32)     // Free memory

// Request/Response handlers (at least one required)
on_request_headers(ptr: i32, len: i32) -> i64
on_response_headers(ptr: i32, len: i32) -> i64

Request Object (Input JSON)

{
    "method": "GET",
    "uri": "/api/users?page=1",
    "client_ip": "192.168.1.100",
    "correlation_id": "abc123",
    "headers": {
        "Content-Type": "application/json",
        "User-Agent": "Mozilla/5.0..."
    }
}

Result Object (Output JSON)

{
    "decision": "allow",
    "status": 403,
    "body": "Forbidden",
    "add_request_headers": {"X-Processed": "true"},
    "remove_request_headers": ["X-Debug"],
    "add_response_headers": {"X-Frame-Options": "DENY"},
    "tags": ["processed"]
}

Decision Values

DecisionDescription
allowAllow the request/response
blockBlock with status (default: 403) and body
denySame as block
redirectRedirect to URL in body field
challengeIssue a challenge (CAPTCHA, JS challenge, proof-of-work)

Challenge Decision

{
    "decision": "challenge",
    "challenge_type": "captcha",
    "challenge_params": {
        "site_key": "your-captcha-site-key",
        "action": "login"
    },
    "tags": ["bot-challenge"]
}

Extended Audit Metadata

For detailed audit logging, include rule IDs, confidence scores, and reason codes:

{
    "decision": "block",
    "status": 403,
    "tags": ["path-traversal"],
    "rule_ids": ["SEC-001", "OWASP-930"],
    "confidence": 0.95,
    "reason_codes": ["PATH_TRAVERSAL_DETECTED"]
}
FieldTypeDescription
tagsarrayFreeform tags for categorization
rule_idsarraySpecific rule identifiers that triggered
confidencenumberConfidence score (0.0 to 1.0)
reason_codesarrayStructured reason codes

Routing Metadata

Control upstream selection dynamically:

{
    "decision": "allow",
    "routing_metadata": {
        "upstream": "api-v2-backend",
        "priority": "high"
    }
}

Body Hooks

For body inspection, additional handler functions are available:

HookDescription
on_request_headers(ptr, len) -> i64Called when request headers are received
on_request_body(ptr, len) -> i64Called when request body is available
on_response_headers(ptr, len) -> i64Called when response headers are received
on_response_body(ptr, len) -> i64Called when response body is available

Note: Body hooks require events ["request_headers" "request_body_chunk" "response_headers" "response_body_chunk"] in the Zentinel configuration.

Body Mutation

Modify request or response bodies:

{
    "decision": "allow",
    "request_body_mutation": {
        "action": "pass_through",
        "chunk_index": 0
    }
}
{
    "decision": "allow",
    "response_body_mutation": {
        "action": "replace",
        "chunk_index": 0,
        "data": "Modified response content"
    }
}
ActionDescription
pass_throughPass the chunk unchanged
replaceReplace chunk with data field content
dropDrop the chunk entirely

Needs More Data

Signal that you need the request body before making a decision:

{
    "decision": "allow",
    "needs_more": true
}

Return this from on_request_headers to receive the body in on_request_body before the final decision.

Rust Module Example

use serde::{Deserialize, Serialize};
use std::alloc::{alloc as heap_alloc, dealloc as heap_dealloc, Layout};

#[derive(Deserialize)]
struct Request {
    method: String,
    uri: String,
    client_ip: String,
    headers: std::collections::HashMap<String, String>,
}

#[derive(Serialize, Default)]
struct Result {
    decision: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    status: Option<u16>,
    #[serde(skip_serializing_if = "Option::is_none")]
    body: Option<String>,
}

#[no_mangle]
pub extern "C" fn alloc(size: i32) -> i32 {
    let layout = Layout::from_size_align(size as usize, 1).unwrap();
    unsafe { heap_alloc(layout) as i32 }
}

#[no_mangle]
pub extern "C" fn dealloc(ptr: i32, size: i32) {
    let layout = Layout::from_size_align(size as usize, 1).unwrap();
    unsafe { heap_dealloc(ptr as *mut u8, layout) }
}

#[no_mangle]
pub extern "C" fn on_request_headers(ptr: i32, len: i32) -> i64 {
    let input = unsafe {
        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
        std::str::from_utf8(slice).unwrap()
    };

    let request: Request = serde_json::from_str(input).unwrap();

    let result = if request.uri.contains("/admin") {
        Result {
            decision: "block".to_string(),
            status: Some(403),
            body: Some("Forbidden".to_string()),
        }
    } else {
        Result {
            decision: "allow".to_string(),
            ..Default::default()
        }
    };

    let output = serde_json::to_string(&result).unwrap();
    let bytes = output.as_bytes();
    let len = bytes.len() as i32;
    let ptr = alloc(len);
    unsafe {
        std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, bytes.len());
    }
    ((ptr as i64) << 32) | (len as i64)
}

Build Module

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

Bot Protection Example (Rust)

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Deserialize)]
struct Request {
    uri: String,
    headers: HashMap<String, String>,
}

#[derive(Serialize, Default)]
struct Result {
    decision: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    challenge_type: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    challenge_params: Option<HashMap<String, String>>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    tags: Vec<String>,
}

#[no_mangle]
pub extern "C" fn on_request_headers(ptr: i32, len: i32) -> i64 {
    let input = unsafe {
        let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
        std::str::from_utf8(slice).unwrap()
    };

    let request: Request = serde_json::from_str(input).unwrap();
    let ua = request.headers.get("User-Agent").map(|s| s.as_str()).unwrap_or("");

    let result = if ua.is_empty() {
        // No User-Agent - issue JS challenge
        Result {
            decision: "challenge".to_string(),
            challenge_type: Some("js_challenge".to_string()),
            tags: vec!["no-user-agent".to_string()],
            ..Default::default()
        }
    } else if ua.to_lowercase().contains("bot") || ua.to_lowercase().contains("curl") {
        // Suspicious UA - issue CAPTCHA
        let mut params = HashMap::new();
        params.insert("site_key".to_string(), "your-captcha-site-key".to_string());

        Result {
            decision: "challenge".to_string(),
            challenge_type: Some("captcha".to_string()),
            challenge_params: Some(params),
            tags: vec!["suspicious-ua".to_string()],
        }
    } else {
        Result {
            decision: "allow".to_string(),
            ..Default::default()
        }
    };

    // ... encode and return result
    let output = serde_json::to_string(&result).unwrap();
    let bytes = output.as_bytes();
    let len = bytes.len() as i32;
    let ptr = alloc(len);
    unsafe {
        std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr as *mut u8, bytes.len());
    }
    ((ptr as i64) << 32) | (len as i64)
}

Instance Pooling

Configure pool size based on workload:

Pool SizeUse Case
1Minimum memory, sequential processing
4 (default)Good balance for most workloads
8+High-concurrency scenarios

Error Handling

ModeOn Error
--fail-open enabledLog error, allow request, add wasm-error and fail-open tags
--fail-open disabledLog error, block with 500 status, add wasm-error tag

Comparison with Other Scripting Agents

FeatureWebAssemblyJavaScriptLua
LanguageAny (Rust, Go, C)JavaScriptLua
RuntimewasmtimeQuickJSmlua
PerformanceFastestFastFast
SandboxingStrong (Wasm)BasicBasic
ComplexityHigherLowerLower
Use CaseMax performanceFull regexSimple scripts

Use WebAssembly when:

  • Maximum performance requirements
  • Existing Rust/Go/C code to port
  • Strong isolation between modules
  • Memory-safe execution

Use JavaScript or Lua when:

  • Simpler scripting needs
  • Rapid prototyping
  • No compilation step desired
AgentIntegration
JavaScriptSimpler scripting with full regex
LuaSimple scripting with Lua syntax
WAFCombine with security rules