mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 02:42:43 +00:00
fix(security): restrict tool gatewayUrl overrides
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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/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/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/Gateway: block `system.execApprovals.*` via `node.invoke` (use `exec.approvals.node.*` instead). Thanks @christos-eth.
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
|
import { callGatewayTool, resolveGatewayOptions } from "./gateway.js";
|
||||||
|
|
||||||
const callGatewayMock = vi.fn();
|
const callGatewayMock = vi.fn();
|
||||||
|
vi.mock("../../config/config.js", () => ({
|
||||||
|
loadConfig: () => ({}),
|
||||||
|
resolveGatewayPort: () => 18789,
|
||||||
|
}));
|
||||||
vi.mock("../../gateway/call.js", () => ({
|
vi.mock("../../gateway/call.js", () => ({
|
||||||
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
||||||
}));
|
}));
|
||||||
@@ -20,15 +24,24 @@ describe("gateway tool defaults", () => {
|
|||||||
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
callGatewayMock.mockResolvedValueOnce({ ok: true });
|
||||||
await callGatewayTool(
|
await callGatewayTool(
|
||||||
"health",
|
"health",
|
||||||
{ gatewayUrl: "ws://example", gatewayToken: "t", timeoutMs: 5000 },
|
{ gatewayUrl: "ws://127.0.0.1:18789", gatewayToken: "t", timeoutMs: 5000 },
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
expect(callGatewayMock).toHaveBeenCalledWith(
|
expect(callGatewayMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
url: "ws://example",
|
url: "ws://127.0.0.1:18789",
|
||||||
token: "t",
|
token: "t",
|
||||||
timeoutMs: 5000,
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { loadConfig, resolveGatewayPort } from "../../config/config.js";
|
||||||
import { callGateway } from "../../gateway/call.js";
|
import { callGateway } from "../../gateway/call.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
|
||||||
|
|
||||||
@@ -9,11 +10,77 @@ export type GatewayCallOptions = {
|
|||||||
timeoutMs?: number;
|
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<string>([
|
||||||
|
`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) {
|
export function resolveGatewayOptions(opts?: GatewayCallOptions) {
|
||||||
// Prefer an explicit override; otherwise let callGateway choose based on config.
|
// Prefer an explicit override; otherwise let callGateway choose based on config.
|
||||||
const url =
|
const url =
|
||||||
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
|
typeof opts?.gatewayUrl === "string" && opts.gatewayUrl.trim()
|
||||||
? opts.gatewayUrl.trim()
|
? validateGatewayUrlOverrideForAgentTools(opts.gatewayUrl)
|
||||||
: undefined;
|
: undefined;
|
||||||
const token =
|
const token =
|
||||||
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
|
typeof opts?.gatewayToken === "string" && opts.gatewayToken.trim()
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { resolveSessionAgentId } from "../agent-scope.js";
|
|||||||
import { listChannelSupportedActions } from "../channel-tools.js";
|
import { listChannelSupportedActions } from "../channel-tools.js";
|
||||||
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema/typebox.js";
|
||||||
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
|
||||||
|
import { resolveGatewayOptions } from "./gateway.js";
|
||||||
|
|
||||||
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
|
||||||
const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
|
const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
|
||||||
@@ -441,10 +442,15 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
|
|||||||
params.accountId = accountId;
|
params.accountId = accountId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gateway = {
|
const gatewayResolved = resolveGatewayOptions({
|
||||||
url: readStringParam(params, "gatewayUrl", { trim: false }),
|
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
|
||||||
token: readStringParam(params, "gatewayToken", { trim: false }),
|
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
|
||||||
timeoutMs: readNumberParam(params, "timeoutMs"),
|
timeoutMs: readNumberParam(params, "timeoutMs"),
|
||||||
|
});
|
||||||
|
const gateway = {
|
||||||
|
url: gatewayResolved.url,
|
||||||
|
token: gatewayResolved.token,
|
||||||
|
timeoutMs: gatewayResolved.timeoutMs,
|
||||||
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
clientName: GATEWAY_CLIENT_IDS.GATEWAY_CLIENT,
|
||||||
clientDisplayName: "agent",
|
clientDisplayName: "agent",
|
||||||
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
mode: GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
|
|||||||
Reference in New Issue
Block a user