From c8d458d13d7b1a30baacf3a8864d3e47866133be Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 16:50:04 -0400 Subject: [PATCH] fix(outbound): clamp message gateway timeouts --- src/infra/outbound/message-action-runner.ts | 20 +--------- .../outbound/message-gateway-options.test.ts | 22 +++++++++++ src/infra/outbound/message-gateway-options.ts | 33 ++++++++++++++++ src/infra/outbound/message.ts | 38 +++---------------- 4 files changed, 63 insertions(+), 50 deletions(-) create mode 100644 src/infra/outbound/message-gateway-options.test.ts create mode 100644 src/infra/outbound/message-gateway-options.ts diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index f94a3200d045..00aa58c629fb 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -41,8 +41,6 @@ import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citati import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, INTERNAL_MESSAGE_CHANNEL, type GatewayClientMode, type GatewayClientName, @@ -73,6 +71,7 @@ import { resolveAndApplyOutboundThreadId, } from "./message-action-threading.js"; import { maybeApplyTtsToMessageActionSendPayload } from "./message-action-tts.js"; +import { resolveOutboundMessageGatewayOptions } from "./message-gateway-options.js"; import type { MessagePollResult, MessageSendResult } from "./message.js"; import { applyCrossContextDecoration, @@ -187,22 +186,7 @@ export function getToolResult( } function resolveGatewayActionOptions(gateway?: MessageActionRunnerGateway) { - const url = - gateway?.mode === GATEWAY_CLIENT_MODES.BACKEND || - gateway?.clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT - ? undefined - : gateway?.url; - return { - url, - token: gateway?.token, - timeoutMs: - typeof gateway?.timeoutMs === "number" && Number.isFinite(gateway.timeoutMs) - ? Math.max(1, Math.floor(gateway.timeoutMs)) - : 10_000, - clientName: gateway?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, - clientDisplayName: gateway?.clientDisplayName, - mode: gateway?.mode ?? GATEWAY_CLIENT_MODES.CLI, - }; + return resolveOutboundMessageGatewayOptions(gateway); } async function callGatewayMessageAction(params: { diff --git a/src/infra/outbound/message-gateway-options.test.ts b/src/infra/outbound/message-gateway-options.test.ts new file mode 100644 index 000000000000..8d6360cdf53a --- /dev/null +++ b/src/infra/outbound/message-gateway-options.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { MAX_TIMER_TIMEOUT_MS } from "../../shared/number-coercion.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; +import { resolveOutboundMessageGatewayOptions } from "./message-gateway-options.js"; + +describe("resolveOutboundMessageGatewayOptions", () => { + it("clamps oversized gateway timeouts", () => { + expect( + resolveOutboundMessageGatewayOptions({ timeoutMs: Number.MAX_SAFE_INTEGER }).timeoutMs, + ).toBe(MAX_TIMER_TIMEOUT_MS); + }); + + it("drops caller-provided urls for backend gateway callers", () => { + expect( + resolveOutboundMessageGatewayOptions({ + url: "http://attacker.invalid", + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }).url, + ).toBeUndefined(); + }); +}); diff --git a/src/infra/outbound/message-gateway-options.ts b/src/infra/outbound/message-gateway-options.ts new file mode 100644 index 000000000000..24519ee24c44 --- /dev/null +++ b/src/infra/outbound/message-gateway-options.ts @@ -0,0 +1,33 @@ +import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + type GatewayClientMode, + type GatewayClientName, +} from "../../utils/message-channel.js"; + +export type OutboundMessageGatewayOptionsInput = { + url?: string; + token?: string; + timeoutMs?: number; + clientName?: GatewayClientName; + clientDisplayName?: string; + mode?: GatewayClientMode; +}; + +export function resolveOutboundMessageGatewayOptions(gateway?: OutboundMessageGatewayOptionsInput) { + const clientName = gateway?.clientName ?? GATEWAY_CLIENT_NAMES.CLI; + const mode = gateway?.mode ?? GATEWAY_CLIENT_MODES.CLI; + const url = + mode === GATEWAY_CLIENT_MODES.BACKEND || clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT + ? undefined + : gateway?.url; + return { + url, + token: gateway?.token, + timeoutMs: resolveTimerTimeoutMs(gateway?.timeoutMs, 10_000), + clientName, + clientDisplayName: gateway?.clientDisplayName, + mode, + }; +} diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 6332672f3311..b039f730fc15 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -5,12 +5,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { OutboundMediaAccess } from "../../media/load-options.js"; import type { PollInput } from "../../polls.js"; import { normalizePollInput } from "../../polls.js"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - type GatewayClientMode, - type GatewayClientName, -} from "../../utils/message-channel.js"; import { resolveOutboundChannelPlugin } from "./channel-resolution.js"; import { resolveMessageChannelSelection } from "./channel-selection.js"; import { @@ -20,6 +14,10 @@ import { type OutboundDeliveryQueuePolicy, type OutboundSendDeps, } from "./deliver.js"; +import { + resolveOutboundMessageGatewayOptions, + type OutboundMessageGatewayOptionsInput, +} from "./message-gateway-options.js"; import type { OutboundMirror } from "./mirror.js"; import { createOutboundPayloadPlan, @@ -44,14 +42,7 @@ function loadMessageGatewayRuntime() { return messageGatewayRuntimePromise; } -export type MessageGatewayOptions = { - url?: string; - token?: string; - timeoutMs?: number; - clientName?: GatewayClientName; - clientDisplayName?: string; - mode?: GatewayClientMode; -}; +export type MessageGatewayOptions = OutboundMessageGatewayOptionsInput; type MessageSendParams = { to: string; @@ -261,24 +252,7 @@ async function assertRequiredMessageSendDurability(params: { } 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, - token: opts?.token, - timeoutMs: - typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) - ? Math.max(1, Math.floor(opts.timeoutMs)) - : 10_000, - clientName: opts?.clientName ?? GATEWAY_CLIENT_NAMES.CLI, - clientDisplayName: opts?.clientDisplayName, - mode: opts?.mode ?? GATEWAY_CLIENT_MODES.CLI, - }; + return resolveOutboundMessageGatewayOptions(opts); } async function callMessageGateway(params: {