diff --git a/CHANGELOG.md b/CHANGELOG.md index a76ec655cdf..b152d63eaf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - 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. - Security/Exec: harden PATH handling by disabling project-local `node_modules/.bin` bootstrapping by default, disallowing node-host `PATH` overrides, and spawning ACP servers via the current executable by default. Thanks @akhmittra. +- Security/Gateway: prevent SSRF by ignoring user-provided `gatewayUrl` tool inputs (gateway URL must come from config). Thanks @p80n-sec. - CLI: fix lazy core command registration so top-level maintenance commands (`doctor`, `dashboard`, `reset`, `uninstall`) resolve correctly instead of exposing a non-functional `maintenance` placeholder command. - Telegram: when `channels.telegram.commands.native` is `false`, exclude plugin commands from `setMyCommands` menu registration while keeping plugin slash handlers callable. (#15132) Thanks @Glucksberg. - Security/Agents: scope CLI process cleanup to owned child PIDs to avoid killing unrelated processes on shared hosts. Thanks @aether-ai-agent. diff --git a/src/agents/tools/gateway.e2e.test.ts b/src/agents/tools/gateway.e2e.test.ts index b9d470c1532..ad18edcc6f6 100644 --- a/src/agents/tools/gateway.e2e.test.ts +++ b/src/agents/tools/gateway.e2e.test.ts @@ -20,7 +20,7 @@ describe("gateway tool defaults", () => { expect(opts.url).toBeUndefined(); }); - it("passes through explicit overrides", async () => { + it("accepts allowlisted gatewayUrl overrides (SSRF hardening)", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); await callGatewayTool( "health", diff --git a/src/infra/outbound/message.e2e.test.ts b/src/infra/outbound/message.e2e.test.ts index fa2dfea6ef5..531671d893e 100644 --- a/src/infra/outbound/message.e2e.test.ts +++ b/src/infra/outbound/message.e2e.test.ts @@ -3,6 +3,7 @@ import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugi import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import { sendMessage, sendPoll } from "./message.js"; const setRegistry = (registry: ReturnType) => { @@ -172,6 +173,56 @@ describe("sendPoll channel normalization", () => { }); }); +describe("gateway url override hardening", () => { + beforeEach(() => { + callGatewayMock.mockReset(); + setRegistry(emptyRegistry); + }); + + afterEach(() => { + setRegistry(emptyRegistry); + }); + + it("drops gateway url overrides in backend mode (SSRF hardening)", async () => { + setRegistry( + createTestRegistry([ + { + pluginId: "mattermost", + source: "test", + plugin: { + ...createMattermostLikePlugin({ onSendText: () => {} }), + outbound: { deliveryMode: "gateway" }, + }, + }, + ]), + ); + + callGatewayMock.mockResolvedValueOnce({ messageId: "m1" }); + await sendMessage({ + cfg: {}, + to: "channel:town-square", + content: "hi", + channel: "mattermost", + gateway: { + url: "ws://169.254.169.254:80/latest/meta-data/", + token: "t", + timeoutMs: 5000, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + clientDisplayName: "agent", + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + }); + + expect(callGatewayMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: undefined, + token: "t", + timeoutMs: 5000, + }), + ); + }); +}); + const emptyRegistry = createTestRegistry([]); const createMSTeamsOutbound = (opts?: { includePoll?: boolean }): ChannelOutboundAdapter => ({ diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 6666ab4d249..93d105a331f 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -102,8 +102,15 @@ export type MessagePollResult = { }; function resolveGatewayOptions(opts?: MessageGatewayOptions) { + // Security: backend callers (tools/agents) must not accept user-controlled gateway URLs. + // Use config-derived gateway target only. + const url = + opts?.mode === GATEWAY_CLIENT_MODES.BACKEND || + opts?.clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT + ? undefined + : opts?.url; return { - url: opts?.url, + url, token: opts?.token, timeoutMs: typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)