fix(voice-call): harden webhook pre-auth guards

This commit is contained in:
Peter Steinberger
2026-03-22 23:32:30 -07:00
parent 2467fa4c5b
commit 651dc7450b
4 changed files with 246 additions and 38 deletions

View File

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

View File

@@ -183,6 +183,12 @@ requests are acknowledged but skipped for side effects.
Twilio conversation turns include a per-turn token in `<Gather>` 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

View File

@@ -114,6 +114,23 @@ async function postWebhookForm(server: VoiceCallWebhookServer, baseUrl: string,
});
}
async function postWebhookFormWithHeaders(
server: VoiceCallWebhookServer,
baseUrl: string,
body: string,
headers: Record<string, string>,
) {
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<string>;
},
"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<void>((resolve) => {
releaseReads = resolve;
});
const unblockReads = new Promise<void>((resolve) => {
unblockReadBodies = resolve;
});
const readBodySpy = vi.spyOn(
server as unknown as {
readBody: (req: unknown, maxBytes: number, timeoutMs?: number) => Promise<string>;
},
"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 = {

View File

@@ -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<string, string | string[] | undefined>,
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<string, string | string[] | undefined>,
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<string> {
return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
}