Skip to content

Commit 651dc74

Browse files
committed
fix(voice-call): harden webhook pre-auth guards
1 parent 2467fa4 commit 651dc74

4 files changed

Lines changed: 245 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ Docs: https://docs.openclaw.ai
307307
- 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.
308308
- Config/env: remove legacy `CLAWDBOT_*` and `MOLTBOT_*` compatibility env names across runtime, installers, and test tooling. Use the matching `OPENCLAW_*` env names instead.
309309
- 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.
310+
- 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.
310311

311312
## 2026.3.13
312313

docs/plugins/voice-call.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,12 @@ requests are acknowledged but skipped for side effects.
183183
Twilio conversation turns include a per-turn token in `<Gather>` callbacks, so
184184
stale/replayed speech callbacks cannot satisfy a newer pending transcript turn.
185185

186+
Unauthenticated webhook requests are rejected before body reads when the
187+
provider's required signature headers are missing.
188+
189+
The voice-call webhook uses the shared pre-auth body profile (64 KB / 5 seconds)
190+
plus a per-IP in-flight cap before signature verification.
191+
186192
Example with a stable public host:
187193

188194
```json5

extensions/voice-call/src/webhook.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,23 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string,
114114
});
115115
}
116116

117+
async function postWebhookFormWithHeaders(
118+
server: VoiceCallWebhookServer,
119+
baseUrl: string,
120+
body: string,
121+
headers: Record<string, string>,
122+
) {
123+
const requestUrl = requireBoundRequestUrl(server, baseUrl);
124+
return await fetch(requestUrl.toString(), {
125+
method: "POST",
126+
headers: {
127+
"content-type": "application/x-www-form-urlencoded",
128+
...headers,
129+
},
130+
body,
131+
});
132+
}
133+
117134
describe("VoiceCallWebhookServer stale call reaper", () => {
118135
beforeEach(() => {
119136
vi.useFakeTimers();
@@ -301,6 +318,124 @@ describe("VoiceCallWebhookServer replay handling", () => {
301318
});
302319
});
303320

321+
describe("VoiceCallWebhookServer pre-auth webhook guards", () => {
322+
it("rejects missing signature headers before reading the request body", async () => {
323+
const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "twilio:req:test" }));
324+
const twilioProvider: VoiceCallProvider = {
325+
...provider,
326+
name: "twilio",
327+
verifyWebhook,
328+
};
329+
const { manager } = createManager([]);
330+
const config = createConfig({ provider: "twilio" });
331+
const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
332+
const readBodySpy = vi.spyOn(
333+
server as unknown as {
334+
readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise<string>;
335+
},
336+
"readBody",
337+
);
338+
339+
try {
340+
const baseUrl = await server.start();
341+
const response = await postWebhookForm(server, baseUrl, "CallSid=CA123&SpeechResult=hello");
342+
343+
expect(response.status).toBe(401);
344+
expect(await response.text()).toBe("Unauthorized");
345+
expect(readBodySpy).not.toHaveBeenCalled();
346+
expect(verifyWebhook).not.toHaveBeenCalled();
347+
} finally {
348+
readBodySpy.mockRestore();
349+
await server.stop();
350+
}
351+
});
352+
353+
it("uses the shared pre-auth body cap before verification", async () => {
354+
const verifyWebhook = vi.fn(() => ({ ok: true, verifiedRequestKey: "twilio:req:test" }));
355+
const twilioProvider: VoiceCallProvider = {
356+
...provider,
357+
name: "twilio",
358+
verifyWebhook,
359+
};
360+
const { manager } = createManager([]);
361+
const config = createConfig({ provider: "twilio" });
362+
const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
363+
364+
try {
365+
const baseUrl = await server.start();
366+
const response = await postWebhookFormWithHeaders(
367+
server,
368+
baseUrl,
369+
"CallSid=CA123&SpeechResult=".padEnd(70 * 1024, "a"),
370+
{ "x-twilio-signature": "sig" },
371+
);
372+
373+
expect(response.status).toBe(413);
374+
expect(await response.text()).toBe("Payload Too Large");
375+
expect(verifyWebhook).not.toHaveBeenCalled();
376+
} finally {
377+
await server.stop();
378+
}
379+
});
380+
381+
it("limits concurrent pre-auth requests per source IP", async () => {
382+
const twilioProvider: VoiceCallProvider = {
383+
...provider,
384+
name: "twilio",
385+
verifyWebhook: () => ({ ok: true, verifiedRequestKey: "twilio:req:test" }),
386+
};
387+
const { manager } = createManager([]);
388+
const config = createConfig({ provider: "twilio" });
389+
const server = new VoiceCallWebhookServer(config, manager, twilioProvider);
390+
391+
let enteredReads = 0;
392+
let releaseReads!: () => void;
393+
let unblockReadBodies!: () => void;
394+
const enteredEightReads = new Promise<void>((resolve) => {
395+
releaseReads = resolve;
396+
});
397+
const unblockReads = new Promise<void>((resolve) => {
398+
unblockReadBodies = resolve;
399+
});
400+
const readBodySpy = vi.spyOn(
401+
server as unknown as {
402+
readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise<string>;
403+
},
404+
"readBody",
405+
);
406+
readBodySpy.mockImplementation(async () => {
407+
enteredReads += 1;
408+
if (enteredReads === 8) {
409+
releaseReads();
410+
}
411+
await unblockReads;
412+
return "CallSid=CA123&SpeechResult=hello";
413+
});
414+
415+
try {
416+
const baseUrl = await server.start();
417+
const headers = { "x-twilio-signature": "sig" };
418+
const inFlightRequests = Array.from({ length: 8 }, () =>
419+
postWebhookFormWithHeaders(server, baseUrl, "CallSid=CA123", headers),
420+
);
421+
await enteredEightReads;
422+
423+
const rejected = await postWebhookFormWithHeaders(server, baseUrl, "CallSid=CA999", headers);
424+
expect(rejected.status).toBe(429);
425+
expect(await rejected.text()).toBe("Too Many Requests");
426+
427+
unblockReadBodies();
428+
429+
const settled = await Promise.all(inFlightRequests);
430+
expect(settled.every((response) => response.status === 200)).toBe(true);
431+
} finally {
432+
unblockReadBodies();
433+
readBodySpy.mockRestore();
434+
await server.stop();
435+
}
436+
});
437+
});
438+
304439
describe("VoiceCallWebhookServer response normalization", () => {
305440
it("preserves explicit empty provider response bodies", async () => {
306441
const responseProvider: VoiceCallProvider = {

extensions/voice-call/src/webhook.ts

Lines changed: 103 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import http from "node:http";
22
import { URL } from "node:url";
3+
import {
4+
createWebhookInFlightLimiter,
5+
WEBHOOK_BODY_READ_DEFAULTS,
6+
} from "openclaw/plugin-sdk/webhook-ingress";
37
import {
48
isRequestBodyLimitError,
59
readRequestBodyWithLimit,
610
requestBodyErrorToText,
711
} from "../api.js";
812
import { normalizeVoiceCallConfig, type VoiceCallConfig } from "./config.js";
913
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
14+
import { getHeader } from "./http-headers.js";
1015
import type { CallManager } from "./manager.js";
1116
import type { MediaStreamConfig } from "./media-stream.js";
1217
import { MediaStreamHandler } from "./media-stream.js";
@@ -16,10 +21,18 @@ import type { TwilioProvider } from "./providers/twilio.js";
1621
import type { CallRecord, NormalizedEvent, WebhookContext } from "./types.js";
1722
import { startStaleCallReaper } from "./webhook/stale-call-reaper.js";
1823

19-
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
24+
const MAX_WEBHOOK_BODY_BYTES = WEBHOOK_BODY_READ_DEFAULTS.preAuth.maxBytes;
25+
const WEBHOOK_BODY_TIMEOUT_MS = WEBHOOK_BODY_READ_DEFAULTS.preAuth.timeoutMs;
2026
const STREAM_DISCONNECT_HANGUP_GRACE_MS = 2000;
2127
const TRANSCRIPT_LOG_MAX_CHARS = 200;
2228

29+
type WebhookHeaderGateResult =
30+
| { ok: true }
31+
| {
32+
ok: false;
33+
reason: string;
34+
};
35+
2336
function sanitizeTranscriptForLog(value: string): string {
2437
const sanitized = value
2538
.replace(/[\u0000-\u001f\u007f]/g, " ")
@@ -70,6 +83,7 @@ export class VoiceCallWebhookServer {
7083
private coreConfig: CoreConfig | null;
7184
private agentRuntime: CoreAgentDeps | null;
7285
private stopStaleCallReaper: (() => void) | null = null;
86+
private readonly webhookInFlightLimiter = createWebhookInFlightLimiter();
7387

7488
/** Media stream handler for bidirectional audio (when streaming enabled) */
7589
private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -350,6 +364,7 @@ export class VoiceCallWebhookServer {
350364
clearTimeout(timer);
351365
}
352366
this.pendingDisconnectHangups.clear();
367+
this.webhookInFlightLimiter.clear();
353368

354369
if (this.stopStaleCallReaper) {
355370
this.stopStaleCallReaper();
@@ -444,49 +459,100 @@ export class VoiceCallWebhookServer {
444459
return { statusCode: 405, body: "Method Not Allowed" };
445460
}
446461

447-
let body = "";
462+
const headerGate = this.verifyPreAuthWebhookHeaders(req.headers);
463+
if (!headerGate.ok) {
464+
console.warn(`[voice-call] Webhook rejected before body read: ${headerGate.reason}`);
465+
return { statusCode: 401, body: "Unauthorized" };
466+
}
467+
468+
const inFlightKey = req.socket.remoteAddress ?? "";
469+
if (!this.webhookInFlightLimiter.tryAcquire(inFlightKey)) {
470+
console.warn(`[voice-call] Webhook rejected before body read: too many in-flight requests`);
471+
return { statusCode: 429, body: "Too Many Requests" };
472+
}
473+
448474
try {
449-
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES);
450-
} catch (err) {
451-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
452-
return { statusCode: 413, body: "Payload Too Large" };
453-
}
454-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
455-
return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") };
475+
let body = "";
476+
try {
477+
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES, WEBHOOK_BODY_TIMEOUT_MS);
478+
} catch (err) {
479+
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
480+
return { statusCode: 413, body: "Payload Too Large" };
481+
}
482+
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
483+
return { statusCode: 408, body: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") };
484+
}
485+
throw err;
456486
}
457-
throw err;
458-
}
459487

460-
const ctx: WebhookContext = {
461-
headers: req.headers as Record<string, string | string[] | undefined>,
462-
rawBody: body,
463-
url: url.toString(),
464-
method: "POST",
465-
query: Object.fromEntries(url.searchParams),
466-
remoteAddress: req.socket.remoteAddress ?? undefined,
467-
};
488+
const ctx: WebhookContext = {
489+
headers: req.headers as Record<string, string | string[] | undefined>,
490+
rawBody: body,
491+
url: url.toString(),
492+
method: "POST",
493+
query: Object.fromEntries(url.searchParams),
494+
remoteAddress: req.socket.remoteAddress ?? undefined,
495+
};
468496

469-
const verification = this.provider.verifyWebhook(ctx);
470-
if (!verification.ok) {
471-
console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
472-
return { statusCode: 401, body: "Unauthorized" };
473-
}
474-
if (!verification.verifiedRequestKey) {
475-
console.warn("[voice-call] Webhook verification succeeded without request identity key");
476-
return { statusCode: 401, body: "Unauthorized" };
477-
}
497+
const verification = this.provider.verifyWebhook(ctx);
498+
if (!verification.ok) {
499+
console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
500+
return { statusCode: 401, body: "Unauthorized" };
501+
}
502+
if (!verification.verifiedRequestKey) {
503+
console.warn("[voice-call] Webhook verification succeeded without request identity key");
504+
return { statusCode: 401, body: "Unauthorized" };
505+
}
478506

479-
const parsed = this.provider.parseWebhookEvent(ctx, {
480-
verifiedRequestKey: verification.verifiedRequestKey,
481-
});
507+
const parsed = this.provider.parseWebhookEvent(ctx, {
508+
verifiedRequestKey: verification.verifiedRequestKey,
509+
});
510+
511+
if (verification.isReplay) {
512+
console.warn("[voice-call] Replay detected; skipping event side effects");
513+
} else {
514+
this.processParsedEvents(parsed.events);
515+
}
482516

483-
if (verification.isReplay) {
484-
console.warn("[voice-call] Replay detected; skipping event side effects");
485-
} else {
486-
this.processParsedEvents(parsed.events);
517+
return normalizeWebhookResponse(parsed);
518+
} finally {
519+
this.webhookInFlightLimiter.release(inFlightKey);
487520
}
521+
}
488522

489-
return normalizeWebhookResponse(parsed);
523+
private verifyPreAuthWebhookHeaders(headers: http.IncomingHttpHeaders): WebhookHeaderGateResult {
524+
if (this.config.skipSignatureVerification) {
525+
return { ok: true };
526+
}
527+
switch (this.provider.name) {
528+
case "telnyx": {
529+
const signature = getHeader(headers, "telnyx-signature-ed25519");
530+
const timestamp = getHeader(headers, "telnyx-timestamp");
531+
if (signature && timestamp) {
532+
return { ok: true };
533+
}
534+
return { ok: false, reason: "missing Telnyx signature or timestamp header" };
535+
}
536+
case "twilio":
537+
if (getHeader(headers, "x-twilio-signature")) {
538+
return { ok: true };
539+
}
540+
return { ok: false, reason: "missing X-Twilio-Signature header" };
541+
case "plivo": {
542+
const hasV3 =
543+
Boolean(getHeader(headers, "x-plivo-signature-v3")) &&
544+
Boolean(getHeader(headers, "x-plivo-signature-v3-nonce"));
545+
const hasV2 =
546+
Boolean(getHeader(headers, "x-plivo-signature-v2")) &&
547+
Boolean(getHeader(headers, "x-plivo-signature-v2-nonce"));
548+
if (hasV3 || hasV2) {
549+
return { ok: true };
550+
}
551+
return { ok: false, reason: "missing Plivo signature headers" };
552+
}
553+
default:
554+
return { ok: true };
555+
}
490556
}
491557

492558
private processParsedEvents(events: NormalizedEvent[]): void {
@@ -515,7 +581,7 @@ export class VoiceCallWebhookServer {
515581
private readBody(
516582
req: http.IncomingMessage,
517583
maxBytes: number,
518-
timeoutMs = 30_000,
584+
timeoutMs = WEBHOOK_BODY_TIMEOUT_MS,
519585
): Promise<string> {
520586
return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
521587
}

0 commit comments

Comments
 (0)