src/NETWORK_SECURITY.md
This document catalogs every network-facing surface in IronClaw, its authentication mechanism, bind address, security controls, and known findings. Use this as the authoritative reference during code reviews that touch network-facing code.
Last updated: 2026-02-18
IronClaw operates across four trust boundaries:
| Boundary | Trust Level | Examples |
|---|---|---|
| Local user | Fully trusted | TUI, web gateway (loopback), CLI commands |
| Browser client | Authenticated | Web UI connected via bearer token; subject to CORS, Origin validation, CSRF protections |
| Docker containers | Untrusted (sandboxed) | Worker containers executing user jobs; isolated via per-job tokens, allowlisted egress, dropped capabilities |
| External services | Untrusted | Webhook senders (Telegram, Slack); authenticated via shared secret |
Key assumptions:
| Listener | Default Port | Default Bind | Auth Mechanism | Config Env Var | Source |
|---|---|---|---|---|---|
| Web Gateway | 3000 | 127.0.0.1 | Bearer token (constant-time) | GATEWAY_HOST, GATEWAY_PORT, GATEWAY_AUTH_TOKEN | server.rs — start_server() |
| HTTP Webhook Server | 8080 | 0.0.0.0 | Shared secret (body field) | HTTP_HOST, HTTP_PORT, HTTP_WEBHOOK_SECRET | webhook_server.rs — start() |
| Orchestrator Internal API | 50051 | 127.0.0.1 (macOS/Win) / 0.0.0.0 (Linux) | Per-job bearer token (constant-time) | ORCHESTRATOR_PORT | api.rs — OrchestratorApi::start() |
| OAuth Callback Listener | 9876 | 127.0.0.1 | None (ephemeral, 5-min timeout) | N/A (hardcoded) | oauth_defaults.rs — bind_callback_listener() |
| Sandbox HTTP Proxy | OS-assigned (ephemeral) | 127.0.0.1 | None (loopback only) | N/A (auto-assigned) | proxy/http.rs — SandboxProxy::start() |
Source: src/channels/web/server.rs, src/channels/web/auth.rs
Configurable via GATEWAY_HOST (default 127.0.0.1) and GATEWAY_PORT (default 3000). The gateway is designed as a local-first, single-user service.
Reference: src/config.rs — gateway_host default ("127.0.0.1"), gateway_port default (3000)
Bearer token middleware applied to all /api/* routes via route_layer. Token checked in two locations:
Authorization: Bearer <token> header (primary)?token=<token> query parameter (fallback for SSE EventSource which cannot set headers)Both paths use constant-time comparison via subtle::ConstantTimeEq (ct_eq).
Reference: src/channels/web/auth.rs — auth_middleware(), header check and query-param fallback both use ct_eq
If GATEWAY_AUTH_TOKEN is not set, a random hex token is generated at startup.
| Route | Purpose | Response |
|---|---|---|
/api/health | Health check endpoint | {"status":"healthy","channel":"gateway"} — no version, uptime, or fingerprinting data |
/ | Static HTML (embedded) | Single-page app shell |
/style.css | Static CSS (embedded) | Stylesheet |
/app.js | Static JS (embedded) | Client-side app |
Restricted to a two-origin allowlist (not browser same-origin policy, but a CORS allowlist that achieves equivalent protection):
http://<bind_ip>:<bind_port>http://localhost:<bind_port>Allowed methods: GET, POST, PUT, DELETE. Allowed headers: Content-Type, Authorization. Credentials allowed.
Reference: src/channels/web/server.rs — CorsLayer::new() block
The /api/chat/ws endpoint has two layers of protection:
Bearer token auth — the route is inside the protected router with route_layer, so auth_middleware runs before the handler. The token is passed via the Authorization: Bearer header on the HTTP upgrade request (not via query parameter).
Origin header validation (inside the handler) as a defense-in-depth guard against cross-site WebSocket hijacking (CSWSH):
localhost, 127.0.0.1, and [::1]localhost.evil.com are rejected because the check extracts the host portion before the first : or /Reference: src/channels/web/server.rs — chat_ws_handler() (origin validation block)
Chat endpoint (/api/chat/send) enforces a sliding-window rate limit: 30 requests per 60 seconds (global, not per-IP — single-user gateway).
Reference: src/channels/web/server.rs — RateLimiter struct, chat_rate_limiter field
DefaultBodyLimit::max(1024 * 1024))src/channels/web/server.rs — .layer(DefaultBodyLimit::max(...))The /projects/{project_id}/* routes serve files from project directories. These are behind auth middleware to prevent unauthorized file access.
Reference: src/channels/web/server.rs — project file routes in protected router
The gateway sets the following security headers on all responses (via SetResponseHeaderLayer::if_not_present, so handlers can override):
X-Content-Type-Options: nosniff — prevents MIME-sniffingX-Frame-Options: DENY — prevents clickjacking via iframesReference: src/channels/web/server.rs — SetResponseHeaderLayer calls
Shutdown is triggered via a oneshot::Sender stored in GatewayState::shutdown_tx. The server uses axum::serve(...).with_graceful_shutdown(...) to drain in-flight requests before closing the listener.
Reference: src/channels/web/server.rs — shutdown_tx / shutdown_rx setup
Source: src/channels/webhook_server.rs, src/channels/http.rs
Configurable via HTTP_HOST (default 0.0.0.0) and HTTP_PORT (default 8080).
WARNING: The default bind address is 0.0.0.0, meaning the webhook server listens on all interfaces by default. This is intentional (webhooks must be reachable from external services like Telegram/Slack), but operators should be aware of the exposure.
Reference: src/config.rs — http_host default ("0.0.0.0"), http_port default (8080)
Webhook secret is passed in the JSON request body (secret field), not as a header. The secret is compared using constant-time subtle::ConstantTimeEq (ct_eq).
The secret is required to start the channel — if HTTP_WEBHOOK_SECRET is not set, start() returns an error.
CSRF note: Because the secret is in the JSON body (not a cookie or header that browsers auto-attach), a cross-origin form POST cannot forge a valid request. Browsers would send application/x-www-form-urlencoded, which the Json<T> extractor rejects with HTTP 415. Even if Content-Type were spoofed via CORS preflight, the attacker would need the secret value, which is never stored in the browser.
Reference: src/channels/http.rs — webhook_handler() (secret validation with ct_eq), start() (required-secret check)
The webhook endpoint uses axum's Json<WebhookRequest> extractor, which enforces Content-Type: application/json. Requests with missing or incorrect Content-Type are rejected with HTTP 415 Unsupported Media Type before the handler body executes. Malformed JSON bodies are rejected with HTTP 422 Unprocessable Entity.
Reference: src/channels/http.rs — webhook_handler() function signature (Json(req): Json<WebhookRequest>)
60 requests per minute, enforced via a mutex-protected sliding window.
Reference: src/channels/http.rs — MAX_REQUESTS_PER_MINUTE constant, rate-limit check in webhook_handler()
MAX_BODY_BYTES)MAX_CONTENT_BYTES)MAX_PENDING_RESPONSES)Reference: src/channels/http.rs — constants block (MAX_BODY_BYTES, MAX_CONTENT_BYTES, MAX_PENDING_RESPONSES, MAX_REQUESTS_PER_MINUTE)
| Route | Auth | Purpose | Response |
|---|---|---|---|
/health | None | Health check | {"status":"healthy","channel":"http"} — no fingerprinting data |
/webhook | Webhook secret | Receive messages | Webhook response |
Shutdown is triggered via a oneshot::Sender stored on the WebhookServer struct. The server uses axum::serve(...).with_graceful_shutdown(...). The public shutdown() method sends the signal and awaits the task join handle, ensuring a clean drain-and-wait.
Reference: src/channels/webhook_server.rs — shutdown() method
Source: src/orchestrator/api.rs, src/orchestrator/auth.rs
Platform-dependent:
127.0.0.1:<port> — Docker Desktop routes host.docker.internal through its VM to 127.0.0.10.0.0.0:<port> — containers reach the host via the Docker bridge gateway (172.17.0.1), which is not loopbackDefault port: 50051.
Reference: src/orchestrator/api.rs — OrchestratorApi::start(), platform-conditional bind address block
Per-job bearer tokens validated by worker_auth_middleware:
subtle::ConstantTimeEqReference: src/orchestrator/auth.rs — TokenStore::create_token(), TokenStore::validate(), generate_token()
The middleware extracts the job UUID from the URL path (/worker/{job_id}/...) and validates the Authorization: Bearer header against the stored token for that specific job.
Reference: src/orchestrator/auth.rs — worker_auth_middleware(), extract_job_id_from_path()
The orchestrator can grant per-job access to specific secrets from the encrypted secrets store. Grants are:
TokenStore(secret_name, env_var) pairs/worker/{job_id}/credentialsReference: src/orchestrator/auth.rs — CredentialGrant struct, src/orchestrator/api.rs — get_credentials_handler()
None. The orchestrator API has no rate limiting. All /worker/* endpoints are authenticated via per-job bearer tokens, but a compromised container could spam authenticated endpoints without throttling.
Mitigation: Tokens are scoped per-job so a compromised container can only abuse its own job's endpoints. Container execution is time-bounded (see Docker Container Security), which limits the window for abuse.
| Route | Auth | Purpose | Response |
|---|---|---|---|
/health | None | Health check | "ok" (plain text) — no fingerprinting data |
/worker/{job_id}/job | Per-job token | Get job description | Job JSON |
/worker/{job_id}/llm/complete | Per-job token | Proxy LLM completion | LLM response |
/worker/{job_id}/llm/complete_with_tools | Per-job token | Proxy LLM tool completion | LLM response |
/worker/{job_id}/status | Per-job token | Report worker status | Ack |
/worker/{job_id}/complete | Per-job token | Report job completion | Ack |
/worker/{job_id}/event | Per-job token | Send job events (SSE broadcast) | Ack |
/worker/{job_id}/prompt | Per-job token | Poll for follow-up prompts | Prompt or empty |
/worker/{job_id}/credentials | Per-job token | Retrieve decrypted credentials | Credentials JSON |
None. The orchestrator calls axum::serve(listener, router).await? without .with_graceful_shutdown(). The server stops only when the task is dropped (process exit or tokio task cancellation). In-flight requests may be interrupted.
Reference: src/orchestrator/api.rs — OrchestratorApi::start()
Source: src/cli/oauth_defaults.rs
Always binds to loopback only: 127.0.0.1:9876. Falls back to [::1]:9876 (IPv6 loopback) if IPv4 binding fails for reasons other than AddrInUse. If the port is already in use, the error is returned immediately (fail-fast).
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
Reference: src/cli/oauth_defaults.rs — OAUTH_CALLBACK_PORT constant, bind_callback_listener()
The listener is ephemeral — it is started only when an OAuth flow is initiated (e.g., ironclaw tool auth <name>) and shut down after the callback is received or the timeout expires.
5-minute timeout (Duration::from_secs(300)). If the user does not complete the OAuth flow in the browser within 5 minutes, the listener shuts down.
Reference: src/cli/oauth_defaults.rs — tokio::time::timeout(Duration::from_secs(300), ...)
&, <, >, ", ')error= in the callback query string before extracting the auth codeReference: src/cli/oauth_defaults.rs — html_escape()
Google OAuth client ID and secret are compiled into the binary (with compile-time override via IRONCLAW_GOOGLE_CLIENT_ID / IRONCLAW_GOOGLE_CLIENT_SECRET). As noted in the source, Google Desktop App client secrets are not actually secret per Google's documentation.
Reference: src/cli/oauth_defaults.rs — GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET constants
Implicit. The listener is a raw TcpListener (not axum) inside a tokio::time::timeout future. Once the authorization code or error is received, the future returns and the TcpListener is dropped, closing the port. No explicit shutdown signal is needed.
Reference: src/cli/oauth_defaults.rs — wait_for_callback()
Source: src/sandbox/proxy/http.rs, src/sandbox/proxy/allowlist.rs, src/sandbox/proxy/policy.rs
Always binds to 127.0.0.1 (localhost only). Port is OS-assigned (port 0, ephemeral). Falls back to [::1] (IPv6 loopback) if IPv4 is unavailable.
Both IPv4 and IPv6 loopback addresses are security-equivalent — they are only reachable from the local machine.
Reference: src/sandbox/proxy/http.rs — SandboxProxy::start(), TcpListener::bind("127.0.0.1:0")
Acts as an HTTP/HTTPS proxy for Docker sandbox containers. Containers are configured with http_proxy / https_proxy environment variables pointing to this proxy, so all outbound HTTP traffic is routed through it.
All requests are validated against a domain allowlist before being forwarded:
*.example.com)ftp://, file://, etc.)Reference: src/sandbox/proxy/allowlist.rs — DomainAllowlist struct, is_allowed() method
/worker/{job_id}/credentials endpoint)Reference: src/sandbox/proxy/http.rs — handle_connect() function
For plain HTTP requests to allowed hosts, the proxy can inject credentials:
Authorization headerX-API-Key)Reference: src/sandbox/proxy/http.rs — credential injection block in handle_request()
The proxy strips hop-by-hop headers to prevent header-based attacks: connection, keep-alive, proxy-authenticate, proxy-authorization, te, trailers, transfer-encoding, upgrade.
Reference: src/sandbox/proxy/http.rs — is_hop_by_hop_header()
Containers that use the proxy are configured with defense-in-depth:
| Control | Setting | Reference |
|---|---|---|
| Capabilities | Drop ALL, add only CHOWN | src/sandbox/container.rs — cap_drop / cap_add |
| Privilege escalation | no-new-privileges:true | src/sandbox/container.rs — security_opt |
| Root filesystem | Read-only (except FullAccess policy) | src/sandbox/container.rs — readonly_rootfs |
| User | Non-root (UID 1000:1000) | src/sandbox/container.rs — user field |
| Network | Bridge mode (isolated) | src/sandbox/container.rs — network_mode |
| Tmpfs | /tmp (512 MB), /home/sandbox/.cargo/registry (1 GB) | src/sandbox/container.rs — tmpfs block |
| Auto-remove | Enabled | src/sandbox/container.rs — auto_remove |
| Output limits | Configurable max stdout/stderr | src/sandbox/container.rs — collect_logs() |
| Timeout | Enforced with forced container removal | src/sandbox/container.rs — tokio::time::timeout in run() |
Shutdown is triggered via a oneshot::Sender stored on the proxy. The accept loop uses tokio::select! to race listener.accept() against the shutdown signal. The stop() method fires the signal; the loop breaks on the next iteration. Note: stop() does not await a join handle, so there is no drain-and-wait for in-flight connections.
Reference: src/sandbox/proxy/http.rs — stop() method, tokio::select! loop
WASM tools execute HTTP requests through the host runtime, subject to:
Endpoint allowlist — declared in <tool>.capabilities.json, validated by AllowlistValidator
user:pass@host) rejected to prevent allowlist bypass../, %2e%2e/) normalized and blockedsrc/tools/wasm/allowlist.rsCredential injection — secrets injected at the host boundary by CredentialInjector
allowed_secrets listsrc/tools/wasm/credential_injector.rsLeak detection — LeakDetector scans both outbound requests and inbound responses for secret patterns
src/safety/leak_detector.rsThe http tool (src/tools/builtin/http.rs) has its own SSRF protections:
| Protection | Details | Reference |
|---|---|---|
| HTTPS only | Rejects http:// URLs | http.rs — scheme check |
| Localhost blocked | Rejects localhost and *.localhost | http.rs — host check |
| Private IP blocked | Rejects RFC 1918, loopback, link-local, multicast, unspecified | http.rs — is_disallowed_ip() |
| DNS rebinding | Resolves hostname and checks all resolved IPs against blocklist | http.rs — DNS resolution block |
| Cloud metadata | Blocks 169.254.169.254 (AWS/GCP metadata endpoint) | http.rs — is_disallowed_ip() |
| Redirect blocking | Returns error on 3xx responses (prevents SSRF via redirect) | http.rs — status code check |
| Response size limit | 5 MB max, enforced both via Content-Length header and streaming | http.rs — MAX_RESPONSE_SIZE constant, streaming cap |
| Outbound leak scan | Scans URL, headers, and body for secrets before sending | http.rs — LeakDetector::scan_http_request() |
| Approval required | Requires user approval before execution | http.rs — requires_approval() returns true |
| Timeout | 30 seconds default | http.rs — reqwest::Client builder |
| No redirects | redirect::Policy::none() — redirects are not followed | http.rs — reqwest::Client builder |
MCP servers are external processes accessed via HTTP. The MCP client (src/tools/mcp/client.rs) uses reqwest with a 30-second timeout but has no SSRF protections — it connects to whatever URL is configured for the MCP server.
This is by design: MCP server URLs come from operator-controlled configuration (config files, environment variables, or the CLI tool install command), not from user input or LLM output. A compromised config file is outside IronClaw's threat model — it would imply the operator's machine is already compromised.
Reference: src/tools/mcp/client.rs — reqwest::Client builder
Sandbox containers route all HTTP traffic through the proxy, which enforces a domain allowlist. The allowlist is built from:
src/sandbox/config.rs — default_allowlist())SANDBOX_EXTRA_DOMAINS env var (comma-separated)Reference: src/config.rs — sandbox allowlist assembly
| Mechanism | Constant-Time | Used By | Reference |
|---|---|---|---|
| Gateway bearer token | Yes | Web gateway (header + query) | src/channels/web/auth.rs — auth_middleware() |
| Webhook shared secret | Yes | HTTP webhook (ct_eq comparison) | src/channels/http.rs — webhook_handler() |
| Per-job bearer token | Yes | Orchestrator worker API | src/orchestrator/auth.rs — TokenStore::validate() |
| OAuth callback | N/A | CLI OAuth flow (no auth, loopback-only) | src/cli/oauth_defaults.rs — bind_callback_listener() |
| Sandbox proxy | N/A | No auth (loopback-only, ephemeral) | src/sandbox/proxy/http.rs — SandboxProxy::start() |
Severity: Low (for local deployment) Details: None of the listeners terminate TLS. All communication is plain HTTP. Mitigation: The web gateway and OAuth callback bind to loopback by default. For production, users are expected to front the gateway with a reverse proxy (nginx, Caddy) or tunnel (Cloudflare, ngrok) that provides TLS. Recommendation: Document the requirement for a TLS-terminating reverse proxy in deployment guides.
0.0.0.0 on LinuxSeverity: Medium
Location: src/orchestrator/api.rs — platform-conditional bind in OrchestratorApi::start()
Details: On Linux, the orchestrator API binds to all interfaces because Docker containers reach the host via the bridge gateway (172.17.0.1), not loopback. This means the API is reachable from any network interface on the host.
Mitigation: All /worker/* endpoints require per-job bearer tokens (constant-time, cryptographically random). The /health endpoint is the only unauthenticated route and returns only "ok". Firewall rules should block external access to port 50051.
Recommendation: Document firewall requirements for Linux deployments. Consider binding to the Docker bridge IP (172.17.0.1) instead of 0.0.0.0.
Severity: Info
Details: The SseManager enforces a hard limit of 100 concurrent connections (MAX_CONNECTIONS constant in src/channels/web/sse.rs). Both SSE subscribers and WebSocket connections share this counter. When exceeded, new WebSocket upgrades are rejected with a warning log and the connection is immediately closed.
Reference: src/channels/web/sse.rs — MAX_CONNECTIONS, src/channels/web/ws.rs — handle_ws_connection() early return
Severity: Low
Details: The orchestrator API has no request-rate throttling. A compromised container could spam authenticated endpoints (e.g., /worker/{job_id}/llm/complete) to drive up LLM costs or degrade service for other jobs.
Mitigation: Tokens are scoped per-job, limiting blast radius. Container execution is time-bounded by the sandbox timeout, which caps the abuse window.
Recommendation: Consider adding per-token rate limiting on the LLM proxy endpoints.
Severity: Info
Details: The orchestrator calls axum::serve(listener, router).await? without .with_graceful_shutdown(). In-flight requests (including LLM proxy calls) may be interrupted during process shutdown.
Reference: src/orchestrator/api.rs — OrchestratorApi::start()
Severity: Low
Location: src/channels/http.rs — webhook_handler()
Status: Resolved — webhook secret now uses subtle::ConstantTimeEq (ct_eq), consistent with web gateway and orchestrator auth.
0.0.0.0 by defaultSeverity: Low
Location: src/config.rs, src/main.rs
Status: Mitigated — a tracing::warn! is now emitted at startup when the webhook server binds to an unspecified address (0.0.0.0 or ::), advising operators to set HTTP_HOST=127.0.0.1 to restrict to localhost. The default bind address remains 0.0.0.0, so webhook exposure is still controlled by operator configuration and external network controls (firewalls, ingress rules).
Severity: Low
Status: Mitigated — X-Content-Type-Options: nosniff and X-Frame-Options: DENY are now set on all gateway responses via SetResponseHeaderLayer::if_not_present. Layer ordering ensures these headers are applied even to error responses generated by inner layers (e.g., DefaultBodyLimit 413 rejections).
Use this checklist for any PR that adds or modifies network-facing code.
127.0.0.1) or all interfaces (0.0.0.0)? Justify if 0.0.0.0.DefaultBodyLimit (or equivalent) set?Json<T> extractor)?subtle::ConstantTimeEq?