From a19a7f5e6e4f771b2cb0ed1cc98abc81efc1c2b5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 2 Mar 2026 11:28:27 -0800 Subject: [PATCH] feat(security): Harden Docker browser container chromium flags (#23889) (#31504) * Gateway: honor OPENCLAW_GATEWAY_URL override for remote/local calls * Agents: fix sandbox sessionKey usage for PI embedded subagent calls * Sandbox: tighten browser container Chromium runtime flags * fix: add sandbox browser defaults for container hardening * docs: expand sandbox browser default flags list * fix: make sandbox browser flags optional and preserve gateway env auth overrides * docs: scope PR 31504 changelog entry * style: format gateway call override handling * fix: dedupe sandbox browser chrome args * fix: preserve remote tls fingerprint for env gateway override * fix: enforce auth for env gateway URL override * chore: document gateway override auth security expectations --- CHANGELOG.md | 1 + docs/gateway/configuration-reference.md | 32 ++++++ docs/gateway/sandboxing.md | 34 ++++++ docs/install/docker.md | 39 +++++++ scripts/sandbox-browser-entrypoint.sh | 41 ++++++- src/agents/pi-embedded-runner/compact.ts | 2 +- src/agents/pi-embedded-runner/run/attempt.ts | 6 +- src/gateway/call.test.ts | 111 ++++++++++++++++++- src/gateway/call.ts | 78 +++++++++++-- src/gateway/credentials.test.ts | 13 +++ src/gateway/credentials.ts | 13 ++- 11 files changed, 350 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 594ded2b621..aae18333e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai - Docker/Image health checks: add Dockerfile `HEALTHCHECK` that probes gateway `GET /healthz` so container runtimes can mark unhealthy instances without requiring auth credentials in the probe command. (#11478) Thanks @U-C4N and @vincentkoc. - Docker/Sandbox bootstrap hardening: make `OPENCLAW_SANDBOX` opt-in parsing explicit (`1|true|yes|on`), support custom Docker socket paths via `OPENCLAW_DOCKER_SOCKET`, defer docker.sock exposure until sandbox prerequisites pass, and reset/roll back persisted sandbox mode to `off` when setup is skipped or partially fails to avoid stale broken sandbox state. (#29974) Thanks @jamtujest and @vincentkoc. - Daemon/systemd checks in containers: treat missing `systemctl` invocations (including `spawn systemctl ENOENT`/`EACCES`) as unavailable service state during `is-enabled` checks, preventing container flows from failing with `Gateway service check failed` before install/status handling can continue. (#26089) Thanks @sahilsatralkar and @vincentkoc. +- Browser/Gateway hardening: preserve env credentials for `OPENCLAW_GATEWAY_URL` / `CLAWDBOT_GATEWAY_URL` while treating explicit `--url` as override-only auth, and make container browser hardening flags optional with safer defaults for Docker/LXC stability. (#31504) Thanks @vincentkoc. - Android/Nodes reliability: reject `facing=both` when `deviceId` is set to avoid mislabeled duplicate captures, allow notification `open`/`reply` on non-clearable entries while still gating dismiss, trigger listener rebind before notification actions, and scale invoke-result ack timeout to invoke budget for large clip payloads. (#28260) Thanks @obviyus. - Windows/Plugin install: avoid `spawn EINVAL` on Windows npm/npx invocations by resolving to `node` + npm CLI scripts instead of spawning `.cmd` directly. Landed from contributor PR #31147 by @codertony. Thanks @codertony. - Windows/Spawn canonicalization: unify non-core Windows spawn handling across ACP client, QMD/mcporter memory paths, and sandbox Docker execution using the shared wrapper-resolution policy, with targeted regression coverage for `.cmd` shim unwrapping and shell fallback behavior. (#31750) Thanks @Takhoffman. diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 5f5750dfb5a..c62a2795082 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -1177,6 +1177,35 @@ noVNC observer access uses VNC auth by default and OpenClaw emits a short-lived - `network` defaults to `openclaw-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly want global bridge connectivity. - `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`). - `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (including `[]`), it replaces `docker.binds` for the browser container. +- Launch defaults are defined in `scripts/sandbox-browser-entrypoint.sh` and tuned for container hosts: + - `--remote-debugging-address=127.0.0.1` + - `--remote-debugging-port=` + - `--user-data-dir=${HOME}/.chrome` + - `--no-first-run` + - `--no-default-browser-check` + - `--disable-3d-apis` + - `--disable-gpu` + - `--disable-software-rasterizer` + - `--disable-dev-shm-usage` + - `--disable-background-networking` + - `--disable-features=TranslateUI` + - `--disable-breakpad` + - `--disable-crash-reporter` + - `--renderer-process-limit=2` + - `--no-zygote` + - `--metrics-recording-only` + - `--disable-extensions` (default enabled) + - `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu` are + enabled by default and can be disabled with + `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` if WebGL/3D usage requires it. + - `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` re-enables extensions if your workflow + depends on them. + - `--renderer-process-limit=2` can be changed with + `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=`; set `0` to use Chromium's + default process limit. + - plus `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled. + - Defaults are the container image baseline; use a custom browser image with a custom + entrypoint to change container defaults. @@ -2251,6 +2280,7 @@ See [Plugins](/tools/plugin). color: "#FF4500", // headless: false, // noSandbox: false, + // extraArgs: [], // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", // attachOnly: false, }, @@ -2265,6 +2295,8 @@ See [Plugins](/tools/plugin). - Remote profiles are attach-only (start/stop/reset disabled). - Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary. - Control service: loopback only (port derived from `gateway.port`, default `18791`). +- `extraArgs` appends extra launch flags to local Chromium startup (for example + `--disable-gpu`, window sizing, or debug flags). --- diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 0f6a3d4f3d7..d62af2f4f7d 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -148,6 +148,40 @@ scripts/sandbox-browser-setup.sh By default, sandbox containers run with **no network**. Override with `agents.defaults.sandbox.docker.network`. +The bundled sandbox browser image also applies conservative Chromium startup defaults +for containerized workloads. Current container defaults include: + +- `--remote-debugging-address=127.0.0.1` +- `--remote-debugging-port=` +- `--user-data-dir=${HOME}/.chrome` +- `--no-first-run` +- `--no-default-browser-check` +- `--disable-3d-apis` +- `--disable-gpu` +- `--disable-dev-shm-usage` +- `--disable-background-networking` +- `--disable-extensions` +- `--disable-features=TranslateUI` +- `--disable-breakpad` +- `--disable-crash-reporter` +- `--disable-software-rasterizer` +- `--no-zygote` +- `--metrics-recording-only` +- `--renderer-process-limit=2` +- `--no-sandbox` and `--disable-setuid-sandbox` when `noSandbox` is enabled. +- The three graphics hardening flags (`--disable-3d-apis`, + `--disable-software-rasterizer`, `--disable-gpu`) are optional and are useful + when containers lack GPU support. Set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` + if your workload requires WebGL or other 3D/browser features. +- `--disable-extensions` is enabled by default and can be disabled with + `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for extension-reliant flows. +- `--renderer-process-limit=2` is controlled by + `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=`, where `0` keeps Chromium's default. + +If you need a different runtime profile, use a custom browser image and provide +your own entrypoint. For local (non-container) Chromium profiles, use +`browser.extraArgs` to append additional startup flags. + Security defaults: - `network: "host"` is blocked. diff --git a/docs/install/docker.md b/docs/install/docker.md index 42ce7a08d4d..8d376fb06a1 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -64,6 +64,13 @@ Optional env vars: - `OPENCLAW_DOCKER_SOCKET` — override Docker socket path (default: `DOCKER_HOST=unix://...` path, else `/var/run/docker.sock`) - `OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` — break-glass: allow trusted private-network `ws://` targets for CLI/onboarding client paths (default is loopback-only) +- `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` — disable container browser hardening flags + `--disable-3d-apis`, `--disable-software-rasterizer`, `--disable-gpu` when you need + WebGL/3D compatibility. +- `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` — keep extensions enabled when browser + flows require them (default keeps extensions disabled in sandbox browser). +- `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT=` — set Chromium renderer process + limit; set to `0` to skip the flag and use Chromium default behavior. After it finishes: @@ -672,6 +679,38 @@ Notes: - Browser containers default to a dedicated Docker network (`openclaw-sandbox-browser`) instead of global `bridge`. - Optional `agents.defaults.sandbox.browser.cdpSourceRange` restricts container-edge CDP ingress by CIDR (for example `172.21.0.1/32`). - noVNC observer access is password-protected by default; OpenClaw provides a short-lived observer token URL that serves a local bootstrap page and keeps the password in URL fragment (instead of URL query). +- Browser container startup defaults are conservative for shared/container workloads, including: + - `--remote-debugging-address=127.0.0.1` + - `--remote-debugging-port=` + - `--user-data-dir=${HOME}/.chrome` + - `--no-first-run` + - `--no-default-browser-check` + - `--disable-3d-apis` + - `--disable-software-rasterizer` + - `--disable-gpu` + - `--disable-dev-shm-usage` + - `--disable-background-networking` + - `--disable-features=TranslateUI` + - `--disable-breakpad` + - `--disable-crash-reporter` + - `--metrics-recording-only` + - `--renderer-process-limit=2` + - `--no-zygote` + - `--disable-extensions` + - If `agents.defaults.sandbox.browser.noSandbox` is set, `--no-sandbox` and + `--disable-setuid-sandbox` are also appended. + - The three graphics hardening flags above are optional. If your workload needs + WebGL/3D, set `OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS=0` to run without + `--disable-3d-apis`, `--disable-software-rasterizer`, and `--disable-gpu`. + - Extension behavior is controlled by `--disable-extensions` and can be disabled + (enables extensions) via `OPENCLAW_BROWSER_DISABLE_EXTENSIONS=0` for + extension-dependent pages or extensions-heavy workflows. + - `--renderer-process-limit=2` is also configurable with + `OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT`; set `0` to let Chromium choose its + default process limit when browser concurrency needs tuning. + +Defaults are applied by default in the bundled image. If you need different +Chromium flags, use a custom browser image and provide your own entrypoint. Use config: diff --git a/scripts/sandbox-browser-entrypoint.sh b/scripts/sandbox-browser-entrypoint.sh index 076643facd9..a69cd7d9cce 100755 --- a/scripts/sandbox-browser-entrypoint.sh +++ b/scripts/sandbox-browser-entrypoint.sh @@ -1,6 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +dedupe_chrome_args() { + local -A seen_args=() + local -a unique_args=() + + for arg in "${CHROME_ARGS[@]}"; do + if [[ -n "${seen_args["$arg"]:+x}" ]]; then + continue + fi + seen_args["$arg"]=1 + unique_args+=("$arg") + done + + CHROME_ARGS=("${unique_args[@]}") +} + export DISPLAY=:1 export HOME=/tmp/openclaw-home export XDG_CONFIG_HOME="${HOME}/.config" @@ -14,6 +29,9 @@ ENABLE_NOVNC="${OPENCLAW_BROWSER_ENABLE_NOVNC:-${CLAWDBOT_BROWSER_ENABLE_NOVNC:- HEADLESS="${OPENCLAW_BROWSER_HEADLESS:-${CLAWDBOT_BROWSER_HEADLESS:-0}}" ALLOW_NO_SANDBOX="${OPENCLAW_BROWSER_NO_SANDBOX:-${CLAWDBOT_BROWSER_NO_SANDBOX:-0}}" NOVNC_PASSWORD="${OPENCLAW_BROWSER_NOVNC_PASSWORD:-${CLAWDBOT_BROWSER_NOVNC_PASSWORD:-}}" +DISABLE_GRAPHICS_FLAGS="${OPENCLAW_BROWSER_DISABLE_GRAPHICS_FLAGS:-1}" +DISABLE_EXTENSIONS="${OPENCLAW_BROWSER_DISABLE_EXTENSIONS:-1}" +RENDERER_PROCESS_LIMIT="${OPENCLAW_BROWSER_RENDERER_PROCESS_LIMIT:-2}" mkdir -p "${HOME}" "${HOME}/.chrome" "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" @@ -22,7 +40,6 @@ Xvfb :1 -screen 0 1280x800x24 -ac -nolisten tcp & if [[ "${HEADLESS}" == "1" ]]; then CHROME_ARGS=( "--headless=new" - "--disable-gpu" ) else CHROME_ARGS=() @@ -45,9 +62,30 @@ CHROME_ARGS+=( "--disable-features=TranslateUI" "--disable-breakpad" "--disable-crash-reporter" + "--no-zygote" "--metrics-recording-only" ) +DISABLE_GRAPHICS_FLAGS_LOWER="${DISABLE_GRAPHICS_FLAGS,,}" +if [[ "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "1" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "true" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "yes" || "${DISABLE_GRAPHICS_FLAGS_LOWER}" == "on" ]]; then + CHROME_ARGS+=( + "--disable-3d-apis" + "--disable-gpu" + "--disable-software-rasterizer" + ) +fi + +DISABLE_EXTENSIONS_LOWER="${DISABLE_EXTENSIONS,,}" +if [[ "${DISABLE_EXTENSIONS_LOWER}" == "1" || "${DISABLE_EXTENSIONS_LOWER}" == "true" || "${DISABLE_EXTENSIONS_LOWER}" == "yes" || "${DISABLE_EXTENSIONS_LOWER}" == "on" ]]; then + CHROME_ARGS+=( + "--disable-extensions" + ) +fi + +if [[ "${RENDERER_PROCESS_LIMIT}" =~ ^[0-9]+$ && "${RENDERER_PROCESS_LIMIT}" -gt 0 ]]; then + CHROME_ARGS+=("--renderer-process-limit=${RENDERER_PROCESS_LIMIT}") +fi + if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then CHROME_ARGS+=( "--no-sandbox" @@ -55,6 +93,7 @@ if [[ "${ALLOW_NO_SANDBOX}" == "1" ]]; then ) fi +dedupe_chrome_args chromium "${CHROME_ARGS[@]}" about:blank & for _ in $(seq 1 50); do diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index 4bcdf1db66f..a6be0ca47d0 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -369,7 +369,7 @@ export async function compactEmbeddedPiSessionDirect( sandbox, messageProvider: params.messageChannel ?? params.messageProvider, agentAccountId: params.agentAccountId, - sessionKey: params.sessionKey ?? params.sessionId, + sessionKey: sandboxSessionKey, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index a4fca4ca59c..722bae2f79e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -584,7 +584,7 @@ export async function runEmbeddedAttempt( senderUsername: params.senderUsername, senderE164: params.senderE164, senderIsOwner: params.senderIsOwner, - sessionKey: params.sessionKey ?? params.sessionId, + sessionKey: sandboxSessionKey, agentDir, workspaceDir: effectiveWorkspace, config: params.config, @@ -751,7 +751,7 @@ export async function runEmbeddedAttempt( sandbox: (() => { const runtime = resolveSandboxRuntimeStatus({ cfg: params.config, - sessionKey: params.sessionKey ?? params.sessionId, + sessionKey: sandboxSessionKey, }); return { mode: runtime.mode, sandboxed: runtime.sandboxed }; })(), @@ -1185,7 +1185,7 @@ export async function runEmbeddedAttempt( onAgentEvent: params.onAgentEvent, enforceFinalTag: params.enforceFinalTag, config: params.config, - sessionKey: params.sessionKey ?? params.sessionId, + sessionKey: sandboxSessionKey, }); const { diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 66bced88bc2..5dd982d6efe 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -11,6 +11,7 @@ let lastClientOptions: { url?: string; token?: string; password?: string; + tlsFingerprint?: string; scopes?: string[]; onHelloOk?: () => void | Promise; onClose?: (code: number, reason: string) => void; @@ -90,7 +91,12 @@ function makeRemotePasswordGatewayConfig(remotePassword: string, localPassword = } describe("callGateway url resolution", () => { - const envSnapshot = captureEnv(["OPENCLAW_ALLOW_INSECURE_PRIVATE_WS"]); + const envSnapshot = captureEnv([ + "OPENCLAW_ALLOW_INSECURE_PRIVATE_WS", + "OPENCLAW_GATEWAY_URL", + "OPENCLAW_GATEWAY_TOKEN", + "CLAWDBOT_GATEWAY_TOKEN", + ]); beforeEach(() => { envSnapshot.restore(); @@ -184,6 +190,68 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); + it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { + loadConfig.mockReturnValue({ + gateway: { mode: "remote", bind: "loopback", remote: {} }, + }); + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws"; + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + + await callGateway({ + method: "health", + }); + + expect(lastClientOptions?.url).toBe("wss://gateway-in-container.internal:9443/ws"); + expect(lastClientOptions?.token).toBe("env-token"); + expect(lastClientOptions?.password).toBeUndefined(); + }); + + it("uses remote tlsFingerprint with env URL override", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + remote: { + url: "wss://remote.example:9443/ws", + tlsFingerprint: "remote-fingerprint", + }, + }, + }); + setGatewayNetworkDefaults(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + process.env.OPENCLAW_GATEWAY_URL = "wss://gateway-in-container.internal:9443/ws"; + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + + await callGateway({ + method: "health", + }); + + expect(lastClientOptions?.tlsFingerprint).toBe("remote-fingerprint"); + }); + + it("does not apply remote tlsFingerprint for CLI url override", async () => { + loadConfig.mockReturnValue({ + gateway: { + mode: "remote", + remote: { + url: "wss://remote.example:9443/ws", + tlsFingerprint: "remote-fingerprint", + }, + }, + }); + setGatewayNetworkDefaults(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + + await callGateway({ + method: "health", + url: "wss://override.example:9443/ws", + token: "explicit-token", + }); + + expect(lastClientOptions?.tlsFingerprint).toBeUndefined(); + }); + it.each([ { label: "uses least-privilege scopes by default for non-CLI callers", @@ -300,6 +368,28 @@ describe("buildGatewayConnectionDetails", () => { expect(details.remoteFallbackNote).toBeUndefined(); }); + it("uses env OPENCLAW_GATEWAY_URL when set", () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "loopback" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + const prevUrl = process.env.OPENCLAW_GATEWAY_URL; + try { + process.env.OPENCLAW_GATEWAY_URL = "wss://browser-gateway.local:9443/ws"; + + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("wss://browser-gateway.local:9443/ws"); + expect(details.urlSource).toBe("env OPENCLAW_GATEWAY_URL"); + expect(details.bindDetail).toBeUndefined(); + } finally { + if (prevUrl === undefined) { + delete process.env.OPENCLAW_GATEWAY_URL; + } else { + process.env.OPENCLAW_GATEWAY_URL = prevUrl; + } + } + }); + it("throws for insecure ws:// remote URLs (CWE-319)", () => { loadConfig.mockReturnValue({ gateway: { @@ -434,7 +524,12 @@ describe("callGateway url override auth requirements", () => { let envSnapshot: ReturnType; beforeEach(() => { - envSnapshot = captureEnv(["OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_GATEWAY_PASSWORD"]); + envSnapshot = captureEnv([ + "OPENCLAW_GATEWAY_TOKEN", + "OPENCLAW_GATEWAY_PASSWORD", + "OPENCLAW_GATEWAY_URL", + "CLAWDBOT_GATEWAY_URL", + ]); resetGatewayCallMocks(); setGatewayNetworkDefaults(18789); }); @@ -457,6 +552,18 @@ describe("callGateway url override auth requirements", () => { callGateway({ method: "health", url: "wss://override.example/ws" }), ).rejects.toThrow("explicit credentials"); }); + + it("throws when env URL override is set without env credentials", async () => { + process.env.OPENCLAW_GATEWAY_URL = "wss://override.example/ws"; + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { token: "local-token", password: "local-password" }, + }, + }); + + await expect(callGateway({ method: "health" })).rejects.toThrow("explicit credentials"); + }); }); describe("callGateway password resolution", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 042f55a4a98..58da45db031 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -86,14 +86,30 @@ export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): Explicit export function ensureExplicitGatewayAuth(params: { urlOverride?: string; - auth: ExplicitGatewayAuth; + urlOverrideSource?: "cli" | "env"; + explicitAuth?: ExplicitGatewayAuth; + resolvedAuth?: ExplicitGatewayAuth; errorHint: string; configPath?: string; }): void { if (!params.urlOverride) { return; } - if (params.auth.token || params.auth.password) { + // URL overrides are untrusted redirects and can move WebSocket traffic off the intended host. + // Never allow an override to silently reuse implicit credentials or device token fallback. + const explicitToken = params.explicitAuth?.token; + const explicitPassword = params.explicitAuth?.password; + if (params.urlOverrideSource === "cli" && (explicitToken || explicitPassword)) { + return; + } + const hasResolvedAuth = + params.resolvedAuth?.token || + params.resolvedAuth?.password || + explicitToken || + explicitPassword; + // Env overrides are supported for deployment ergonomics, but only when explicit auth is available. + // This avoids implicit device-token fallback against attacker-controlled WSS endpoints. + if (params.urlOverrideSource === "env" && hasResolvedAuth) { return; } const message = [ @@ -107,7 +123,12 @@ export function ensureExplicitGatewayAuth(params: { } export function buildGatewayConnectionDetails( - options: { config?: OpenClawConfig; url?: string; configPath?: string } = {}, + options: { + config?: OpenClawConfig; + url?: string; + configPath?: string; + urlSource?: "cli" | "env"; + } = {}, ): GatewayConnectionDetails { const config = options.config ?? loadConfig(); const configPath = @@ -120,25 +141,34 @@ export function buildGatewayConnectionDetails( const scheme = tlsEnabled ? "wss" : "ws"; // Self-connections should always target loopback; bind mode only controls listener exposure. const localUrl = `${scheme}://127.0.0.1:${localPort}`; - const urlOverride = + const cliUrlOverride = typeof options.url === "string" && options.url.trim().length > 0 ? options.url.trim() : undefined; + const envUrlOverride = cliUrlOverride + ? undefined + : (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ?? + trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL)); + const urlOverride = cliUrlOverride ?? envUrlOverride; const remoteUrl = typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl; + const urlSourceHint = + options.urlSource ?? (cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined); const url = urlOverride || remoteUrl || localUrl; const urlSource = urlOverride - ? "cli --url" + ? urlSourceHint === "env" + ? "env OPENCLAW_GATEWAY_URL" + : "cli --url" : remoteUrl ? "config gateway.remote.url" : remoteMisconfigured ? "missing gateway.remote.url (fallback local)" : "local loopback"; + const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; const remoteFallbackNote = remoteMisconfigured ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." : undefined; - const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; const allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; // Security check: block ALL insecure ws:// to non-loopback addresses (CWE-319, CVSS 9.8) @@ -196,6 +226,7 @@ type ResolvedGatewayCallContext = { isRemoteMode: boolean; remote?: GatewayRemoteSettings; urlOverride?: string; + urlOverrideSource?: "cli" | "env"; remoteUrl?: string; explicitAuth: ExplicitGatewayAuth; }; @@ -226,10 +257,25 @@ function resolveGatewayCallContext(opts: CallGatewayBaseOptions): ResolvedGatewa const remote = isRemoteMode ? (config.gateway?.remote as GatewayRemoteSettings | undefined) : undefined; - const urlOverride = trimToUndefined(opts.url); + const cliUrlOverride = trimToUndefined(opts.url); + const envUrlOverride = cliUrlOverride + ? undefined + : (trimToUndefined(process.env.OPENCLAW_GATEWAY_URL) ?? + trimToUndefined(process.env.CLAWDBOT_GATEWAY_URL)); + const urlOverride = cliUrlOverride ?? envUrlOverride; + const urlOverrideSource = cliUrlOverride ? "cli" : envUrlOverride ? "env" : undefined; const remoteUrl = trimToUndefined(remote?.url); const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); - return { config, configPath, isRemoteMode, remote, urlOverride, remoteUrl, explicitAuth }; + return { + config, + configPath, + isRemoteMode, + remote, + urlOverride, + urlOverrideSource, + remoteUrl, + explicitAuth, + }; } function ensureRemoteModeUrlConfigured(context: ResolvedGatewayCallContext): void { @@ -254,6 +300,7 @@ function resolveGatewayCredentials(context: ResolvedGatewayCallContext): { env: process.env, explicitAuth: context.explicitAuth, urlOverride: context.urlOverride, + urlOverrideSource: context.urlOverrideSource, remotePasswordPrecedence: "env-first", }); } @@ -266,7 +313,7 @@ async function resolveGatewayTlsFingerprint(params: { const { opts, context, url } = params; const useLocalTls = context.config.gateway?.tls?.enabled === true && - !context.urlOverride && + !context.urlOverrideSource && !context.remoteUrl && url.startsWith("wss://"); const tlsRuntime = useLocalTls @@ -274,7 +321,10 @@ async function resolveGatewayTlsFingerprint(params: { : undefined; const overrideTlsFingerprint = trimToUndefined(opts.tlsFingerprint); const remoteTlsFingerprint = - context.isRemoteMode && !context.urlOverride && context.remoteUrl + // Env overrides may still inherit configured remote TLS pinning for private cert deployments. + // CLI overrides remain explicit-only and intentionally skip config remote TLS to avoid + // accidentally pinning against caller-supplied target URLs. + context.isRemoteMode && context.urlOverrideSource !== "cli" ? trimToUndefined(context.remote?.tlsFingerprint) : undefined; return ( @@ -388,9 +438,12 @@ async function callGatewayWithScopes>( ): Promise { const { timeoutMs, safeTimerTimeoutMs } = resolveGatewayCallTimeout(opts.timeoutMs); const context = resolveGatewayCallContext(opts); + const resolvedCredentials = resolveGatewayCredentials(context); ensureExplicitGatewayAuth({ urlOverride: context.urlOverride, - auth: context.explicitAuth, + urlOverrideSource: context.urlOverrideSource, + explicitAuth: context.explicitAuth, + resolvedAuth: resolvedCredentials, errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", configPath: context.configPath, }); @@ -398,11 +451,12 @@ async function callGatewayWithScopes>( const connectionDetails = buildGatewayConnectionDetails({ config: context.config, url: context.urlOverride, + urlSource: context.urlOverrideSource, ...(opts.configPath ? { configPath: opts.configPath } : {}), }); const url = connectionDetails.url; const tlsFingerprint = await resolveGatewayTlsFingerprint({ opts, context, url }); - const { token, password } = resolveGatewayCredentials(context); + const { token, password } = resolvedCredentials; return await executeGatewayRequestWithScopes({ opts, scopes, diff --git a/src/gateway/credentials.test.ts b/src/gateway/credentials.test.ts index 1de2ce06541..282c72dff92 100644 --- a/src/gateway/credentials.test.ts +++ b/src/gateway/credentials.test.ts @@ -78,6 +78,19 @@ describe("resolveGatewayCredentialsFromConfig", () => { expect(resolved).toEqual({}); }); + it("uses env credentials for env-sourced url overrides", () => { + const resolved = resolveGatewayCredentialsFor( + { + auth: DEFAULT_GATEWAY_AUTH, + }, + { + urlOverride: "wss://example.com", + urlOverrideSource: "env", + }, + ); + expectEnvGatewayCredentials(resolved); + }); + it("uses local-mode environment values before local config", () => { const resolved = resolveGatewayCredentialsFor({ mode: "local", diff --git a/src/gateway/credentials.ts b/src/gateway/credentials.ts index ace7ba4fd27..f7e428bc822 100644 --- a/src/gateway/credentials.ts +++ b/src/gateway/credentials.ts @@ -94,6 +94,7 @@ export function resolveGatewayCredentialsFromConfig(params: { env?: NodeJS.ProcessEnv; explicitAuth?: ExplicitGatewayAuth; urlOverride?: string; + urlOverrideSource?: "cli" | "env"; modeOverride?: GatewayCredentialMode; includeLegacyEnv?: boolean; localTokenPrecedence?: GatewayCredentialPrecedence; @@ -110,9 +111,19 @@ export function resolveGatewayCredentialsFromConfig(params: { if (explicitToken || explicitPassword) { return { token: explicitToken, password: explicitPassword }; } - if (trimToUndefined(params.urlOverride)) { + if (trimToUndefined(params.urlOverride) && params.urlOverrideSource !== "env") { return {}; } + if (trimToUndefined(params.urlOverride) && params.urlOverrideSource === "env") { + return resolveGatewayCredentialsFromValues({ + configToken: undefined, + configPassword: undefined, + env, + includeLegacyEnv, + tokenPrecedence: "env-first", + passwordPrecedence: "env-first", + }); + } const mode: GatewayCredentialMode = params.modeOverride ?? (params.cfg.gateway?.mode === "remote" ? "remote" : "local");