Skip to content
github-actions[bot] edited this page Jun 12, 2026 · 1 revision

Rhai Middleware

Rhai is a specific embedded scripting language (not a compilation target). You write .rhai files — plain text files in Rhai syntax — and Conduit runs them for every request that passes through the middleware. No build step, no compiler, no toolchain required.

Requires cargo build --features rhai

When to use Rhai vs WASM:

Rhai WASM
Language Rhai only Rust, C, Go, AssemblyScript, …
Build step None — edit and reload Compile to .wasm
Read request headers request.header("Name")
Write request headers (to upstream) ❌ ¹ conduit_set_request_header
Read client IP ❌ ² conduit_get_client_ip
Plugin config access
Response phase phase: "response" on_response export
Best for Guards, path checks, allow/deny decisions Header mutation, high performance, complex logic

¹ Rhai can set headers on the abort response (response.header()), but cannot add headers to the request forwarded to the upstream. Use WASM or requestTransform.setHeaders in config for that.

² Client IP is not exposed to Rhai scripts. Use ipFilter in config or a WASM plugin for IP-based decisions.

Use Rhai when you want to inspect headers and allow/deny requests without a build step. Switch to WASM when you need to add or modify forwarded request headers, or access the client IP.


Table of Contents


Configuration

Rhai scripts are plain text files with a .rhai extension. Create them with any text editor — no compiler needed.

# conduit.yaml
middleware:
  - type: script
    path: ./scripts/my-check.rhai # path relative to working directory

  # Multiple scripts run in order — first false wins
  - type: script
    path: ./scripts/rate-check.rhai

  # Optional config object available inside the script as `config` variable
  - type: script
    path: ./scripts/auth.rhai
    config:
      allowed_token: "secret-123"
      max_body_kb: 512
// conduit.json
{
  "middleware": [
    { "type": "script", "path": "./scripts/my-check.rhai" },
    {
      "type": "script",
      "path": "./scripts/auth.rhai",
      "config": { "allowed_token": "secret-123", "max_body_kb": 512 }
    }
  ]
}

Scripts are loaded from the path relative to the working directory where conduit is started. Paths starting with / are absolute.


Script API

Every script receives two pre-populated variables: request and response.

request object

Read-only view of the incoming HTTP request.

Property / Method Type Description
request.path String Request path, e.g. "/api/users"
request.method String HTTP method, e.g. "GET", "POST"
request.query String Raw query string, e.g. "page=1&size=10" (empty when absent)
request.header("Name") String Header value — case-insensitive; empty string when absent

response object

Used to build the response when aborting the pipeline.

Property / Method Type Description
response.status int HTTP status code to send (default: 200)
response.body String Response body text (default: "")
response.header("Name", "Value") Append a response header

Return value

Return Effect
true (or any truthy value) Pipeline continues — request forwarded to upstream
(no explicit return) Treated as true — pipeline continues
false Pipeline stops — response is sent to the client

Examples

Allow / deny by header

Check that an Authorization header is present. Returns 401 with a WWW-Authenticate challenge when it is missing.

// auth-check.rhai
let token = request.header("Authorization");
if token == "" {
    response.status = 401;
    response.body   = "Unauthorized";
    response.header("WWW-Authenticate", "Bearer realm=\"api\"");
    return false;
}
true

Check a specific bearer token:

// bearer-token.rhai
let auth = request.header("Authorization");
if auth != "Bearer my-secret-token" {
    response.status = 403;
    response.body   = "Forbidden";
    return false;
}
true

Block by path

Deny all requests to /admin from this middleware point:

// block-admin.rhai
if request.path.starts_with("/admin") {
    response.status = 404;   // pretend it doesn't exist
    return false;
}
true

Block by HTTP method

Allow only GET and HEAD:

// read-only.rhai
let allowed = ["GET", "HEAD"];
if !allowed.contains(request.method) {
    response.status = 405;
    response.body   = "Method Not Allowed";
    response.header("Allow", "GET, HEAD");
    return false;
}
true

Check query parameters

Block or restrict access based on query string values:

// debug-guard.rhai — block ?debug=1 in production
if request.query.contains("debug=1") {
    response.status = 403;
    response.body   = "Debug mode is disabled";
    return false;
}
true
// api-version-check.rhai — require ?version=2
if !request.query.contains("version=2") {
    response.status = 400;
    response.body   = "Missing required ?version=2 parameter";
    return false;
}
true

Redirect old paths

Use a redirect response to route legacy URLs to new ones:

// legacy-redirect.rhai
// Redirect /legacy/* → /api/*
if request.path.starts_with("/legacy/") {
    // Strip "/legacy/" prefix (8 chars) and build new URL
    let new_path = "/api/" + request.path.sub_string(8);
    response.status = 301;
    response.header("Location", new_path);
    return false;   // send the redirect response
}
true
// version-redirect.rhai
// Redirect /v1/* → /v2/*
if request.path.starts_with("/v1/") {
    response.status = 308;   // Permanent Redirect, preserves method
    response.header("Location", "/v2/" + request.path.sub_string(4));
    return false;
}
true

To rewrite the path transparently (without the client seeing a redirect), use proxy.*.rewrite rules in the config — Rhai cannot modify the forwarded path directly.


Custom JSON error

// json-errors.rhai
let key = request.header("X-API-Key");
if key == "" {
    response.status = 401;
    response.header("Content-Type", "application/json");
    response.body = `{"error":"missing API key","status":401}`;
    return false;
}
true

Log and pass through

Scripts have access to Rhai's standard library. Use print to write a line to the process stdout (captured by journald/Docker logs, not the access log):

// debug-log.rhai
print(`[debug] ${request.method} ${request.path}`);
true

print outputs to stdout at the process level — it does not appear in structured JSON access logs and has no log level. For production observability, prefer logging: json and OTLP tracing. Use print for local development only.


Per-script config

Set a config object in the middleware entry — it becomes a config variable in the script scope. Fields map directly to Rhai values (strings, numbers, booleans, arrays, nested objects).

# conduit.yaml
middleware:
  - type: script
    path: ./scripts/check-key.rhai
    config:
      valid_key: "$MY_SECRET_KEY"
      blocked_paths: ["/internal", "/debug"]
      max_body_kb: 512
// conduit.json
{
  "middleware": [
    {
      "type": "script",
      "path": "./scripts/check-key.rhai",
      "config": {
        "valid_key": "$MY_SECRET_KEY",
        "blocked_paths": ["/internal", "/debug"],
        "max_body_kb": 512
      }
    }
  ]
}
// check-key.rhai — access config fields directly

// String field
let key = request.header("X-API-Key");
if key != config.valid_key {
    response.status = 401;
    return false;
}

// Array field
if config.blocked_paths.contains(request.path) {
    response.status = 403;
    return false;
}

true

Config types:

YAML / JSON value Rhai type
"string" String
42 i64
3.14 f64
true / false bool
[1, 2, 3] Array
{ key: val } Map — access as config.key
absent (no config:) () (unit) — check with config == ()

Response phase

Scripts can run after the upstream response is received by setting phase: "response" in the middleware entry. The same .rhai file path — no separate file needed.

# conduit.yaml
middleware:
  # Request phase (default) — runs before upstream
  - type: script
    path: ./scripts/auth-check.rhai

  # Response phase — runs after upstream responds
  - type: script
    path: ./scripts/add-response-headers.rhai
    phase: "response"
// conduit.json
{
  "middleware": [
    { "type": "script", "path": "./scripts/auth-check.rhai" },
    {
      "type": "script",
      "path": "./scripts/add-response-headers.rhai",
      "phase": "response"
    }
  ]
}

In a response-phase script, the available variables differ from request phase:

Variable Properties / Methods Description
upstream .status HTTP status returned by upstream (e.g. 200, 404)
.header("Name") Read an upstream response header (empty if absent)
response .set_header("Name", "Value") Add/overwrite a header on the client response
.remove_header("Name") Remove a header from the client response
config same as request phase Per-script config object (or () if not set)

request is not available in response-phase scripts.

response methods differ between phases:

  • Request phase: response.header("Name", "Value") — appends a header to the abort response (only used when return false)
  • Response phase: response.set_header("Name", "Value") — modifies the upstream response forwarded to the client

Return value is ignored — response scripts always continue.

// add-response-headers.rhai — add security header to all responses
response.set_header("X-Served-By", "conduit");
response.remove_header("X-Powered-By");
// log-errors.rhai — log when upstream returns 5xx
if upstream.status >= 500 {
    print(`upstream error: ${upstream.status} for path`);
    // Add debug header for internal use
    response.set_header("X-Upstream-Error", upstream.status.to_string());
}
// hide-server.rhai — remove server identity headers on all responses
response.remove_header("Server");
response.remove_header("X-Powered-By");
response.remove_header("Via");
// cors-on-error.rhai — ensure CORS header is present even on 4xx/5xx
// (some upstreams strip CORS headers on errors)
let origin = "https://app.example.com";
if upstream.header("Access-Control-Allow-Origin") == "" {
    response.set_header("Access-Control-Allow-Origin", origin);
}

Execution model

  • Scripts are compiled once (on first use) and the AST is cached for the lifetime of the process. Hot-reload (conduit reload) clears the cache so the next request picks up the new file.
  • Scripts execute synchronously in the request-handling thread.
  • Each request gets its own request and response scope — there is no shared mutable state between requests.
  • The Rhai engine runs in safe mode — file I/O, network, and system calls are not available.

Resource limits (hard limits enforced by the engine):

Limit Value
Max operations per script execution 500,000
Max string size 1 MiB (1,048,576 bytes)
Max array / map size 65,536 elements

A script that exceeds any limit terminates with a runtime error and fails open (request passes through). Loops in middleware scripts should be short or bounded.


Error handling

Conduit uses fail-open: if a script fails to compile or throws a runtime error, the error is logged as a warning and the request passes through as if the script returned true.

This means a broken script will never take down your server, but it also means errors can silently bypass auth checks. Monitor your logs.

WARN conduit::filter::script: Rhai compile error: Variable not found: undefined_var
WARN conduit::filter::script: Rhai runtime error: Division by zero

Set RUST_LOG=conduit=debug to see the full error context.


Rhai language reference

Rhai is a simple, Rust-like scripting language. Below are the patterns most useful when writing request middleware.

Variables and strings

let path   = request.path;    // "/api/users"
let method = request.method;  // "GET"
let query  = request.query;   // "page=1&size=10"
let auth   = request.header("Authorization");  // "" if absent

// String interpolation
let msg = `${method} ${path} rejected`;

// String tests
path.starts_with("/api")
path.ends_with(".json")
path.contains("admin")
auth.len() > 0
auth == ""

// Case conversion
method.to_lower() == "get"
path.to_upper()

// Extract substring — sub_string(start) or sub_string(start, length)
let tail = path.sub_string(5);      // skip first 5 chars
let seg  = path.sub_string(1, 3);   // chars 1..4

Checks and early return

// Allow → return true (or just fall through)
// Deny  → set response, return false

if auth == "" {
    response.status = 401;
    response.body   = "Unauthorized";
    return false;
}

// One-liner guard
if !path.starts_with("/api") { return true; }  // not our concern

Arrays

let blocked = ["/admin", "/internal", "/debug"];
if blocked.contains(path) {
    response.status = 403;
    return false;
}

let methods = ["GET", "POST"];
if !methods.contains(method) {
    response.status = 405;
    response.header("Allow", "GET, POST");
    return false;
}

Accessing config

// config is set in conduit.yaml under middleware[].config
// Absent config → config == ()

if config == () {
    // No config provided — use fallback
    return true;
}

if config.allowed_key != request.header("X-API-Key") {
    response.status = 403;
    return false;
}

// Numeric config
if config.max_path_len < path.len() {
    response.status = 414;
    return false;
}

// Array config
if config.blocked_paths.contains(path) {
    response.status = 403;
    return false;
}

Response headers and redirect

// Add response header (only effective when returning false)
response.header("X-Reason", "blocked");
response.header("Content-Type", "application/json");

// Redirect
response.status = 302;
response.header("Location", "/new-path");
return false;

Conditionals and loops

if x > 10 { ... } else if x > 5 { ... } else { ... }

// Switch-like
let result = switch method {
    "GET"  => "read",
    "POST" => "write",
    _      => "unknown",
};

// Loops (rarely needed in middleware)
for item in array { ... }
while condition { ... }

Full language documentation: rhai.rs/book

Clone this wiki locally