mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-30 01:06:11 +00:00
fix(voice-call): harden webhook pre-auth guards
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user