mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 22:41:25 +00:00
fix(security): restrict tool gatewayUrl overrides
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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) {
|
||||
// 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()
|
||||
|
||||
@@ -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<ChannelMessageActionName>([
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user