diff --git a/CHANGELOG.md b/CHANGELOG.md index ee31be5203a..16155d9c587 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -307,6 +307,7 @@ Docs: https://docs.openclaw.ai - Gateway/usage: include reset and deleted archived session transcripts in usage totals, session discovery, and archived-only session detail fallback so the Usage view no longer undercounts rotated sessions. (#43215) Thanks @rcrick. - Config/env: remove legacy `CLAWDBOT_*` and `MOLTBOT_*` compatibility env names across runtime, installers, and test tooling. Use the matching `OPENCLAW_*` env names instead. - Security/exec approvals: treat `time` as a transparent dispatch wrapper during allowlist evaluation and allow-always persistence so approved `time ...` commands bind the inner executable instead of the wrapper path. Thanks @YLChen-007 for reporting. +- Voice-call/webhooks: reject missing provider signature headers before body reads, drop the pre-auth body budget to `64 KB` / `5s`, and cap concurrent pre-auth requests per source IP so unauthenticated callers cannot force the old `1 MB` / `30s` buffering path. Thanks @SEORY0 for reporting. ## 2026.3.13 diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 1a9af8e3e41..8dd48e9130d 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -183,6 +183,12 @@ requests are acknowledged but skipped for side effects. Twilio conversation turns include a per-turn token in `` callbacks, so stale/replayed speech callbacks cannot satisfy a newer pending transcript turn. +Unauthenticated webhook requests are rejected before body reads when the +provider's required signature headers are missing. + +The voice-call webhook uses the shared pre-auth body profile (64 KB / 5 seconds) +plus a per-IP in-flight cap before signature verification. + Example with a stable public host: ```json5 diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index f88383751c2..004ba68a22f 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -114,6 +114,23 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string, }); } +async function postWebhookFormWithHeaders( + server: VoiceCallWebhookServer, + baseUrl: string, + body: string, + headers: Record, +) { + const requestUrl = requireBoundRequestUrl(server, baseUrl); + return await fetch(requestUrl.toString(), { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + ...headers, + }, + body, + }); +} + describe("VoiceCallWebhookServer stale call reaper", () => { beforeEach(() => { vi.useFakeTimers(); @@ -301,6 +318,124 @@ describe("VoiceCallWebhookServer replay handling", () => { }); }); +describe("VoiceCallWebhookServer pre-auth webhook guards", () => { + it("rejects missing signature headers before reading the request body", async () => { + const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "twilio:req:test" })); + const twilioProvider: VoiceCallProvider = { + ...provider, + name: "twilio", + verifyWebhook, + }; + const { manager } = createManager([]); + const config = createConfig({ provider: "twilio" }); + const server = new VoiceCallWebhookServer(config, manager, twilioProvider); + const readBodySpy = vi.spyOn( + server as unknown as { + readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; + }, + "readBody", + ); + + try { + const baseUrl = await server.start(); + const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello"); + + expect(response.status).toBe(401); + expect(await response.text()).toBe("Unauthorized"); + expect(readBodySpy).not.toHaveBeenCalled(); + expect(verifyWebhook).not.toHaveBeenCalled(); + } finally { + readBodySpy.mockRestore(); + await server.stop(); + } + }); + + it("uses the shared pre-auth body cap before verification", async () => { + const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "twilio:req:test" })); + const twilioProvider: VoiceCallProvider = { + ...provider, + name: "twilio", + verifyWebhook, + }; + const { manager } = createManager([]); + const config = createConfig({ provider: "twilio" }); + const server = new VoiceCallWebhookServer(config, manager, twilioProvider); + + try { + const baseUrl = await server.start(); + const response = await postWebhookFormWithHeaders( + server, + baseUrl, + "CallSid=CA123&SpeechResult=".padEnd(70 * 1024, "a"), + { "x-twilio-signature": "sig" }, + ); + + expect(response.status).toBe(413); + expect(await response.text()).toBe("Payload Too Large"); + expect(verifyWebhook).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); + + it("limits concurrent pre-auth requests per source IP", async () => { + const twilioProvider: VoiceCallProvider = { + ...provider, + name: "twilio", + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "twilio:req:test" }), + }; + const { manager } = createManager([]); + const config = createConfig({ provider: "twilio" }); + const server = new VoiceCallWebhookServer(config, manager, twilioProvider); + + let enteredReads = 0; + let releaseReads!: () => void; + let unblockReadBodies!: () => void; + const enteredEightReads = new Promise((resolve) => { + releaseReads = resolve; + }); + const unblockReads = new Promise((resolve) => { + unblockReadBodies = resolve; + }); + const readBodySpy = vi.spyOn( + server as unknown as { + readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise; + }, + "readBody", + ); + readBodySpy.mockImplementation(async () => { + enteredReads += 1; + if (enteredReads === 8) { + releaseReads(); + } + await unblockReads; + return "CallSid=CA123&SpeechResult=hello"; + }); + + try { + const baseUrl = await server.start(); + const headers = { "x-twilio-signature": "sig" }; + const inFlightRequests = Array.from({ length: 8 }, () => + postWebhookFormWithHeaders(server, baseUrl, "CallSid=CA123", headers), + ); + await enteredEightReads; + + const rejected = await postWebhookFormWithHeaders(server, baseUrl, "CallSid=CA999", headers); + expect(rejected.status).toBe(429); + expect(await rejected.text()).toBe("Too Many Requests"); + + unblockReadBodies(); + + const settled = await Promise.all(inFlightRequests); + expect(settled.every((response) => response.status === 200)).toBe(true); + } finally { + unblockReadBodies(); + readBodySpy.mockRestore(); + await server.stop(); + } + }); +}); + describe("VoiceCallWebhookServer response normalization", () => { it("preserves explicit empty provider response bodies", async () => { const responseProvider: VoiceCallProvider = { diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 9855d810a07..4e20a00f441 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -1,5 +1,9 @@ import http from "node:http"; import { URL } from "node:url"; +import { + createWebhookInFlightLimiter, + WEBHOOK_BODY_READ_DEFAULTS, +} from "openclaw/plugin-sdk/webhook-ingress"; import { isRequestBodyLimitError, readRequestBodyWithLimit, @@ -7,6 +11,7 @@ import { } from "../api.js"; import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js"; import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js"; +import { getHeader } from "./http-headers.js"; import type { CallManager } from "./manager.js"; import type { MediaStreamConfig } from "./media-stream.js"; import { MediaStreamHandler } from "./media-stream.js"; @@ -16,10 +21,18 @@ import type { TwilioProvider } from "./providers/twilio.js"; import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js"; import { startStaleCallReaper } from "./webhook/stale-call-reaper.js"; -const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024; +const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes; +const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs; const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2000; const TRANSCRIPT_LOG_MAX_CHARS = 200; +type WebhookHeaderGateResult = + | { ok: true } + | { + ok: false; + reason: string; + }; + function sanitizeTranscriptForLog(value: string): string { const sanitized = value .replace(/[\u0000-\u001f\u007f]/g, " ") @@ -70,6 +83,7 @@ export class VoiceCallWebhookServer { private coreConfig: CoreConfig | null; private agentRuntime: CoreAgentDeps | null; private stopStaleCallReaper: (() => void) | null = null; + private readonly webhookInFlightLimiter = createWebhookInFlightLimiter(); /** Media stream handler for bidirectional audio (when streaming enabled) */ private mediaStreamHandler: MediaStreamHandler | null = null; @@ -350,6 +364,7 @@ export class VoiceCallWebhookServer { clearTimeout(timer); } this.pendingDisconnectHangups.clear(); + this.webhookInFlightLimiter.clear(); if (this.stopStaleCallReaper) { this.stopStaleCallReaper(); @@ -444,49 +459,100 @@ export class VoiceCallWebhookServer { return { statusCode: 405, body: "Method Not Allowed" }; } - let body = ""; + const headerGate = this.verifyPreAuthWebhookHeaders(req.headers); + if (!headerGate.ok) { + console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`); + return { statusCode: 401, body: "Unauthorized" }; + } + + const inFlightKey = req.socket.remoteAddress ?? ""; + if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) { + console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`); + return { statusCode: 429, body: "Too Many Requests" }; + } + try { - body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES); - } catch (err) { - if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { - return { statusCode: 413, body: "Payload Too Large" }; + let body = ""; + try { + body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS); + } catch (err) { + if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) { + return { statusCode: 413, body: "Payload Too Large" }; + } + if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { + return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }; + } + throw err; } - if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) { - return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }; + + const ctx: WebhookContext = { + headers: req.headers as Record, + rawBody: body, + url: url.toString(), + method: "POST", + query: Object.fromEntries(url.searchParams), + remoteAddress: req.socket.remoteAddress ?? undefined, + }; + + const verification = this.provider.verifyWebhook(ctx); + if (!verification.ok) { + console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`); + return { statusCode: 401, body: "Unauthorized" }; } - throw err; + if (!verification.verifiedRequestKey) { + console.warn("[voice-call] Webhook verification succeeded without request identity key"); + return { statusCode: 401, body: "Unauthorized" }; + } + + const parsed = this.provider.parseWebhookEvent(ctx, { + verifiedRequestKey: verification.verifiedRequestKey, + }); + + if (verification.isReplay) { + console.warn("[voice-call] Replay detected; skipping event side effects"); + } else { + this.processParsedEvents(parsed.events); + } + + return normalizeWebhookResponse(parsed); + } finally { + this.webhookInFlightLimiter.release(inFlightKey); } + } - const ctx: WebhookContext = { - headers: req.headers as Record, - rawBody: body, - url: url.toString(), - method: "POST", - query: Object.fromEntries(url.searchParams), - remoteAddress: req.socket.remoteAddress ?? undefined, - }; - - const verification = this.provider.verifyWebhook(ctx); - if (!verification.ok) { - console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`); - return { statusCode: 401, body: "Unauthorized" }; + private verifyPreAuthWebhookHeaders(headers: http.IncomingHttpHeaders): WebhookHeaderGateResult { + if (this.config.skipSignatureVerification) { + return { ok: true }; } - if (!verification.verifiedRequestKey) { - console.warn("[voice-call] Webhook verification succeeded without request identity key"); - return { statusCode: 401, body: "Unauthorized" }; + switch (this.provider.name) { + case "telnyx": { + const signature = getHeader(headers, "telnyx-signature-ed25519"); + const timestamp = getHeader(headers, "telnyx-timestamp"); + if (signature && timestamp) { + return { ok: true }; + } + return { ok: false, reason: "missing Telnyx signature or timestamp header" }; + } + case "twilio": + if (getHeader(headers, "x-twilio-signature")) { + return { ok: true }; + } + return { ok: false, reason: "missing X-Twilio-Signature header" }; + case "plivo": { + const hasV3 = + Boolean(getHeader(headers, "x-plivo-signature-v3")) && + Boolean(getHeader(headers, "x-plivo-signature-v3-nonce")); + const hasV2 = + Boolean(getHeader(headers, "x-plivo-signature-v2")) && + Boolean(getHeader(headers, "x-plivo-signature-v2-nonce")); + if (hasV3 || hasV2) { + return { ok: true }; + } + return { ok: false, reason: "missing Plivo signature headers" }; + } + default: + return { ok: true }; } - - const parsed = this.provider.parseWebhookEvent(ctx, { - verifiedRequestKey: verification.verifiedRequestKey, - }); - - if (verification.isReplay) { - console.warn("[voice-call] Replay detected; skipping event side effects"); - } else { - this.processParsedEvents(parsed.events); - } - - return normalizeWebhookResponse(parsed); } private processParsedEvents(events: NormalizedEvent[]): void { @@ -515,7 +581,7 @@ export class VoiceCallWebhookServer { private readBody( req: http.IncomingMessage, maxBytes: number, - timeoutMs = 30_000, + timeoutMs = WEBHOOK_BODY_TIMEOUT_MS, ): Promise { return readRequestBodyWithLimit(req, { maxBytes, timeoutMs }); }