fix: harden voice-call webhooks (#1213) (thanks @andrew-kurin)
This commit is contained in:
parent
0e1ebc7da1
commit
ee2cfa5ca1
@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot
|
||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
||||
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
|
||||
|
||||
## 2026.1.18-5
|
||||
|
||||
|
||||
@ -64,17 +64,21 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
|
||||
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
|
||||
private readonly twimlStorage = new Map<string, string>();
|
||||
/** Track notify-mode calls to avoid streaming on follow-up callbacks */
|
||||
/** Track notify-mode calls to avoid streaming on follow-up callbacks. */
|
||||
private readonly notifyCalls = new Set<string>();
|
||||
|
||||
/**
|
||||
* Delete stored TwiML for a given `callId`.
|
||||
*
|
||||
* We keep TwiML in-memory only long enough to satisfy the initial Twilio
|
||||
* webhook request (notify mode). Subsequent webhooks should not reuse it.
|
||||
* webhook request (notify mode). The notify guard is cleared separately.
|
||||
*/
|
||||
private deleteStoredTwiml(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
}
|
||||
|
||||
private clearNotifyState(callId: string): void {
|
||||
this.twimlStorage.delete(callId);
|
||||
this.notifyCalls.delete(callId);
|
||||
}
|
||||
|
||||
@ -90,7 +94,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
const callIdMatch = webhookUrl.match(/callId=([^&]+)/);
|
||||
if (!callIdMatch) return;
|
||||
|
||||
this.deleteStoredTwiml(callIdMatch[1]);
|
||||
this.clearNotifyState(callIdMatch[1]);
|
||||
}
|
||||
|
||||
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
|
||||
@ -260,12 +264,12 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
case "no-answer":
|
||||
case "failed":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: callStatus };
|
||||
case "canceled":
|
||||
if (callIdOverride) {
|
||||
this.deleteStoredTwiml(callIdOverride);
|
||||
this.clearNotifyState(callIdOverride);
|
||||
}
|
||||
return { ...baseEvent, type: "call.ended", reason: "hangup-bot" };
|
||||
default:
|
||||
@ -399,6 +403,7 @@ export class TwilioProvider implements VoiceCallProvider {
|
||||
// We now serve it from the webhook endpoint instead of sending inline
|
||||
if (input.inlineTwiml) {
|
||||
this.twimlStorage.set(input.callId, input.inlineTwiml);
|
||||
// Keep notify-mode calls out of streaming TwiML for any follow-up callbacks.
|
||||
this.notifyCalls.add(input.callId);
|
||||
}
|
||||
|
||||
|
||||
39
extensions/voice-call/src/providers/twilio/api.test.ts
Normal file
39
extensions/voice-call/src/providers/twilio/api.test.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { twilioApiRequest } from "./api.js";
|
||||
|
||||
describe("twilioApiRequest", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("encodes array params as repeated form fields", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => "",
|
||||
});
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
await twilioApiRequest({
|
||||
baseUrl: "https://api.example.com",
|
||||
accountSid: "AC123",
|
||||
authToken: "token",
|
||||
endpoint: "/Calls.json",
|
||||
body: {
|
||||
To: "+15555550123",
|
||||
StatusCallbackEvent: ["initiated", "completed"],
|
||||
},
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] ?? [];
|
||||
const body = init?.body as URLSearchParams | undefined;
|
||||
|
||||
expect(body?.getAll("StatusCallbackEvent")).toEqual([
|
||||
"initiated",
|
||||
"completed",
|
||||
]);
|
||||
expect(body?.get("To")).toBe("+15555550123");
|
||||
});
|
||||
});
|
||||
@ -29,6 +29,7 @@ export async function twilioApiRequest<T = unknown>(params: {
|
||||
Authorization: `Basic ${Buffer.from(`${params.accountSid}:${params.authToken}`).toString("base64")}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
// URL-encoded body required by Twilio, with repeated keys for arrays.
|
||||
body: bodyParams,
|
||||
});
|
||||
|
||||
|
||||
@ -231,7 +231,8 @@ export async function startTailscaleTunnel(config: {
|
||||
}
|
||||
|
||||
const path = config.path.startsWith("/") ? config.path : `/${config.path}`;
|
||||
const localUrl = `http://127.0.0.1:${config.port}${path}`;
|
||||
// --set-path already mounts the path; keep the target base URL to avoid /path/path.
|
||||
const localUrl = `http://127.0.0.1:${config.port}`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
|
||||
@ -79,7 +79,7 @@ function twilioSignature(params: {
|
||||
let dataToSign = params.url;
|
||||
const sortedParams = Array.from(
|
||||
new URLSearchParams(params.postBody).entries(),
|
||||
).sort((a, b) => a[0].localeCompare(b[0]));
|
||||
).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||||
|
||||
for (const [key, value] of sortedParams) {
|
||||
dataToSign += key + value;
|
||||
@ -205,4 +205,33 @@ describe("verifyTwilioWebhook", () => {
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("sorts params using ASCII order", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const publicUrl = "https://example.com/voice/webhook";
|
||||
const postBody = "Z=first&a=second";
|
||||
|
||||
const signature = twilioSignature({
|
||||
authToken,
|
||||
url: publicUrl,
|
||||
postBody,
|
||||
});
|
||||
|
||||
const result = verifyTwilioWebhook(
|
||||
{
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
"x-twilio-signature": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook",
|
||||
method: "POST",
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -23,7 +23,7 @@ export function validateTwilioSignature(
|
||||
// Build the string to sign: URL + sorted params (key+value pairs)
|
||||
let dataToSign = url;
|
||||
|
||||
// Sort params alphabetically and append key+value
|
||||
// Sort params by ASCII order (Twilio spec) and append key+value.
|
||||
const sortedParams = Array.from(params.entries()).sort((a, b) =>
|
||||
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
||||
);
|
||||
|
||||
@ -553,7 +553,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
}
|
||||
|
||||
export function buildRuntimeLine(
|
||||
runtimeInfo: {
|
||||
runtimeInfo?: {
|
||||
agentId?: string;
|
||||
host?: string;
|
||||
os?: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user