-
Notifications
You must be signed in to change notification settings - Fork 4.4k
10.1 Gateway Security
Relevant source files
The following files were used as context for generating this wiki page:
This document covers the security architecture of the HTTP gateway, including authentication mechanisms, rate limiting, network isolation, and webhook signature verification. The gateway implements defense-in-depth with multiple independent security layers to protect against unauthorized access, brute-force attacks, and various abuse vectors.
For general security architecture across the entire system (including tool execution, file access, and autonomy controls), see Security Model. For channel-specific security (allowlists, pairing codes), see Channel Security.
The gateway (src/gateway/mod.rs) provides HTTP endpoints for external integrations. Security is enforced through five independent layers:
| Layer | Mechanism | Purpose |
|---|---|---|
| Network | Localhost binding, tunnel requirement | Prevent public exposure |
| Authentication | Pairing + bearer tokens | Identify legitimate clients |
| Rate Limiting | Sliding window per IP | Prevent brute-force and DoS |
| Webhook Auth | SHA-256 secret validation | Verify webhook sources |
| Request Security | Body limits, timeouts, idempotency | Prevent resource exhaustion |
Each layer operates independently — even if one is bypassed, the others provide redundant protection.
Sources: src/gateway/mod.rs:1-505
graph TB
subgraph "External Clients"
HTTP["HTTP Client"]
WA["WhatsApp Platform"]
WebhookSender["Webhook Sender"]
end
subgraph "Gateway Layers src/gateway/mod.rs"
Bind["Bind Check<br/>is_public_bind()"]
Tunnel["Tunnel Requirement<br/>create_tunnel()"]
BodyLimit["RequestBodyLimitLayer<br/>MAX_BODY_SIZE=64KB"]
Timeout["TimeoutLayer<br/>REQUEST_TIMEOUT_SECS=30"]
RateLimit["GatewayRateLimiter<br/>SlidingWindowRateLimiter"]
Pairing["PairingGuard<br/>src/security/pairing.rs"]
WebhookSecret["hash_webhook_secret()<br/>SHA-256 + constant_time_eq()"]
WhatsAppSig["verify_whatsapp_signature()<br/>HMAC-SHA256"]
Idempotency["IdempotencyStore<br/>TTL + max_keys"]
end
subgraph "Handlers"
HandlePair["handle_pair()<br/>/pair"]
HandleWebhook["handle_webhook()<br/>/webhook"]
HandleWhatsAppVerify["handle_whatsapp_verify()<br/>/whatsapp GET"]
HandleWhatsAppMsg["handle_whatsapp_message()<br/>/whatsapp POST"]
HandleHealth["handle_health()<br/>/health"]
end
subgraph "Backend"
Provider["Provider trait"]
Memory["Memory trait"]
Observer["Observer trait"]
end
HTTP --> Bind
WebhookSender --> Bind
WA --> Bind
Bind --> Tunnel
Tunnel --> BodyLimit
BodyLimit --> Timeout
Timeout --> RateLimit
RateLimit -->|/pair| HandlePair
RateLimit -->|/webhook| HandleWebhook
RateLimit -->|/whatsapp| HandleWhatsAppVerify
RateLimit -->|/whatsapp| HandleWhatsAppMsg
RateLimit -->|/health| HandleHealth
HandlePair --> Pairing
HandleWebhook --> Pairing
HandleWebhook --> WebhookSecret
HandleWebhook --> Idempotency
HandleWhatsAppMsg --> WhatsAppSig
Pairing --> Provider
WebhookSecret --> Provider
WhatsAppSig --> Provider
Provider --> Memory
Provider --> Observer
Sources: src/gateway/mod.rs:282-505, src/security/pairing.rs:1-485
The gateway refuses to bind to public addresses unless explicitly configured. This prevents accidental exposure to the internet.
flowchart TD
Start["run_gateway(host, port)"] --> CheckBind{"is_public_bind(host)?"}
CheckBind -->|"No (127.0.0.1, localhost)"| BindOK["Proceed with bind"]
CheckBind -->|"Yes (0.0.0.0, public IP)"| CheckTunnel{"config.tunnel.provider != 'none'?"}
CheckTunnel -->|Yes| BindOK
CheckTunnel -->|No| CheckOverride{"config.gateway.allow_public_bind?"}
CheckOverride -->|Yes| WarnAndBind["⚠️ Warn + Proceed"]
CheckOverride -->|No| Reject["🛑 Refuse to start<br/>Return error"]
BindOK --> CreateListener["TcpListener::bind(addr)"]
WarnAndBind --> CreateListener
Implementation: src/gateway/mod.rs:284-292
The is_public_bind() function checks if the host is 127.0.0.1, localhost, ::1, or [::1]. All other addresses are considered public.
Sources: src/security/pairing.rs:224-231
When a tunnel is configured (Tunnel documentation), the gateway automatically starts it and prints the public URL. This allows external access while keeping the actual bind address on localhost.
Supported tunnel providers:
-
tailscale- Zero-config VPN tunnel -
cloudflare- Cloudflare Tunnel (requirescloudflared) -
ngrok- ngrok tunnel (requires auth token) -
none- No tunnel (local-only)
Sources: src/gateway/mod.rs:415-431
Pairing is a first-connect authentication mechanism that prevents unauthorized access. On startup, if no paired tokens exist, the gateway generates a 6-digit one-time code and prints it to the terminal. Clients must present this code via X-Pairing-Code header on POST /pair to receive a bearer token.
sequenceDiagram
participant GW as Gateway Startup
participant PG as PairingGuard
participant Term as Terminal
participant Client as HTTP Client
participant Config as config.toml
GW->>PG: new(require_pairing=true, existing_tokens=[])
PG->>PG: generate_code()<br/>(6 digits, CSPRNG)
PG-->>GW: pairing_code = "123456"
GW->>Term: Print pairing code
Note over Client: User sees code: 123456
Client->>GW: POST /pair<br/>X-Pairing-Code: 123456
GW->>PG: try_pair("123456")
PG->>PG: constant_time_eq(code, expected)
PG->>PG: generate_token()<br/>(32 bytes random → zc_...)
PG->>PG: hash_token(plaintext)<br/>(SHA-256 → 64 hex)
PG->>PG: Store hash in paired_tokens
PG-->>GW: Ok(Some("zc_abc123..."))
GW->>Client: 200 OK<br/>{"token": "zc_abc123..."}
GW->>Config: persist paired_tokens<br/>(save hash only)
Note over Client: Client saves token
Client->>GW: POST /webhook<br/>Authorization: Bearer zc_abc123...
GW->>PG: is_authenticated("zc_abc123...")
PG->>PG: hash_token(input)<br/>Compare against stored hash
PG-->>GW: true
GW->>Client: 200 OK (request processed)
Sources: src/security/pairing.rs:38-151, src/gateway/mod.rs:392-612
Bearer tokens are generated using cryptographically secure randomness:
-
Entropy Source:
rand::rng()backed by OS CSPRNG (/dev/urandomon Linux,BCryptGenRandomon Windows) -
Format:
zc_<64 hex chars>(32 random bytes → 256 bits of entropy) - Storage: SHA-256 hash of the token (prevents plaintext exposure in config)
Implementation: src/security/pairing.rs:177-193
The 6-digit code uses rejection sampling to eliminate modulo bias:
const UPPER_BOUND: u32 = 1_000_000;
const REJECT_THRESHOLD: u32 = (u32::MAX / UPPER_BOUND) * UPPER_BOUND;
loop {
let uuid = uuid::Uuid::new_v4(); // Uses getrandom (CSPRNG)
let bytes = uuid.as_bytes();
let raw = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
if raw < REJECT_THRESHOLD {
return format!("{:06}", raw % UPPER_BOUND);
}
// Rejection probability: ~0.02%
}Sources: src/security/pairing.rs:153-175
The PairingGuard tracks failed pairing attempts and enforces a lockout after MAX_PAIR_ATTEMPTS (5) failures:
| State | Behavior |
|---|---|
| 0-4 failed attempts | Allow pairing attempts |
| 5+ failed attempts | Lock out for PAIR_LOCKOUT_SECS (300s = 5 min) |
| Successful pairing | Reset failed attempt counter |
Implementation: src/security/pairing.rs:83-128
stateDiagram-v2
[*] --> Ready: require_pairing=true<br/>no tokens
Ready --> AttemptingPair: POST /pair
AttemptingPair --> Success: Code matches
AttemptingPair --> Failed: Code mismatch
Success --> Paired: Return bearer token
Failed --> Ready: attempts < 5
Failed --> LockedOut: attempts >= 5
LockedOut --> Ready: After 5 minutes
Paired --> [*]: Token persisted
Sources: src/security/pairing.rs:15-36
To prevent timing attacks that could leak code/token information, all string comparisons use constant-time equality:
pub fn constant_time_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
let len_diff = a.len() ^ b.len();
let max_len = a.len().max(b.len());
let mut byte_diff = 0u8;
// Always iterate over max length, padding shorter with zeros
for i in 0..max_len {
let x = *a.get(i).unwrap_or(&0);
let y = *b.get(i).unwrap_or(&0);
byte_diff |= x ^ y;
}
(len_diff == 0) & (byte_diff == 0)
}This ensures comparison time is independent of where strings differ, preventing attackers from inferring correct prefixes via timing measurements.
Sources: src/security/pairing.rs:201-222
The gateway implements sliding-window rate limiting using GatewayRateLimiter, which wraps two independent SlidingWindowRateLimiter instances:
-
Pair endpoint:
pair_rate_limit_per_minute(default: unlimited) -
Webhook endpoint:
webhook_rate_limit_per_minute(default: unlimited)
classDiagram
class GatewayRateLimiter {
+pair: SlidingWindowRateLimiter
+webhook: SlidingWindowRateLimiter
+new(pair_per_min, webhook_per_min, max_keys)
+allow_pair(key) bool
+allow_webhook(key) bool
}
class SlidingWindowRateLimiter {
-limit_per_window: u32
-window: Duration
-max_keys: usize
-requests: Mutex~HashMap~
+new(limit, window, max_keys)
+allow(key) bool
-prune_stale(requests, cutoff)
}
class ClientKeyExtraction {
+client_key_from_request(peer_addr, headers, trust_forwarded)
+forwarded_client_ip(headers) Option~IpAddr~
+parse_client_ip(value) Option~IpAddr~
}
GatewayRateLimiter --> SlidingWindowRateLimiter : uses
GatewayRateLimiter --> ClientKeyExtraction : uses
Sources: src/gateway/mod.rs:136-158
Rate limits are enforced per-client, identified by IP address:
Priority (if trust_forwarded_headers = true):
- First IP in
X-Forwarded-Forheader -
X-Real-IPheader - Socket peer address
Priority (if trust_forwarded_headers = false):
- Socket peer address only
Implementation: src/gateway/mod.rs:235-249
trust_forwarded_headers when the gateway is behind a trusted reverse proxy. Otherwise, clients can spoof headers to bypass rate limits.
For each client key, the limiter maintains a list of request timestamps:
flowchart LR
Request["New Request<br/>at time T"] --> GetKey["client_key_from_request()"]
GetKey --> Cutoff["cutoff = T - window"]
Cutoff --> Prune["Prune timestamps < cutoff"]
Prune --> CheckCount{"timestamps.len() >= limit?"}
CheckCount -->|Yes| Reject["Return 429 Too Many Requests"]
CheckCount -->|No| Allow["Add T to timestamps<br/>Return 200 OK"]
Periodic Cleanup: Every RATE_LIMITER_SWEEP_INTERVAL_SECS (300s), the limiter removes client keys with no recent requests to prevent unbounded memory growth.
Cardinality Protection: If the map reaches max_keys, the limiter evicts the least-recently-used client before adding a new one.
Sources: src/gateway/mod.rs:66-134
[gateway]
# Rate limits per minute (0 = unlimited)
pair_rate_limit_per_minute = 10
webhook_rate_limit_per_minute = 60
# Maximum distinct client IPs tracked
rate_limit_max_keys = 10000
# Trust X-Forwarded-For headers (only enable behind trusted proxy)
trust_forwarded_headers = falseSources: src/gateway/mod.rs:397-405
In addition to pairing authentication, the /webhook endpoint supports an optional second authentication layer via X-Webhook-Secret header. This is useful for webhook sources that cannot send Authorization: Bearer headers.
sequenceDiagram
participant WS as Webhook Source
participant GW as Gateway
participant Hash as hash_webhook_secret()
participant Compare as constant_time_eq()
Note over GW: Startup: config.channels_config.webhook.secret
GW->>Hash: hash_webhook_secret(plaintext)
Hash->>Hash: SHA-256 digest
Hash-->>GW: Store hex hash in webhook_secret_hash
WS->>GW: POST /webhook<br/>Authorization: Bearer ...<br/>X-Webhook-Secret: raw_secret
GW->>GW: Check pairing (bearer token)
alt webhook_secret_hash is Some
GW->>Hash: hash_webhook_secret(header_value)
Hash-->>GW: header_hash
GW->>Compare: constant_time_eq(header_hash, stored_hash)
Compare-->>GW: true/false
alt Mismatch
GW->>WS: 401 Unauthorized<br/>"invalid or missing X-Webhook-Secret"
end
end
GW->>WS: 200 OK (process webhook)
Sources: src/gateway/mod.rs:352-360, src/gateway/mod.rs:655-670
The plaintext secret is never stored. Only the SHA-256 hash is retained:
let webhook_secret_hash: Option<Arc<str>> =
config.channels_config.webhook.as_ref()
.and_then(|webhook| {
webhook.secret.as_ref()
.and_then(|raw_secret| {
let trimmed = raw_secret.trim();
(!trimmed.is_empty()).then(||
Arc::<str>::from(hash_webhook_secret(trimmed))
)
})
});Sources: src/gateway/mod.rs:56-61
The /whatsapp webhook endpoint verifies incoming requests using HMAC-SHA256 signature validation, as required by Meta's Webhooks API.
Meta sends the signature in the X-Hub-Signature-256 header:
X-Hub-Signature-256: sha256=<hex_hmac_signature>
flowchart TD
Request["POST /whatsapp<br/>X-Hub-Signature-256: sha256=..."] --> Extract["Extract hex signature<br/>from header"]
Extract --> Decode["hex::decode(signature)"]
Decode --> ComputeHMAC["HMAC-SHA256(body, app_secret)"]
ComputeHMAC --> Compare["mac.verify_slice(expected)<br/>(constant-time)"]
Compare -->|"Ok()"| Accept["Process webhook"]
Compare -->|"Err()"| Reject["Return empty 200<br/>(Meta retries on non-200)"]
Implementation:
pub fn verify_whatsapp_signature(app_secret: &str, body: &[u8], signature_header: &str) -> bool {
use hmac::{Hmac, Mac};
use sha2::Sha256;
// Extract hex signature from "sha256=<hex>"
let Some(hex_sig) = signature_header.strip_prefix("sha256=") else {
return false;
};
let Ok(expected) = hex::decode(hex_sig) else {
return false;
};
// Compute HMAC-SHA256
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(app_secret.as_bytes()) else {
return false;
};
mac.update(body);
// Constant-time comparison (prevents timing attacks)
mac.verify_slice(&expected).is_ok()
}Sources: src/gateway/mod.rs:843-868
The WhatsApp app secret can be provided via:
Priority:
- Environment variable:
ZEROCLAW_WHATSAPP_APP_SECRET - Config file:
[channels_config.whatsapp] app_secret = "..."
Sources: src/gateway/mod.rs:373-390
The signature is computed over the raw request body. The handler receives body: Bytes directly (not parsed JSON) to ensure the HMAC input matches exactly what Meta signed.
Sources: src/gateway/mod.rs:870-918
The gateway provides optional idempotency for /webhook requests via the X-Idempotency-Key header. This prevents duplicate processing if a webhook sender retries the same request.
classDiagram
class IdempotencyStore {
-ttl: Duration
-max_keys: usize
-keys: Mutex~HashMap~String, Instant~~
+new(ttl, max_keys)
+record_if_new(key) bool
}
class CleanupStrategy {
+Retain: keys.retain(expired)
+Eviction: Remove LRU when full
}
IdempotencyStore --> CleanupStrategy
Behavior:
-
First request:
record_if_new(key)returnstrue→ process normally -
Duplicate request:
record_if_new(key)returnsfalse→ return 200 with{"status": "duplicate"}
Expiration: Keys older than ttl are removed automatically on each call.
Cardinality: If max_keys is reached, the least-recently-seen key is evicted before adding a new one.
Sources: src/gateway/mod.rs:160-200
[gateway]
# Idempotency key TTL in seconds (default: 3600 = 1 hour)
idempotency_ttl_secs = 3600
# Maximum distinct idempotency keys retained
idempotency_max_keys = 10000Sources: src/gateway/mod.rs:406-413
# First request
curl -X POST http://localhost:3000/webhook \
-H "Authorization: Bearer zc_abc..." \
-H "X-Idempotency-Key: req-12345" \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
# Response: 200 OK, {"response": "...", "model": "..."}
# Duplicate request (within TTL)
curl -X POST http://localhost:3000/webhook \
-H "Authorization: Bearer zc_abc..." \
-H "X-Idempotency-Key: req-12345" \
-H "Content-Type: application/json" \
-d '{"message": "Hello"}'
# Response: 200 OK, {"status": "duplicate", "idempotent": true, ...}Sources: src/gateway/mod.rs:684-700
The gateway enforces a 64KB maximum request body size to prevent memory exhaustion attacks:
pub const MAX_BODY_SIZE: usize = 65_536;
let app = Router::new()
// ...
.layer(RequestBodyLimitLayer::new(MAX_BODY_SIZE));Requests exceeding this limit receive a 413 Payload Too Large response.
Sources: src/gateway/mod.rs:37-38, src/gateway/mod.rs:491
All requests must complete within 30 seconds to prevent slow-loris attacks:
pub const REQUEST_TIMEOUT_SECS: u64 = 30;
let app = Router::new()
// ...
.layer(TimeoutLayer::with_status_code(
StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(REQUEST_TIMEOUT_SECS),
));Timed-out requests receive a 408 Request Timeout response.
Sources: src/gateway/mod.rs:39-40, src/gateway/mod.rs:492-495
The gateway uses axum (built on hyper) for proper HTTP/1.1 parsing, replacing a previous raw TCP implementation. This provides:
- Automatic
Content-Lengthvalidation - Header sanitization (via hyper's strict parsing)
- Protection against HTTP smuggling attacks
- Standards-compliant request/response handling
Sources: src/gateway/mod.rs:1-9
| Endpoint | Pairing | Rate Limit | Webhook Secret | WhatsApp Sig | Idempotency |
|---|---|---|---|---|---|
GET /health |
❌ Public | ❌ None | ❌ N/A | ❌ N/A | ❌ N/A |
GET /metrics |
❌ Public | ❌ None | ❌ N/A | ❌ N/A | ❌ N/A |
POST /pair |
✅ Yes | ❌ N/A | ❌ N/A | ❌ N/A | |
POST /webhook |
✅ Required | ✅ Yes | ✅ Optional | ❌ N/A | ✅ Optional |
GET /whatsapp |
❌ Token-based | ❌ None | ❌ N/A | ❌ N/A | ❌ N/A |
POST /whatsapp |
❌ N/A | ❌ None | ❌ N/A | ✅ Required | ❌ N/A |
Notes:
-
GET /healthandGET /metricsare intentionally public for monitoring -
GET /whatsappuses Meta'sverify_token(not pairing) -
POST /whatsapprequires signature verification instead of pairing -
POST /webhooksupports both pairing and webhook secret (layered defense)
Sources: src/gateway/mod.rs:483-490
[gateway]
# ── Network Security ──
host = "127.0.0.1"
port = 3000
allow_public_bind = false # Refuse public bind without tunnel
# ── Pairing Authentication ──
require_pairing = true
paired_tokens = [
"a1b2c3d4e5f6...", # SHA-256 hashes (64 hex chars)
]
# ── Rate Limiting ──
pair_rate_limit_per_minute = 10
webhook_rate_limit_per_minute = 60
rate_limit_max_keys = 10000
trust_forwarded_headers = false # Only enable behind trusted proxy
# ── Idempotency ──
idempotency_ttl_secs = 3600
idempotency_max_keys = 10000
[tunnel]
provider = "tailscale" # Options: tailscale, cloudflare, ngrok, none
[channels_config.webhook]
secret = "your-webhook-secret" # Optional second auth layer
[channels_config.whatsapp]
app_secret = "meta-app-secret" # Or use ZEROCLAW_WHATSAPP_APP_SECRET env var
verify_token = "meta-verify-token"
access_token = "whatsapp-access-token"
phone_number_id = "123456789"
allowed_numbers = ["+1234567890"]Sources: src/gateway/mod.rs:282-480
| Attack Vector | Mitigation | Implementation |
|---|---|---|
| Unauthorized Access | Pairing + bearer tokens |
PairingGuard, SHA-256 token hashing |
| Brute-Force Pairing | Rate limiting + lockout | 5 attempts → 5 min lockout |
| Credential Theft | Token hashing | Only SHA-256 hashes persisted |
| Timing Attacks | Constant-time comparison |
constant_time_eq() for all secrets |
| DoS via Rate | Sliding-window rate limiter | Per-IP limits with cardinality caps |
| Memory Exhaustion | Body size limits | 64KB max per request |
| Slow-Loris | Request timeouts | 30s timeout per request |
| Replay Attacks | Idempotency keys | TTL-based duplicate detection |
| Webhook Forgery | HMAC signatures | verify_whatsapp_signature() |
| Public Exposure | Network isolation | Localhost-only + tunnel requirement |
| IP Spoofing | Header trust control |
trust_forwarded_headers flag |
Sources: src/gateway/mod.rs:1-918, src/security/pairing.rs:1-485
graph LR
subgraph "src/gateway/mod.rs"
run_gateway["run_gateway(host, port, config)"]
AppState["AppState struct"]
handle_pair["handle_pair()"]
handle_webhook["handle_webhook()"]
handle_whatsapp_message["handle_whatsapp_message()"]
GatewayRateLimiter["GatewayRateLimiter struct"]
IdempotencyStore["IdempotencyStore struct"]
verify_whatsapp_signature["verify_whatsapp_signature()"]
hash_webhook_secret["hash_webhook_secret()"]
client_key_from_request["client_key_from_request()"]
end
subgraph "src/security/pairing.rs"
PairingGuard["PairingGuard struct"]
try_pair["try_pair()"]
is_authenticated["is_authenticated()"]
generate_code["generate_code()"]
generate_token["generate_token()"]
constant_time_eq["constant_time_eq()"]
is_public_bind["is_public_bind()"]
end
subgraph "Axum Middleware"
RequestBodyLimitLayer["RequestBodyLimitLayer"]
TimeoutLayer["TimeoutLayer"]
end
run_gateway --> AppState
run_gateway --> PairingGuard
run_gateway --> GatewayRateLimiter
run_gateway --> IdempotencyStore
handle_pair --> PairingGuard
handle_pair --> try_pair
handle_pair --> GatewayRateLimiter
handle_webhook --> PairingGuard
handle_webhook --> is_authenticated
handle_webhook --> hash_webhook_secret
handle_webhook --> IdempotencyStore
handle_webhook --> GatewayRateLimiter
handle_whatsapp_message --> verify_whatsapp_signature
PairingGuard --> generate_code
PairingGuard --> generate_token
PairingGuard --> constant_time_eq
GatewayRateLimiter --> client_key_from_request
run_gateway --> RequestBodyLimitLayer
run_gateway --> TimeoutLayer
Sources: src/gateway/mod.rs:282-918, src/security/pairing.rs:38-231