diff --git a/CHANGELOG.md b/CHANGELOG.md index 71f7085351e..9c274f75770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. - Web UI: apply button styling to the new-messages indicator. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. +- Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier. - Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass. - Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing. - Cron: reload store data when the store file is recreated or mtime changes. diff --git a/docs/cli/devices.md b/docs/cli/devices.md index 17c3780cd66..670960104df 100644 --- a/docs/cli/devices.md +++ b/docs/cli/devices.md @@ -61,6 +61,9 @@ openclaw devices revoke --device --role node - `--timeout `: RPC timeout. - `--json`: JSON output (recommended for scripting). +Note: when you set `--url`, the CLI does not fall back to config or environment credentials. +Pass `--token` or `--password` explicitly. Missing explicit credentials is an error. + ## Notes - Token rotation returns a new token (sensitive). Treat it like a secret. diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 1c8793e7ed3..64724761a19 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -78,6 +78,9 @@ Shared options (where supported): - `--timeout `: timeout/budget (varies per command). - `--expect-final`: wait for a “final” response (agent calls). +Note: when you set `--url`, the CLI does not fall back to config or environment credentials. +Pass `--token` or `--password` explicitly. Missing explicit credentials is an error. + ### `gateway health` ```bash diff --git a/docs/cli/index.md b/docs/cli/index.md index 4fcd4866ba3..ad0034f85df 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -715,6 +715,8 @@ openclaw logs --no-color ### `gateway ` Gateway CLI helpers (use `--url`, `--token`, `--password`, `--timeout`, `--expect-final` for RPC subcommands). +When you pass `--url`, the CLI does not auto-apply config or environment credentials. +Include `--token` or `--password` explicitly. Missing explicit credentials is an error. Subcommands: diff --git a/docs/gateway/remote.md b/docs/gateway/remote.md index fc32d8c570c..0ff510bd374 100644 --- a/docs/gateway/remote.md +++ b/docs/gateway/remote.md @@ -80,6 +80,8 @@ With the tunnel up: - `openclaw gateway {status,health,send,agent,call}` can also target the forwarded URL via `--url` when needed. Note: replace `18789` with your configured `gateway.port` (or `--port`/`OPENCLAW_GATEWAY_PORT`). +Note: when you pass `--url`, the CLI does not fall back to config or environment credentials. +Include `--token` or `--password` explicitly. Missing explicit credentials is an error. ## CLI remote defaults diff --git a/docs/tools/index.md b/docs/tools/index.md index a2c741af2bc..5ecb51b4247 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -465,6 +465,9 @@ Gateway-backed tools (`canvas`, `nodes`, `cron`): - `gatewayToken` (if auth enabled) - `timeoutMs` +Note: when `gatewayUrl` is set, include `gatewayToken` explicitly. Tools do not inherit config +or environment credentials for overrides, and missing explicit credentials is an error. + Browser tool: - `profile` (optional; defaults to `browser.defaultProfile`) diff --git a/docs/tui.md b/docs/tui.md index cc0b4d9e0b7..2be342092ed 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -142,6 +142,9 @@ Other Gateway slash commands (for example, `/context`) are forwarded to the Gate - `--thinking `: Override thinking level for sends - `--timeout-ms `: Agent timeout in ms (defaults to `agents.defaults.timeoutSeconds`) +Note: when you set `--url`, the TUI does not fall back to config or environment credentials. +Pass `--token` or `--password` explicitly. Missing explicit credentials is an error. + ## Troubleshooting No output after sending a message: diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index d5def046bfa..640340f17c9 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -201,6 +201,8 @@ Notes: - `gatewayUrl` is stored in localStorage after load and removed from the URL. - `token` is stored in localStorage; `password` is kept in memory only. +- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials. + Provide `token` (or `password`) explicitly. Missing explicit credentials is an error. - Use `wss://` when the Gateway is behind TLS (Tailscale Serve, HTTPS proxy, etc.). - `gatewayUrl` is only accepted in a top-level window (not embedded) to prevent clickjacking. - For cross-origin dev setups (e.g. `pnpm ui:dev` to a remote Gateway), add the UI diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7bb8ae95d96..04e90669bdc 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -113,9 +113,14 @@ describe("callGateway url resolution", () => { resolveGatewayPort.mockReturnValue(18789); pickPrimaryTailnetIPv4.mockReturnValue(undefined); - await callGateway({ method: "health", url: "wss://override.example/ws" }); + await callGateway({ + method: "health", + url: "wss://override.example/ws", + token: "explicit-token", + }); expect(lastClientOptions?.url).toBe("wss://override.example/ws"); + expect(lastClientOptions?.token).toBe("explicit-token"); }); }); @@ -257,6 +262,40 @@ describe("callGateway error details", () => { }); }); +describe("callGateway url override auth requirements", () => { + beforeEach(() => { + loadConfig.mockReset(); + resolveGatewayPort.mockReset(); + pickPrimaryTailnetIPv4.mockReset(); + lastClientOptions = null; + startMode = "hello"; + closeCode = 1006; + closeReason = ""; + resolveGatewayPort.mockReturnValue(18789); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + }); + + afterEach(() => { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_PASSWORD; + }); + + it("throws when url override is set without explicit credentials", async () => { + process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; + process.env.OPENCLAW_GATEWAY_PASSWORD = "env-password"; + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { token: "local-token", password: "local-password" }, + }, + }); + + await expect( + callGateway({ method: "health", url: "wss://override.example/ws" }), + ).rejects.toThrow("explicit credentials"); + }); +}); + describe("callGateway password resolution", () => { const originalEnvPassword = process.env.OPENCLAW_GATEWAY_PASSWORD; @@ -338,6 +377,24 @@ describe("callGateway password resolution", () => { expect(lastClientOptions?.password).toBe("from-env"); }); + + it("uses explicit password when url override is set", async () => { + process.env.OPENCLAW_GATEWAY_PASSWORD = "from-env"; + loadConfig.mockReturnValue({ + gateway: { + mode: "local", + auth: { password: "from-config" }, + }, + }); + + await callGateway({ + method: "health", + url: "wss://override.example/ws", + password: "explicit-password", + }); + + expect(lastClientOptions?.password).toBe("explicit-password"); + }); }); describe("callGateway token resolution", () => { @@ -364,18 +421,21 @@ describe("callGateway token resolution", () => { } }); - it("uses remote token when remote mode uses url override", async () => { + it("uses explicit token when url override is set", async () => { process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; loadConfig.mockReturnValue({ gateway: { - mode: "remote", - remote: { token: "remote-token" }, + mode: "local", auth: { token: "local-token" }, }, }); - await callGateway({ method: "health", url: "wss://override.example/ws" }); + await callGateway({ + method: "health", + url: "wss://override.example/ws", + token: "explicit-token", + }); - expect(lastClientOptions?.token).toBe("remote-token"); + expect(lastClientOptions?.token).toBe("explicit-token"); }); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index bb196883a52..1f89b18e15e 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -51,6 +51,43 @@ export type GatewayConnectionDetails = { message: string; }; +export type ExplicitGatewayAuth = { + token?: string; + password?: string; +}; + +export function resolveExplicitGatewayAuth(opts?: ExplicitGatewayAuth): ExplicitGatewayAuth { + const token = + typeof opts?.token === "string" && opts.token.trim().length > 0 ? opts.token.trim() : undefined; + const password = + typeof opts?.password === "string" && opts.password.trim().length > 0 + ? opts.password.trim() + : undefined; + return { token, password }; +} + +export function ensureExplicitGatewayAuth(params: { + urlOverride?: string; + auth: ExplicitGatewayAuth; + errorHint: string; + configPath?: string; +}): void { + if (!params.urlOverride) { + return; + } + if (params.auth.token || params.auth.password) { + return; + } + const message = [ + "gateway url override requires explicit credentials", + params.errorHint, + params.configPath ? `Config: ${params.configPath}` : undefined, + ] + .filter(Boolean) + .join("\n"); + throw new Error(message); +} + export function buildGatewayConnectionDetails( options: { config?: OpenClawConfig; url?: string; configPath?: string } = {}, ): GatewayConnectionDetails { @@ -118,6 +155,13 @@ export async function callGateway>( const remote = isRemoteMode ? config.gateway?.remote : undefined; const urlOverride = typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; + const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); + ensureExplicitGatewayAuth({ + urlOverride, + auth: explicitAuth, + errorHint: "Fix: pass --token or --password (or gatewayToken in tools).", + configPath: opts.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)), + }); const remoteUrl = typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; if (isRemoteMode && !urlOverride && !remoteUrl) { @@ -153,31 +197,31 @@ export async function callGateway>( remoteTlsFingerprint || (tlsRuntime?.enabled ? tlsRuntime.fingerprintSha256 : undefined); const token = - (typeof opts.token === "string" && opts.token.trim().length > 0 - ? opts.token.trim() - : undefined) || - (isRemoteMode - ? typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined - : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || - (typeof authToken === "string" && authToken.trim().length > 0 - ? authToken.trim() - : undefined)); + explicitAuth.token || + (!urlOverride + ? isRemoteMode + ? typeof remote?.token === "string" && remote.token.trim().length > 0 + ? remote.token.trim() + : undefined + : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || + process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + (typeof authToken === "string" && authToken.trim().length > 0 + ? authToken.trim() + : undefined) + : undefined); const password = - (typeof opts.password === "string" && opts.password.trim().length > 0 - ? opts.password.trim() - : undefined) || - process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || - (isRemoteMode - ? typeof remote?.password === "string" && remote.password.trim().length > 0 - ? remote.password.trim() - : undefined - : typeof authPassword === "string" && authPassword.trim().length > 0 - ? authPassword.trim() - : undefined); + explicitAuth.password || + (!urlOverride + ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || + process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + (isRemoteMode + ? typeof remote?.password === "string" && remote.password.trim().length > 0 + ? remote.password.trim() + : undefined + : typeof authPassword === "string" && authPassword.trim().length > 0 + ? authPassword.trim() + : undefined) + : undefined); const formatCloseError = (code: number, reason: string) => { const reasonText = reason?.trim() || "no close reason"; diff --git a/src/tui/gateway-chat.test.ts b/src/tui/gateway-chat.test.ts new file mode 100644 index 00000000000..e38db25bbd0 --- /dev/null +++ b/src/tui/gateway-chat.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn(); +const resolveGatewayPort = vi.fn(); + +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig, + resolveGatewayPort, + }; +}); + +const { resolveGatewayConnection } = await import("./gateway-chat.js"); + +describe("resolveGatewayConnection", () => { + beforeEach(() => { + loadConfig.mockReset(); + resolveGatewayPort.mockReset(); + resolveGatewayPort.mockReturnValue(18789); + }); + + it("throws when url override is missing explicit credentials", () => { + loadConfig.mockReturnValue({ gateway: { mode: "local" } }); + + expect(() => resolveGatewayConnection({ url: "wss://override.example/ws" })).toThrow( + "explicit credentials", + ); + }); + + it("uses explicit token when url override is set", () => { + loadConfig.mockReturnValue({ gateway: { mode: "local" } }); + + const result = resolveGatewayConnection({ + url: "wss://override.example/ws", + token: "explicit-token", + }); + + expect(result).toEqual({ + url: "wss://override.example/ws", + token: "explicit-token", + password: undefined, + }); + }); + + it("uses explicit password when url override is set", () => { + loadConfig.mockReturnValue({ gateway: { mode: "local" } }); + + const result = resolveGatewayConnection({ + url: "wss://override.example/ws", + password: "explicit-password", + }); + + expect(result).toEqual({ + url: "wss://override.example/ws", + token: undefined, + password: "explicit-password", + }); + }); +}); diff --git a/src/tui/gateway-chat.ts b/src/tui/gateway-chat.ts index fe871dac568..f859fe027f4 100644 --- a/src/tui/gateway-chat.ts +++ b/src/tui/gateway-chat.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { ensureExplicitGatewayAuth, resolveExplicitGatewayAuth } from "../gateway/call.js"; import { GatewayClient } from "../gateway/client.js"; import { GATEWAY_CLIENT_CAPS } from "../gateway/protocol/client-info.js"; import { @@ -224,33 +225,41 @@ export function resolveGatewayConnection(opts: GatewayConnectionOptions) { const authToken = config.gateway?.auth?.token; const localPort = resolveGatewayPort(config); + const urlOverride = + typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined; + const explicitAuth = resolveExplicitGatewayAuth({ token: opts.token, password: opts.password }); + ensureExplicitGatewayAuth({ + urlOverride, + auth: explicitAuth, + errorHint: "Fix: pass --token or --password when using --url.", + }); const url = - (typeof opts.url === "string" && opts.url.trim().length > 0 ? opts.url.trim() : undefined) || + urlOverride || (typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined) || `ws://127.0.0.1:${localPort}`; const token = - (typeof opts.token === "string" && opts.token.trim().length > 0 - ? opts.token.trim() - : undefined) || - (isRemoteMode - ? typeof remote?.token === "string" && remote.token.trim().length > 0 - ? remote.token.trim() - : undefined - : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || - (typeof authToken === "string" && authToken.trim().length > 0 - ? authToken.trim() - : undefined)); + explicitAuth.token || + (!urlOverride + ? isRemoteMode + ? typeof remote?.token === "string" && remote.token.trim().length > 0 + ? remote.token.trim() + : undefined + : process.env.OPENCLAW_GATEWAY_TOKEN?.trim() || + (typeof authToken === "string" && authToken.trim().length > 0 + ? authToken.trim() + : undefined) + : undefined); const password = - (typeof opts.password === "string" && opts.password.trim().length > 0 - ? opts.password.trim() - : undefined) || - process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || - (typeof remote?.password === "string" && remote.password.trim().length > 0 - ? remote.password.trim() + explicitAuth.password || + (!urlOverride + ? process.env.OPENCLAW_GATEWAY_PASSWORD?.trim() || + (typeof remote?.password === "string" && remote.password.trim().length > 0 + ? remote.password.trim() + : undefined) : undefined); return { url, token, password };