diff --git a/src/commands/status.scan.fast-json.test.ts b/src/commands/status.scan.fast-json.test.ts index 83bc1bd5341..7bb3c14d1c5 100644 --- a/src/commands/status.scan.fast-json.test.ts +++ b/src/commands/status.scan.fast-json.test.ts @@ -134,8 +134,8 @@ vi.mock("../agents/memory-search.js", () => ({ resolveMemorySearchConfig: mocks.resolveMemorySearchConfig, })); -vi.mock("../gateway/call.js", () => ({ - buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, +vi.mock("../gateway/connection-details.js", () => ({ + buildGatewayConnectionDetailsFromConfig: mocks.buildGatewayConnectionDetails, })); vi.mock("../gateway/probe.js", () => ({ diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index 6f28bcd7773..b399a2f2888 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -1,6 +1,9 @@ import { existsSync } from "node:fs"; import type { OpenClawConfig } from "../config/types.js"; -import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { + buildGatewayConnectionDetailsFromConfig, + type GatewayConnectionDetails, +} from "../gateway/connection-details.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { probeGateway } from "../gateway/probe.js"; import type { MemoryProviderStatus } from "../memory/types.js"; @@ -20,7 +23,7 @@ export type MemoryPluginStatus = { }; export type GatewayProbeSnapshot = { - gatewayConnection: ReturnType; + gatewayConnection: GatewayConnectionDetails; remoteUrlMissing: boolean; gatewayMode: "local" | "remote"; gatewayProbeAuth: { @@ -60,7 +63,7 @@ export async function resolveGatewayProbeSnapshot(params: { cfg: OpenClawConfig; opts: { timeoutMs?: number; all?: boolean }; }): Promise { - const gatewayConnection = buildGatewayConnectionDetails({ config: params.cfg }); + const gatewayConnection = buildGatewayConnectionDetailsFromConfig({ config: params.cfg }); const isRemoteMode = params.cfg.gateway?.mode === "remote"; const remoteUrlRaw = typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 1098b3d9bc3..a9bfdbe9b96 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -79,8 +79,11 @@ vi.mock("./status.scan.deps.runtime.js", () => ({ getMemorySearchManager: mocks.getMemorySearchManager, })); +vi.mock("../gateway/connection-details.js", () => ({ + buildGatewayConnectionDetailsFromConfig: mocks.buildGatewayConnectionDetails, +})); + vi.mock("../gateway/call.js", () => ({ - buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails, callGateway: mocks.callGateway, })); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 98793dd4071..a230be67a1c 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -1,11 +1,6 @@ import { randomUUID } from "node:crypto"; import type { OpenClawConfig } from "../config/config.js"; -import { - loadConfig, - resolveConfigPath, - resolveGatewayPort, - resolveStateDir, -} from "../config/config.js"; +import { loadConfig, resolveConfigPath, resolveStateDir } from "../config/config.js"; import { resolveSecretInputRef } from "../config/types.secrets.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { loadGatewayTlsRuntime } from "../infra/tls/gateway.js"; @@ -18,6 +13,10 @@ import { } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import { GatewayClient } from "./client.js"; +import { + buildGatewayConnectionDetailsFromConfig, + type GatewayConnectionDetails, +} from "./connection-details.js"; import { GatewaySecretRefUnavailableError, resolveGatewayCredentialsFromConfig, @@ -32,7 +31,6 @@ import { resolveLeastPrivilegeOperatorScopesForMethod, type OperatorScope, } from "./method-scopes.js"; -import { isSecureWebSocketUrl } from "./net.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; type CallGatewayBaseOptions = { @@ -73,14 +71,6 @@ export type CallGatewayOptions = CallGatewayBaseOptions & { scopes?: OperatorScope[]; }; -export type GatewayConnectionDetails = { - url: string; - urlSource: string; - bindDetail?: string; - remoteFallbackNote?: string; - message: string; -}; - function shouldAttachDeviceIdentityForGatewayCall(params: { url: string; token?: string; @@ -156,86 +146,12 @@ export function buildGatewayConnectionDetails( } = {}, ): GatewayConnectionDetails { const config = options.config ?? loadConfig(); - const configPath = - options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); - const isRemoteMode = config.gateway?.mode === "remote"; - const remote = isRemoteMode ? config.gateway?.remote : undefined; - const tlsEnabled = config.gateway?.tls?.enabled === true; - const localPort = resolveGatewayPort(config); - const bindMode = config.gateway?.bind ?? "loopback"; - 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 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 - ? 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 allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; - // Security check: block ALL insecure ws:// to non-loopback addresses (CWE-319, CVSS 9.8) - // This applies to the FINAL resolved URL, regardless of source (config, CLI override, etc). - // Both credentials and chat/conversation data must not be transmitted over plaintext to remote hosts. - if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { - throw new Error( - [ - `SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`, - "Both credentials and chat data would be exposed to network interception.", - `Source: ${urlSource}`, - `Config: ${configPath}`, - "Fix: Use wss:// for remote gateway URLs.", - "Safe remote access defaults:", - "- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)", - "- or use Tailscale Serve/Funnel for HTTPS remote access", - allowPrivateWs - ? undefined - : "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", - "Doctor: openclaw doctor --fix", - "Docs: https://docs.openclaw.ai/gateway/remote", - ].join("\n"), - ); - } - - const message = [ - `Gateway target: ${url}`, - `Source: ${urlSource}`, - `Config: ${configPath}`, - bindDetail, - remoteFallbackNote, - ] - .filter(Boolean) - .join("\n"); - - return { - url, - urlSource, - bindDetail, - remoteFallbackNote, - message, - }; + return buildGatewayConnectionDetailsFromConfig({ + config, + url: options.url, + configPath: options.configPath, + urlSource: options.urlSource, + }); } type GatewayRemoteSettings = { diff --git a/src/gateway/connection-details.ts b/src/gateway/connection-details.ts new file mode 100644 index 00000000000..065e9426735 --- /dev/null +++ b/src/gateway/connection-details.ts @@ -0,0 +1,101 @@ +import { resolveConfigPath, resolveGatewayPort, resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.js"; +import { isSecureWebSocketUrl } from "./net.js"; + +export type GatewayConnectionDetails = { + url: string; + urlSource: string; + bindDetail?: string; + remoteFallbackNote?: string; + message: string; +}; + +function trimToUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function buildGatewayConnectionDetailsFromConfig(options: { + config: OpenClawConfig; + url?: string; + configPath?: string; + urlSource?: "cli" | "env"; +}): GatewayConnectionDetails { + const configPath = + options.configPath ?? resolveConfigPath(process.env, resolveStateDir(process.env)); + const isRemoteMode = options.config.gateway?.mode === "remote"; + const remote = isRemoteMode ? options.config.gateway?.remote : undefined; + const tlsEnabled = options.config.gateway?.tls?.enabled === true; + const localPort = resolveGatewayPort(options.config); + const bindMode = options.config.gateway?.bind ?? "loopback"; + 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 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 + ? 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 allowPrivateWs = process.env.OPENCLAW_ALLOW_INSECURE_PRIVATE_WS === "1"; + if (!isSecureWebSocketUrl(url, { allowPrivateWs })) { + throw new Error( + [ + `SECURITY ERROR: Gateway URL "${url}" uses plaintext ws:// to a non-loopback address.`, + "Both credentials and chat data would be exposed to network interception.", + `Source: ${urlSource}`, + `Config: ${configPath}`, + "Fix: Use wss:// for remote gateway URLs.", + "Safe remote access defaults:", + "- keep gateway.bind=loopback and use an SSH tunnel (ssh -N -L 18789:127.0.0.1:18789 user@gateway-host)", + "- or use Tailscale Serve/Funnel for HTTPS remote access", + allowPrivateWs + ? undefined + : "Break-glass (trusted private networks only): set OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1", + "Doctor: openclaw doctor --fix", + "Docs: https://docs.openclaw.ai/gateway/remote", + ].join("\n"), + ); + } + + const message = [ + `Gateway target: ${url}`, + `Source: ${urlSource}`, + `Config: ${configPath}`, + bindDetail, + remoteFallbackNote, + ] + .filter(Boolean) + .join("\n"); + + return { + url, + urlSource, + bindDetail, + remoteFallbackNote, + message, + }; +} diff --git a/src/gateway/gateway-connection.test-mocks.ts b/src/gateway/gateway-connection.test-mocks.ts index 966ec8254c6..1e8d9541a3c 100644 --- a/src/gateway/gateway-connection.test-mocks.ts +++ b/src/gateway/gateway-connection.test-mocks.ts @@ -12,6 +12,13 @@ vi.mock("../config/config.js", async (importOriginal) => { return { ...actual, loadConfig: loadConfigMock, + }; +}); + +vi.mock("../config/paths.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, resolveGatewayPort: resolveGatewayPortMock, }; });