From 2d5647a80452031757eff106b84f27c00835beed Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 14 Feb 2026 21:36:02 +0100 Subject: [PATCH] fix(security): restrict tool gatewayUrl overrides --- CHANGELOG.md | 4 +- src/agents/tools/gateway.e2e.test.ts | 17 ++++++- src/agents/tools/gateway.ts | 69 +++++++++++++++++++++++++++- src/agents/tools/message-tool.ts | 12 +++-- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5701b6cfa7..d9886cb1258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,9 @@ Docs: https://docs.openclaw.ai - Sessions/Agents: harden transcript path resolution for mismatched agent context by preserving explicit store roots and adding safe absolute-path fallback to the correct agent sessions directory. (#16288) Thanks @robbyczgw-cla. - BlueBubbles: include sender identity in group chat envelopes and pass clean message text to the agent prompt, aligning with iMessage/Signal formatting. (#16210) Thanks @zerone0x. - WhatsApp: honor per-account `dmPolicy` overrides (account-level settings now take precedence over channel defaults for inbound DMs). (#10082) Thanks @mcaxtr. -- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks @mcaxtr. +- Media: accept `MEDIA:`-prefixed paths (lenient whitespace) when loading outbound media to prevent `ENOENT` for tool-returned local media paths. (#13107) Thanks . +- Security/Gateway: harden tool-supplied `gatewayUrl` overrides by restricting them to loopback or the configured `gateway.remote.url`. Thanks -sec. + - Security/Node Host: enforce `system.run` rawCommand/argv consistency to prevent allowlist/approval bypass. Thanks @christos-eth. - Security/Exec approvals: prevent safeBins allowlist bypass via shell expansion (host exec allowlist mode only; not enabled by default). Thanks @christos-eth. - Security/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth. diff --git a/src/agents/tools/gateway.e2e.test.ts b/src/agents/tools/gateway.e2e.test.ts index 5b3b8495b7b..777ec43a130 100644 --- a/src/agents/tools/gateway.e2e.test.ts +++ b/src/agents/tools/gateway.e2e.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { callGatewayTool, resolveGatewayOptions } from "./gateway.js"; const callGatewayMock = vi.fn(); +vi.mock("../../config/config.js", () => ({ + loadConfig: () => ({}), + resolveGatewayPort: () => 18789, +})); vi.mock("../../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); @@ -20,15 +24,24 @@ describe("gateway tool defaults", () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool( "health", - { gatewayUrl: "ws://example", gatewayToken: "t", timeoutMs: 5000 }, + { gatewayUrl: "ws://127.0.0.1:18789", gatewayToken: "t", timeoutMs: 5000 }, {}, ); expect(callGatewayMock).toHaveBeenCalledWith( expect.objectContaining({ - url: "ws://example", + url: "ws://127.0.0.1:18789", token: "t", timeoutMs: 5000, }), ); }); + + it("rejects non-allowlisted overrides (SSRF hardening)", async () => { + await expect( + callGatewayTool("health", { gatewayUrl: "ws://127.0.0.1:8080", gatewayToken: "t" }, {}), + ).rejects.toThrow(/gatewayUrl override blocked/i); + await expect( + callGatewayTool("health", { gatewayUrl: "ws://169.254.169.254", gatewayToken: "t" }, {}), + ).rejects.toThrow(/gatewayUrl override blocked/i); + }); }); diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index fc15c769d08..eecc663c705 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -1,3 +1,4 @@ +import { loadConfig, resolveGatewayPort } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; @@ -9,11 +10,77 @@ export type GatewayCallOptions = { timeoutMs?: number; }; +function canonicalizeToolGatewayWsUrl(raw: string): { origin: string; key: string } { + const input = raw.trim(); + let url: URL; + try { + url = new URL(input); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`invalid gatewayUrl: ${input} (${message})`, { cause: error }); + } + + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error(`invalid gatewayUrl protocol: ${url.protocol} (expected ws:// or wss://)`); + } + if (url.username || url.password) { + throw new Error("invalid gatewayUrl: credentials are not allowed"); + } + if (url.search || url.hash) { + throw new Error("invalid gatewayUrl: query/hash not allowed"); + } + // Agents/tools expect the gateway websocket on the origin, not arbitrary paths. + if (url.pathname && url.pathname !== "/") { + throw new Error("invalid gatewayUrl: path not allowed"); + } + + const origin = url.origin; + // Key: protocol + host only, lowercased. (host includes IPv6 brackets + port when present) + const key = `${url.protocol}//${url.host.toLowerCase()}`; + return { origin, key }; +} + +function validateGatewayUrlOverrideForAgentTools(urlOverride: string): string { + const cfg = loadConfig(); + const port = resolveGatewayPort(cfg); + const allowed = new Set([ + `ws://127.0.0.1:${port}`, + `wss://127.0.0.1:${port}`, + `ws://localhost:${port}`, + `wss://localhost:${port}`, + `ws://[::1]:${port}`, + `wss://[::1]:${port}`, + ]); + + const remoteUrl = + typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url.trim() : ""; + if (remoteUrl) { + try { + const remote = canonicalizeToolGatewayWsUrl(remoteUrl); + allowed.add(remote.key); + } catch { + // ignore: misconfigured remote url; tools should fall back to default resolution. + } + } + + const parsed = canonicalizeToolGatewayWsUrl(urlOverride); + if (!allowed.has(parsed.key)) { + throw new Error( + [ + "gatewayUrl override blocked (SSRF hardening).", + `Allowed: ws(s) loopback on port ${port} (127.0.0.1/localhost/[::1])`, + "Or: configure gateway.remote.url and omit gatewayUrl.", + ].join(" "), + ); + } + return parsed.origin; +} + export function resolveGatewayOptions(opts?: GatewayCallOptions) { // Prefer an explicit override; otherwise let callGateway choose based on config. const url = typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim() - ? opts.gatewayUrl.trim() + ? validateGatewayUrlOverrideForAgentTools(opts.gatewayUrl) : undefined; const token = typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim() diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 277f5f083de..c30b89d4894 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -22,6 +22,7 @@ import { resolveSessionAgentId } from "../agent-scope.js"; import { listChannelSupportedActions } from "../channel-tools.js"; import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js"; import { jsonResult, readNumberParam, readStringParam } from "./common.js"; +import { resolveGatewayOptions } from "./gateway.js"; const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES; const EXPLICIT_TARGET_ACTIONS = new Set([ @@ -441,10 +442,15 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { params.accountId = accountId; } - const gateway = { - url: readStringParam(params, "gatewayUrl", { trim: false }), - token: readStringParam(params, "gatewayToken", { trim: false }), + const gatewayResolved = resolveGatewayOptions({ + gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }), + gatewayToken: readStringParam(params, "gatewayToken", { trim: false }), timeoutMs: readNumberParam(params, "timeoutMs"), + }); + const gateway = { + url: gatewayResolved.url, + token: gatewayResolved.token, + timeoutMs: gatewayResolved.timeoutMs, clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT, clientDisplayName: "agent", mode: GATEWAY_CLIENT_MODES.BACKEND,