fix(security): restrict tool gatewayUrl overrides

This commit is contained in:
Peter Steinberger
2026-02-14 21:36:02 +01:00
parent 07850e8a93
commit 2d5647a804
4 changed files with 95 additions and 7 deletions

View File

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

View File

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

View File

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