Skip to content

feat(security): Zero-trust secure gateway with secrets proxy#9271

Closed
robertchang-ga wants to merge 97 commits into
openclaw:mainfrom
robertchang-ga:feat/security-proxy
Closed

feat(security): Zero-trust secure gateway with secrets proxy#9271
robertchang-ga wants to merge 97 commits into
openclaw:mainfrom
robertchang-ga:feat/security-proxy

Conversation

@robertchang-ga

@robertchang-ga robertchang-ga commented Feb 5, 2026

Copy link
Copy Markdown

feat(security): Zero-trust secure gateway with secrets proxy

Summary

Implements a zero-trust architecture for the OpenClaw gateway. In secure mode (--secure), the gateway runs in a Docker container that has no access to any secrets — API keys, tokens, and credentials remain exclusively on the host.

A host-side secrets proxy intercepts all outbound API requests from the container, injects credentials at the network edge, and enforces a domain allowlist to prevent exfiltration. A socat relay container on an isolated Docker network bridges the gap between the air-gapped gateway container and the host proxy.

Architecture

graph TB
  USER["👤 User / Client<br/>(browser, Telegram, WhatsApp, …)"]

  subgraph HOST ["🖥️ Host Machine"]
    CLI["openclaw gateway --secure<br/>(CLI orchestrator)"]
    PROXY["Secrets Proxy<br/>127.0.0.1:8080 / Unix socket<br/>─────────────────<br/>• Holds all API keys<br/>• Domain allowlist<br/>• Placeholder → secret replacement"]
    CLI -->|starts| PROXY
  end

  subgraph NET ["🔒 openclaw-secure-net (internal, no internet)"]
    GW["Gateway Container<br/>openclaw-gateway<br/>─────────────────<br/>• No secrets in env or fs<br/>• PROXY_URL → relay<br/>• secure-fetch wraps fetch()"]
    RELAY["Relay Container<br/>openclaw-relay (socat)<br/>─────────────────<br/>Linux/macOS: TCP → Unix socket<br/>Windows: TCP → host.docker.internal"]
  end

  USER <-->|"WebSocket<br/>(chat messages)"| GW
  GW -->|"outbound API call<br/>X-Target-URL + X-Proxy-Token"| RELAY
  RELAY -->|forwards request| PROXY
  PROXY -->|"request with real credentials"| API["☁️ Allowlisted APIs<br/>LLMs · Channels · Integrations<br/>(Anthropic, Telegram, Notion, …)"]
  API -.->|response| PROXY
  PROXY -.->|response| RELAY
  RELAY -.->|response| GW
Loading

Request Flow

sequenceDiagram
  participant USER as User / Client
  participant GW as Gateway Container
  participant RELAY as Relay (socat)
  participant PROXY as Host Secrets Proxy
  participant API as Allowlisted API

  USER->>GW: Chat message (WebSocket)
  Note over GW: Gateway processes message,<br/>needs to call an external API
  GW->>GW: fetch("https://api.example.com/...")
  Note over GW: secure-fetch intercepts
  GW->>RELAY: POST PROXY_URL<br/>X-Target-URL: api.example.com<br/>X-Proxy-Token: <token><br/>Body contains {{API_KEY}}
  RELAY->>PROXY: socat forwards (TCP→socket or TCP→host)
  PROXY->>PROXY: 1. Verify proxy token
  PROXY->>PROXY: 2. Check domain allowlist
  PROXY->>PROXY: 3. Replace {{...}} with real keys
  PROXY->>API: Forward with real credentials
  API-->>PROXY: Response
  PROXY-->>RELAY: Response
  RELAY-->>GW: Response
  GW-->>USER: Reply (WebSocket)
Loading

Zero-Trust Model

Traditional setups pass API keys directly to the gateway — if the gateway is compromised via prompt injection or malicious dependencies, your keys leak. This PR implements a zero-trust model:

  • Bot container receives only a proxy URL, never real credentials
  • Host-side secrets proxy holds all credentials
  • All API requests route through the proxy, which injects credentials at the network edge
  • Even a fully compromised container cannot extract your API keys

Prerequisites

Docker Installation

Secure mode requires Docker to be installed and running:

# Ubuntu/Debian
sudo apt update && sudo apt install -y docker.io
sudo systemctl enable --now docker

# Add your user to docker group (logout required)
sudo usermod -aG docker $USER

# Verify installation
docker --version

Building the Docker Image

Before first use, build the secure gateway image:

npm run docker:build
# Or manually:
docker build -t openclaw-gateway -f Dockerfile.gateway .

Key Changes

Secrets Proxy (secrets-proxy.ts)

  • Runs on host, intercepts all outbound requests from container
  • Replaces {{CONFIG:...}}, {{OAUTH:...}}, {{ENV:...}} placeholders with real values
  • Enforces domain allowlist to prevent exfiltration
  • Per-request auth via X-Proxy-Token header (random per-run secret)

Relay Container (gateway-container.ts)

  • socat-based relay bridges the air-gapped internal network to the host proxy
  • Linux/macOS (Unix socket mode): mounts the host proxy's Unix socket and relays TCP→UNIX-CONNECT
  • Windows (TCP mode): relays TCP→host.docker.internal:hostPort then disconnects from the bridge network for isolation
  • Explicit hostProxyPort parameter decouples relay listen port from upstream target port

Config Sanitization (sanitize-secrets.ts)

  • Strips all sensitive values from config before mounting into container
  • Replaces with placeholders that only the host proxy can resolve

Secure Fetch Wrapper (secure-fetch.ts)

  • Intercepts all fetch() calls inside the container
  • Routes through host proxy via X-Target-URL header
  • Gated on PROXY_URL env var (container-only, no-op on host)

Container Isolation (prepare-sanitized-mounts.ts)

  • Mounts sanitized config (read-only)
  • Mounts credentials directory for pairing files (read-only)
  • Separates session storage between normal and secure modes

Secrets Registry (secrets-registry.ts)

  • Host-side credential store that resolves placeholders to real values
  • Supports config keys, OAuth tokens, and environment variables
  • Provides the data layer for the secrets proxy

Security Auditing (audit.ts, audit-extra.ts, audit-fs.ts)

  • Pre-launch audit of the container environment
  • Verifies no secrets leaked into env vars, filesystem, or config
  • Blocks container start if secrets are detected

Security Enhancements

Threat Protection
Prompt injection extracting secrets ✅ Secrets never in container
Malicious npm dependencies ✅ No access to host secrets
Log leakage of credentials ✅ Only placeholders logged
Container escape + filesystem read ✅ No secrets on container fs
Exfiltration to unauthorized domains ✅ Domain allowlist enforced
Relay reachable from other containers ✅ Disconnected from bridge network (TCP mode)
Network escape from gateway container ✅ Internal-only Docker network (no outbound internet)

Backwards Compatibility

This is an opt-in security feature. Without --secure, the gateway runs exactly as before:

  • No Docker required
  • Direct access to secrets
  • No performance overhead

Users can adopt secure mode when ready, with no changes to their existing setup.

Important

Session Isolation: Secure mode uses separate session storage (sessions-secure/ instead of sessions/) to prevent cross-contamination. This means chat history from normal mode will not be available in secure mode, and vice versa. This is intentional — it ensures session data cannot leak between trust boundaries.

Usage

# Start gateway in zero-trust secure mode
openclaw gateway --secure

# Manage allowed domains
openclaw gateway allowlist list
openclaw gateway allowlist add api.example.com
openclaw gateway allowlist remove api.example.com

# Get/set the secrets proxy port
openclaw gateway allowlist port          # show current port
openclaw gateway allowlist port 9090     # set a custom port

CLI Commands

Command Description
openclaw gateway --secure Start gateway inside a secure Docker container
openclaw gateway allowlist list List all allowed proxy domains
openclaw gateway allowlist add <domain> Add a domain to the allowlist
openclaw gateway allowlist remove <domain> Remove a domain from the allowlist
openclaw gateway allowlist port [value] Get or set the secrets proxy port

Testing

  • Telegram polling works in secure mode
  • DM authorization works with pairing files mounted
  • LLM API calls succeed with placeholder replacement
  • Allowlist blocks unauthorized domains
  • Normal mode (without --secure) continues to work unchanged
  • Relay container isolated from bridge network (TCP mode)
  • Unix socket relay works on Linux/macOS

AI Assistance 🤖

This PR was developed with AI assistance (Gemini/Claude).

  • Testing level: Fully tested with Telegram in secure mode
  • Understanding: I understand the code and have reviewed all changes

Greptile Overview

Greptile Summary

This PR implements a production-grade zero-trust architecture for the OpenClaw gateway. The core design successfully isolates secrets from the container runtime using a host-side proxy with placeholder-based credential injection.

Architecture Overview

The implementation uses three key components:

  1. Gateway Container - Runs on an --internal Docker network (no internet access), contains zero secrets
  2. Socat Relay - Bridges the internal network to the host proxy via Unix socket (Linux/macOS) or TCP (Windows)
  3. Secrets Proxy - Host-side HTTP proxy that intercepts API requests, validates against an allowlist, and injects real credentials by replacing placeholders like {{CONFIG:channels.telegram.botToken}}

Security Model

The threat model assumes the gateway container is fully compromised (e.g., via prompt injection or malicious dependencies). Protection mechanisms:

  • All API keys, OAuth tokens, and credentials remain exclusively on the host
  • Container filesystem contains only placeholder strings
  • Network-level isolation blocks direct internet access
  • Domain allowlist prevents exfiltration to unauthorized endpoints
  • Per-session proxy authentication token prevents unauthorized local processes from using the proxy

Key Implementation Strengths

  1. Placeholder System - Comprehensive coverage of CONFIG, OAUTH, OAUTH_REFRESH, APIKEY, TOKEN, and ENV patterns with DoS limits
  2. OAuth Refresh Handling - Refresh tokens are correctly resolved from the live authStore.profiles (not the startup snapshot) to handle token rotation
  3. Binary Body Preservation - Content-Type detection ensures non-text payloads aren't corrupted during placeholder replacement
  4. Platform-Specific Networking - Unix sockets on Linux/macOS (zero TCP exposure) vs. localhost TCP on Windows (Docker Desktop constraint)
  5. Sanitization Logic - Config and auth-profiles files are pre-sanitized before mounting into the container
  6. Loopback Detection - Proper handling of localhost, ::1, and the full 127.0.0.0/8 range to bypass proxy for local services

Recent Fixes Confirmed

From the previous review thread, the following critical issues have been resolved:

  • Request body forwarding now converts ReadableStream to ArrayBuffer for Node/undici compatibility (secure-fetch.ts:102)
  • Proxy auth token (PROXY_AUTH_TOKEN) is correctly passed to container and normalized as string | string[] before comparison (secrets-proxy.ts:263)
  • OAuth refresh is properly gated on PROXY_URL instead of OPENCLAW_SECURE_MODE to avoid breaking host-side proxy (oauth.ts:43)
  • Allowlist caching uses mtime-based reload instead of per-request disk reads (secrets-proxy.ts:21-33)
  • Redirect handling clarified - undici doesn't follow by default, and any follow-up requests route back through the proxy for re-validation
  • Gateway container now binds to loopback instead of lan to minimize exposure (gateway-container.ts:236)
  • Unix socket permissions set to 0600 to prevent other local users from connecting (secrets-proxy.ts:452)
  • Credentials directory mount excludes oauth.json legacy file and only mounts safe pairing files (prepare-sanitized-mounts.ts:233-242)

Backwards Compatibility

Secure mode is entirely opt-in via --secure flag. Without it, the gateway runs exactly as before with no Docker requirement.

Confidence Score: 4/5

  • This PR is safe to merge with minor caveats around edge case handling
  • Score reflects thorough security architecture with comprehensive testing, but the complexity of the Docker networking, placeholder resolution, and multi-platform support introduces surface area for edge cases. The core threat model (container compromise) is well-defended, but operational scenarios (port conflicts, network configuration, legacy OAuth files) require careful deployment documentation.
  • Pay attention to src/security/secrets-proxy.ts (credential injection logic), src/security/gateway-container.ts (network isolation), and src/config/prepare-sanitized-mounts.ts (mount sanitization) during deployment and monitoring

robertchang-ga and others added 30 commits February 2, 2026 19:56
- Add secrets-proxy.ts: HTTP proxy with placeholder replacement, allowlist, 10MB body limit, 5min timeout
- Add secrets-proxy-allowlist.ts: Domain allowlist with persistence to ~/.openclaw/allowlist.json
- Add gateway-container.ts: Docker container lifecycle, env filtering, port mapping
- Add secure-fetch.ts: Fetch wrapper routing requests through proxy via X-Target-URL header
- Add allowlist-cli.ts: CLI commands for gateway allowlist management
- Modify run.ts: Add --secure flag with proxy/container orchestration
- Modify index.ts: Install secure fetch wrapper on startup
- Modify model-auth.ts, live-auth-keys.ts: Return placeholders in secure mode
- Add SecretsProxyConfig to types.gateway.ts
- Update SECURITY.MD with implementation status
P0 Fixes:
- secure-fetch: Properly extract method/headers/body from Request objects
- secrets-proxy: Only send body for methods that should have one (not GET/HEAD)
- secrets-proxy: Handle binary payloads properly, don't force utf8

P1 Fixes:
- gateway-container: Comprehensive secret filtering (AWS, DB, cloud provider creds)
- gateway-container: Add bind mount support for tools/skills
- gateway-container: Add health check utility functions
- secrets-proxy: Properly handle string[] response headers
- secrets-proxy: Add timeout to DoS placeholder limits
- run.ts: Add container health checks with timeout and periodic monitoring
- run.ts: Add proper error handling for proxy/container startup failures
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
Extends secrets injection system to protect channel and gateway credentials
by replacing them with placeholders when OPENCLAW_SECURE_MODE=1.

Changes:
- Add src/security/secrets-registry.ts: Host-side credential loader
- Add src/config/sanitize-secrets.ts: Config secret sanitization
- Update src/security/secrets-proxy.ts: Multi-pattern placeholder resolution
- Update src/agents/auth-profiles/oauth.ts: Return auth placeholders in secure mode
- Update src/config/io.ts: Auto-apply sanitization on config load
- Update src/cli/gateway-cli/run.ts: Initialize registry before proxy

Protected secrets:
- OAuth tokens (auto-refresh), API keys, static tokens
- Channel credentials (Discord, Telegram, Slack, Feishu, Google Chat)
- Gateway auth (token, password, remote credentials)
- Sensitive environment variables

Container now receives only placeholder values like {{CONFIG:channels.discord.token}}
which are resolved by the host-side proxy at request time.
Creates sanitized config files on host before Docker container starts,
ensuring no secrets enter the container even via filesystem access.

Mount strategy:
- Sanitized openclaw.yaml/json mounted read-only (placeholders only)
- Sanitized auth-profiles.json per-agent mounted read-only (placeholders)
- Conversations, sessions, memory dirs mounted read-write (persist data)
- Workspace dirs mounted read-write (file access)
- Credentials dir NOT mounted (OAuth stays on host)

Key changes:
- Add src/config/prepare-sanitized-mounts.ts: Generate sanitized files
- Update src/cli/gateway-cli/run.ts: Call prepareSanitizedMounts() on startup
- Auto-create data directories (conversations, sessions, memory, workspace)
  on host before mounting to ensure fresh installs work

Security: Container filesystem now contains only placeholder values like
{{OAUTH:google-gemini-cli}} and {{CONFIG:channels.discord.token}}, which
are resolved by the host-side secrets proxy at request time.
Gateway auth token/password are for inbound authentication,
not outgoing API calls. Container needs real values to validate
incoming client connections.
- Don't sanitize gateway.auth (inbound authentication)
- Add Commander config to show options in gateway --help
Container runs as 'node' user, not root. All bind mounts now
target /home/node/.openclaw/ instead of /root/.openclaw/
- Container runs as 'node' user, not root
- All bind mounts target /home/node/.openclaw/
- Reverted enablePositionalOptions/passThroughOptions (caused error)
Ensure gateway uses /home/node/.openclaw inside container,
not host paths that might be passed through env vars
HOME and OPENCLAW_*_DIR/_PATH from host were overwriting
container paths, causing mkdir /home/robert-chang errors
Docker connections come from 172.17.0.1, not localhost, so auto-pairing
doesn't trigger. Token auth is still enforced, so this is safe.
- Rewrite workspace paths in sanitized config
- Block PATH, USER, LOGNAME, PWD, XDG_*, NVM_* env vars
- Set explicit container environment overrides
- Sanitize agents.defaults.workspace path
- Block underscore (_) env var
- Don't mount sessions dir (contains host paths in sessions.json)
The config cache may return an unsanitized version if it was loaded
before OPENCLAW_SECURE_MODE was set. Disable cache to ensure
sanitizeConfigSecrets runs on a fresh config.
Use separate sessions-secure directory to avoid sessions.json with host paths.
Container will create fresh sessions.
- Skip local token validation in container, return placeholders
- Fix proxy regex to match full profile IDs with colons and @ signs
- Host proxy handles token resolution and refresh
@robertchang-ga

Copy link
Copy Markdown
Author

@greptileai Please review

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/config/io.ts Outdated
@greptile-apps

greptile-apps Bot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor
Additional Comments (2)

src/config/prepare-sanitized-mounts.ts
Sanitizing different config

This branch checks resolveConfigPath() and mounts that file, but it sanitizes via loadConfig() (default loader) instead of loading/sanitizing the contents from configPath. If a user supplies a non-default config path, the container will mount a sanitized file whose contents come from a different config, and placeholder→secret resolution will be wrong. Consider sanitizing the exact parsed content of configPath (or ensuring loadConfig() is reading that same path).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/config/prepare-sanitized-mounts.ts
Line: 285:290

Comment:
**Sanitizing different config**

This branch checks `resolveConfigPath()` and mounts that file, but it sanitizes via `loadConfig()` (default loader) instead of loading/sanitizing the contents from `configPath`. If a user supplies a non-default config path, the container will mount a sanitized file whose contents come from a different config, and placeholder→secret resolution will be wrong. Consider sanitizing the exact parsed content of `configPath` (or ensuring `loadConfig()` is reading that same path).

How can I resolve this? If you propose a fix, please make it concise.

src/security/secrets-registry.ts
Registry loads sanitized config

createSecretsRegistry() reads secrets from loadConfig() in ../config/io.js, but in secure mode you also generate a sanitized config file and mount it into the container. If the host process ever has its config path pointed at that sanitized file (or OPENCLAW_STATE_DIR/paths are adjusted), the registry would ingest placeholders instead of real secrets and the proxy would inject empty strings. To keep the security boundary sound, it’s important that the host-side registry always reads the real host config (not the sanitized mount copy).

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/secrets-registry.ts
Line: 1847:1849

Comment:
**Registry loads sanitized config**

`createSecretsRegistry()` reads secrets from `loadConfig()` in `../config/io.js`, but in secure mode you also generate a sanitized config file and mount it into the container. If the host process ever has its config path pointed at that sanitized file (or `OPENCLAW_STATE_DIR`/paths are adjusted), the registry would ingest placeholders instead of real secrets and the proxy would inject empty strings. To keep the security boundary sound, it’s important that the host-side registry always reads the *real* host config (not the sanitized mount copy).

How can I resolve this? If you propose a fix, please make it concise.

@robertchang-ga

Copy link
Copy Markdown
Author

@greptileai please review

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 file reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

@greptile-apps

greptile-apps Bot commented Feb 10, 2026

Copy link
Copy Markdown
Contributor
Additional Comments (1)

src/security/gateway-container.ts
Relay exposed on bridge

In Windows/TCP mode, the relay container is started on Docker’s default bridge network (--network bridge) and immediately begins listening on TCP-LISTEN:${proxyPort} before it’s connected to openclaw-secure-net and disconnected from bridge (see startRelayContainer() around network = ... ? SECURE_NETWORK_NAME : "bridge" and the later network connect/disconnect). Any other local container on bridge can deterministically reach the relay during this window and forward traffic to the host secrets proxy (relying only on X-Proxy-Token). If the goal is “only the secure gateway container can reach the proxy”, this startup sequence breaks that isolation; the relay should never be reachable from the shared bridge network, even transiently.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/security/gateway-container.ts
Line: 810:846

Comment:
**Relay exposed on bridge**

In Windows/TCP mode, the relay container is started on Docker’s default `bridge` network (`--network bridge`) and immediately begins listening on `TCP-LISTEN:${proxyPort}` before it’s connected to `openclaw-secure-net` and disconnected from `bridge` (see `startRelayContainer()` around `network = ... ? SECURE_NETWORK_NAME : "bridge"` and the later `network connect`/`disconnect`). Any other local container on `bridge` can deterministically reach the relay during this window and forward traffic to the host secrets proxy (relying only on `X-Proxy-Token`). If the goal is “only the secure gateway container can reach the proxy”, this startup sequence breaks that isolation; the relay should never be reachable from the shared `bridge` network, even transiently.

How can I resolve this? If you propose a fix, please make it concise.

@robertchang-ga

Copy link
Copy Markdown
Author

@greptileai Please review

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment thread src/security/secrets-proxy.ts
Comment thread src/security/secure-fetch.ts
Comment thread src/security/gateway-container.ts
@robertchang-ga

Copy link
Copy Markdown
Author

@greptileai please review

@sebslight

Copy link
Copy Markdown
Contributor

Closing as duplicate of #12839. If this is incorrect, comment and we can reopen.

@sebslight sebslight closed this Feb 13, 2026
@HenryLoenwind

Copy link
Copy Markdown
Contributor

@sebslight

Closing as duplicate of #12839. If this is incorrect, comment and we can reopen.

I haven't done a full code review of either, but this one looks more mature. Maybe add a note to the other one reminding whoever will be deciding to merge such a feature ot review both and decide on which one to use? This is not a 1-line change where every PR is interchangeable, but a major architectural change.

@robertchang-ga

robertchang-ga commented Feb 13, 2026

Copy link
Copy Markdown
Author

Closing as duplicate of #12839. If this is incorrect, comment and we can reopen.

We're trying to tackle the same issue in different ways, but I have a simpler framework that's applicable cross-platform and across more API request types. The vault adds defense-in-depth but both of our implementations would fail if someone gains access to your local machine. See the following:

Comparison: Secrets Proxy vs PR #12839 Vault Proxy

Both implementations solve the same core problem — keeping API keys out of the gateway process — but take fundamentally different architectural approaches.

Architecture Overview

Aspect Secrets Proxy (Ours) PR #12839 Vault Proxy
Proxy type In-process Node.js HTTP server External nginx reverse proxy sidecar
Encryption None (secrets live in memory/env) age-encrypted vault file (vault.age)
Credential injection Placeholder replacement in headers/body nginx proxy_set_header at request time
Network isolation Docker --internal network + socat relay → host-side Node.js proxy Two Docker networks (internal + external), all in containers
Secret management Pulled from env vars, config, OAuth store CLI commands (vault init/add/remove/list/migrate)
Fetch interception Global fetch monkey-patch via secure-fetch.ts normalizeProviders() rewrites baseUrl in config
Dependencies undici (already in project) age-encryption (new npm dependency) + nginx

How Credentials Flow

Our approach

The gateway's secure-fetch.ts intercepts every fetch() call, adds an X-Target-URL header, and routes through the Node.js proxy on localhost. The proxy replaces placeholders ({{APIKEY:openai}}, {{OAUTH:profile}}, {{CONFIG:path}}, etc.) in headers and body before forwarding.

fetch("https://api.openai.com/v1/chat", { headers: { Authorization: "Bearer {{APIKEY:openai}}" } })
  → secure-fetch intercepts → routes to localhost proxy
  → proxy replaces {{APIKEY:openai}} with real key
  → proxy forwards to api.openai.com

PR #12839

The config layer rewrites each provider's baseUrl to point at the vault sidecar (e.g., http://vault:8081). The gateway sends requests with a dummy API key (vault-proxy-managed), and nginx strips it and injects the real credential.

normalizeProviders() rewrites openai.baseUrl → http://vault:8081
  → gateway sends to http://vault:8081/v1/chat with dummy key
  → nginx strips dummy key, injects "Authorization: Bearer <real-key>"
  → nginx forwards to https://api.openai.com

Security Model

Security Feature Ours PR #12839
Gateway sees plaintext keys? No (proxy replaces placeholders) No (nginx injects them)
Keys at rest encryption? ❌ Plain env vars / config ✅ age-encrypted vault.age
Network isolation --internal net + relay container + host proxy ✅ Two-network model (internal + external)
Domain allowlist ✅ Extensive built-in list ❌ Path-based restrictions per provider
Auth token for proxy X-Proxy-Token shared secret ❌ Relies on network isolation
Userinfo/SSRF protection ✅ URL userinfo rejection ✅ Hostname regex validation
DoS protection ✅ Replacement limits, timeouts, body size ❌ Not applicable (nginx handles)
Secret key rotation N/A ✅ CLI-guided keypair rotation

Flexibility & Scope

Capability Ours PR #12839
Credential types API keys, OAuth tokens, refresh tokens, config secrets, env vars, tool secrets API keys only
Placeholder patterns 6 types: CONFIG, OAUTH, OAUTH_REFRESH, APIKEY, TOKEN, ENV None (static nginx injection)
Body replacement ✅ Replaces secrets in request body (e.g., Telegram bot tokens in URLs) ❌ Header-only injection
Provider coverage Any domain in allowlist 10 hardcoded providers (OpenAI, Anthropic, Deepgram, etc.)
Adding providers Add domain to allowlist Edit 3 files: nginx template, entrypoint.sh, operations.ts
Web search providers ✅ Via allowlist ✅ Brave, Perplexity, Grok with dedicated ports
Windows support ✅ TCP fallback for Docker-on-Windows ❌ Docker/nginx required

UX & Operations

Feature Ours PR #12839
Setup Automatic (proxy starts with gateway) openclaw vault init + Docker compose setup
Adding secrets Set env vars or config openclaw vault add OPENAI_API_KEY CLI
Migration path N/A openclaw vault migrate (moves plaintext config → encrypted)
Status/visibility Logging only openclaw vault status command
Non-Docker usage ❌ Requires Docker (no-op without PROXY_URL) ⚠️ Vault CLI works, but proxy requires Docker

Code Scope

Metric Ours PR #12839
Core files 3 files (~850 lines) ~15 files (~2,500+ lines)
Test files (not reviewed) 8 test files, 143 tests
Infrastructure None nginx config template, Dockerfile, entrypoint.sh, docker-compose
Config schema Allowlist JSON vault section in openclaw.json (TypeBox schema)
Documentation Inline code docs Full docs/gateway/vault.md (347 lines)

Which Proxy Engine Is Better?

nginx proxy_set_header is better for pure API key injection:

  • C-level performance, minimal memory overhead
  • Credential never enters Node.js heap — immune to memory dumps, heap snapshots, prototype pollution
  • Battle-tested proxy infrastructure (decades old)

In-process Node.js proxy is better for everything beyond API keys:

  • Dynamic placeholder replacement in request bodies (nginx can't do this without Lua/njs)
  • OAuth token refresh — nginx has no concept of this
  • Multi-source credential resolution (config paths, env vars, OAuth stores, tool secrets)
  • Content-aware processing — distinguishes text vs binary bodies

Bottom line: nginx is the better proxy engine; our placeholder system is the better credential model. For the current scale (proxying a handful of LLM API calls where API latency dwarfs proxy overhead), the in-process approach is pragmatically better given the broader credential types it handles.

Key Tradeoffs

Where ours is better:

  • Broader credential support (OAuth, refresh tokens, config paths, env vars — not just API keys)
  • Body-level secret injection (critical for APIs that need tokens in URLs/body like Telegram)
  • Cross-platform Docker support (TCP fallback for Docker-on-Windows where Unix sockets aren't available)
  • No extra sidecar infrastructure (no nginx container, no age dependency — proxy runs in-process on the host)
  • Easier to add providers (just add a domain to the allowlist)

Where PR #12839 is better:

  • Encryption at rest (secrets stored in age-encrypted vault file, not plain env vars)
  • Stronger network isolation (two Docker networks vs. relay-to-host model)
  • Better secret lifecycle management (init, add, remove, list, migrate, status CLI)
  • Migration tooling (automated migration from plaintext config to encrypted vault)
  • Comprehensive documentation and extensive test coverage (143 tests)

Compatibility

The two approaches are not mutually exclusive. The PR's vault proxy adds a vault section to openclaw.json and modifies normalizeProviders() and model-auth.ts — it doesn't touch secrets-proxy.ts, secure-fetch.ts, or secrets-proxy-allowlist.ts. They solve overlapping but different problems at different layers.

@HenryLoenwind

Copy link
Copy Markdown
Contributor

Credential types API keys, OAuth tokens, refresh tokens, config secrets, env vars, tool secrets API keys only
Placeholder patterns 6 types: CONFIG, OAUTH, OAUTH_REFRESH, APIKEY, TOKEN, ENV None (static nginx injection)
Body replacement ✅ Replaces secrets in request body (e.g., Telegram bot tokens in URLs) ❌ Header-only injection

I mean, for me, that alone would be the deciding factor...

@robertchang-ga

Copy link
Copy Markdown
Author

@sebslight I feel that it should be reopened. I'll continue working on this in the meantime.

@robertchang-ga

Copy link
Copy Markdown
Author

@joshavant , @mbelinky , @vincentkoc : I've been working on my fork for a while, all the while trying to stay sync'd to upstream. I'm building out a zero-trust framework on top of the existing harness to prevent access to sensitive data by running the gateway within a sanitized container and preventing unauthorized outbound connection at the network layer. Maybe worth a gander?

@openclaw-barnacle

Copy link
Copy Markdown

Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling cli CLI command changes gateway Gateway runtime size: XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants