mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 03:44:31 +00:00
fix(voice-call): block Twilio webhook replay and stale transitions
This commit is contained in:
@@ -30,6 +30,29 @@ export interface PlivoProviderOptions {
|
||||
type PendingSpeak = { text: string; locale?: string };
|
||||
type PendingListen = { language?: string };
|
||||
|
||||
function getHeader(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createPlivoRequestDedupeKey(ctx: WebhookContext): string {
|
||||
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
|
||||
if (nonceV3) {
|
||||
return `plivo:v3:${nonceV3}`;
|
||||
}
|
||||
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
|
||||
if (nonceV2) {
|
||||
return `plivo:v2:${nonceV2}`;
|
||||
}
|
||||
return `plivo:fallback:${crypto.createHash("sha256").update(ctx.rawBody).digest("hex")}`;
|
||||
}
|
||||
|
||||
export class PlivoProvider implements VoiceCallProvider {
|
||||
readonly name = "plivo" as const;
|
||||
|
||||
@@ -104,7 +127,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
console.warn(`[plivo] Webhook verification failed: ${result.reason}`);
|
||||
}
|
||||
|
||||
return { ok: result.ok, reason: result.reason };
|
||||
return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
|
||||
}
|
||||
|
||||
parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult {
|
||||
@@ -173,7 +196,8 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
|
||||
// Normal events.
|
||||
const callIdFromQuery = this.getCallIdFromQuery(ctx);
|
||||
const event = this.normalizeEvent(parsed, callIdFromQuery);
|
||||
const dedupeKey = createPlivoRequestDedupeKey(ctx);
|
||||
const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey);
|
||||
|
||||
return {
|
||||
events: event ? [event] : [],
|
||||
@@ -186,7 +210,11 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null {
|
||||
private normalizeEvent(
|
||||
params: URLSearchParams,
|
||||
callIdOverride?: string,
|
||||
dedupeKey?: string,
|
||||
): NormalizedEvent | null {
|
||||
const callUuid = params.get("CallUUID") || "";
|
||||
const requestUuid = params.get("RequestUUID") || "";
|
||||
|
||||
@@ -201,6 +229,7 @@ export class PlivoProvider implements VoiceCallProvider {
|
||||
|
||||
const baseEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
dedupeKey,
|
||||
callId: callIdOverride || callUuid || requestUuid,
|
||||
providerCallId: callUuid || requestUuid || undefined,
|
||||
timestamp: Date.now(),
|
||||
|
||||
@@ -59,4 +59,38 @@ describe("TwilioProvider", () => {
|
||||
expect(result.providerResponseBody).toContain('<Parameter name="token" value="');
|
||||
expect(result.providerResponseBody).toContain("<Connect>");
|
||||
});
|
||||
|
||||
it("uses a stable dedupeKey for identical request payloads", () => {
|
||||
const provider = createProvider();
|
||||
const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello";
|
||||
const ctxA = {
|
||||
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
||||
headers: { "i-twilio-idempotency-token": "idem-123" },
|
||||
};
|
||||
const ctxB = {
|
||||
...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }),
|
||||
headers: { "i-twilio-idempotency-token": "idem-123" },
|
||||
};
|
||||
|
||||
const eventA = provider.parseWebhookEvent(ctxA).events[0];
|
||||
const eventB = provider.parseWebhookEvent(ctxB).events[0];
|
||||
|
||||
expect(eventA).toBeDefined();
|
||||
expect(eventB).toBeDefined();
|
||||
expect(eventA?.id).not.toBe(eventB?.id);
|
||||
expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123");
|
||||
expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey);
|
||||
});
|
||||
|
||||
it("keeps turnToken from query on speech events", () => {
|
||||
const provider = createProvider();
|
||||
const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", {
|
||||
callId: "call-2",
|
||||
turnToken: "turn-xyz",
|
||||
});
|
||||
|
||||
const event = provider.parseWebhookEvent(ctx).events[0];
|
||||
expect(event?.type).toBe("call.speech");
|
||||
expect(event?.turnToken).toBe("turn-xyz");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,33 @@ import type { VoiceCallProvider } from "./base.js";
|
||||
import { twilioApiRequest } from "./twilio/api.js";
|
||||
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
|
||||
|
||||
function getHeader(
|
||||
headers: Record<string, string | string[] | undefined>,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const value = headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function createTwilioRequestDedupeKey(ctx: WebhookContext): string {
|
||||
const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token");
|
||||
if (idempotencyToken) {
|
||||
return `twilio:idempotency:${idempotencyToken}`;
|
||||
}
|
||||
|
||||
const signature = getHeader(ctx.headers, "x-twilio-signature") ?? "";
|
||||
const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : "";
|
||||
const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : "";
|
||||
const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : "";
|
||||
return `twilio:fallback:${crypto
|
||||
.createHash("sha256")
|
||||
.update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`)
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Twilio Voice API provider implementation.
|
||||
*
|
||||
@@ -212,7 +239,16 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
|
||||
? ctx.query.callId.trim()
|
||||
: undefined;
|
||||
const event = this.normalizeEvent(params, callIdFromQuery);
|
||||
const turnTokenFromQuery =
|
||||
typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim()
|
||||
? ctx.query.turnToken.trim()
|
||||
: undefined;
|
||||
const dedupeKey = createTwilioRequestDedupeKey(ctx);
|
||||
const event = this.normalizeEvent(params, {
|
||||
callIdOverride: callIdFromQuery,
|
||||
dedupeKey,
|
||||
turnToken: turnTokenFromQuery,
|
||||
});
|
||||
|
||||
// For Twilio, we must return TwiML. Most actions are driven by Calls API updates,
|
||||
// so the webhook response is typically a pause to keep the call alive.
|
||||
@@ -245,14 +281,24 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
/**
|
||||
* Convert Twilio webhook params to normalized event format.
|
||||
*/
|
||||
private normalizeEvent(params: URLSearchParams, callIdOverride?: string): NormalizedEvent | null {
|
||||
private normalizeEvent(
|
||||
params: URLSearchParams,
|
||||
options?: {
|
||||
callIdOverride?: string;
|
||||
dedupeKey?: string;
|
||||
turnToken?: string;
|
||||
},
|
||||
): NormalizedEvent | null {
|
||||
const callSid = params.get("CallSid") || "";
|
||||
const callIdOverride = options?.callIdOverride;
|
||||
|
||||
const baseEvent = {
|
||||
id: crypto.randomUUID(),
|
||||
dedupeKey: options?.dedupeKey,
|
||||
callId: callIdOverride || callSid,
|
||||
providerCallId: callSid,
|
||||
timestamp: Date.now(),
|
||||
turnToken: options?.turnToken,
|
||||
direction: TwilioProvider.parseDirection(params.get("Direction")),
|
||||
from: params.get("From") || undefined,
|
||||
to: params.get("To") || undefined,
|
||||
@@ -603,9 +649,14 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
throw new Error("Missing webhook URL for this call (provider state not initialized)");
|
||||
}
|
||||
|
||||
const actionUrl = new URL(webhookUrl);
|
||||
if (input.turnToken) {
|
||||
actionUrl.searchParams.set("turnToken", input.turnToken);
|
||||
}
|
||||
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(webhookUrl)}" method="POST">
|
||||
<Gather input="speech" speechTimeout="auto" language="${input.language || "en-US"}" action="${escapeXml(actionUrl.toString())}" method="POST">
|
||||
</Gather>
|
||||
</Response>`;
|
||||
|
||||
|
||||
@@ -28,5 +28,6 @@ export function verifyTwilioProviderWebhook(params: {
|
||||
return {
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
isReplay: result.isReplay,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user