fix(security): prevent gatewayUrl SSRF

This commit is contained in:
Peter Steinberger
2026-02-14 20:53:30 +01:00
parent e95ce05c1e
commit c5406e1d24
4 changed files with 61 additions and 2 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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<typeof createTestRegistry>) => {
@@ -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 => ({

View File

@@ -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)