feat(security): Zero-trust secure gateway with secrets proxy#9271
feat(security): Zero-trust secure gateway with secrets proxy#9271robertchang-ga wants to merge 97 commits into
Conversation
- 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
|
@greptileai Please review |
Additional Comments (2)
This branch checks Prompt To Fix With AIThis 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.
Prompt To Fix With AIThis 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. |
|
@greptileai please review |
Additional Comments (1)
In Windows/TCP mode, the relay container is started on Docker’s default Prompt To Fix With AIThis 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. |
|
@greptileai Please review |
|
@greptileai please review |
…ga/openclaw into feat/security-proxy # Conflicts: # pnpm-lock.yaml
|
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. |
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 ProxyBoth implementations solve the same core problem — keeping API keys out of the gateway process — but take fundamentally different architectural approaches. Architecture Overview
How Credentials FlowOur approachThe gateway's PR #12839The config layer rewrites each provider's Security Model
Flexibility & Scope
UX & Operations
Code Scope
Which Proxy Engine Is Better?nginx
In-process Node.js proxy is better for everything beyond API keys:
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 TradeoffsWhere ours is better:
Where PR #12839 is better:
CompatibilityThe two approaches are not mutually exclusive. The PR's vault proxy adds a |
I mean, for me, that alone would be the deciding factor... |
|
@sebslight I feel that it should be reopened. I'll continue working on this in the meantime. |
|
@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? |
|
Please don’t spam-ping multiple maintainers at once. Be patient, or join our community Discord for help: https://discord.gg/clawd |
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| GWRequest 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)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:
Prerequisites
Docker Installation
Secure mode requires Docker to be installed and running:
Building the Docker Image
Before first use, build the secure gateway image:
Key Changes
Secrets Proxy (
secrets-proxy.ts){{CONFIG:...}},{{OAUTH:...}},{{ENV:...}}placeholders with real valuesX-Proxy-Tokenheader (random per-run secret)Relay Container (
gateway-container.ts)TCP→UNIX-CONNECTTCP→host.docker.internal:hostPortthen disconnects from the bridge network for isolationhostProxyPortparameter decouples relay listen port from upstream target portConfig Sanitization (
sanitize-secrets.ts)Secure Fetch Wrapper (
secure-fetch.ts)fetch()calls inside the containerX-Target-URLheaderPROXY_URLenv var (container-only, no-op on host)Container Isolation (
prepare-sanitized-mounts.ts)Secrets Registry (
secrets-registry.ts)Security Auditing (
audit.ts,audit-extra.ts,audit-fs.ts)Security Enhancements
Backwards Compatibility
This is an opt-in security feature. Without
--secure, the gateway runs exactly as before: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 ofsessions/) 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
CLI Commands
openclaw gateway --secureopenclaw gateway allowlist listopenclaw gateway allowlist add <domain>openclaw gateway allowlist remove <domain>openclaw gateway allowlist port [value]Testing
--secure) continues to work unchangedAI Assistance 🤖
This PR was developed with AI assistance (Gemini/Claude).
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:
--internalDocker network (no internet access), contains zero secrets{{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:
Key Implementation Strengths
authStore.profiles(not the startup snapshot) to handle token rotationRecent Fixes Confirmed
From the previous review thread, the following critical issues have been resolved:
ReadableStreamtoArrayBufferfor Node/undici compatibility (secure-fetch.ts:102)PROXY_AUTH_TOKEN) is correctly passed to container and normalized asstring | string[]before comparison (secrets-proxy.ts:263)PROXY_URLinstead ofOPENCLAW_SECURE_MODEto avoid breaking host-side proxy (oauth.ts:43)loopbackinstead oflanto minimize exposure (gateway-container.ts:236)oauth.jsonlegacy file and only mounts safe pairing files (prepare-sanitized-mounts.ts:233-242)Backwards Compatibility
Secure mode is entirely opt-in via
--secureflag. Without it, the gateway runs exactly as before with no Docker requirement.Confidence Score: 4/5
src/security/secrets-proxy.ts(credential injection logic),src/security/gateway-container.ts(network isolation), andsrc/config/prepare-sanitized-mounts.ts(mount sanitization) during deployment and monitoring