diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a5608da..78e8e0f11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Docs: https://docs.clawd.bot - Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools - Tools: centralize plugin tool policy helpers. +### Fixes +- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864) + ## 2026.1.18-1 ### Changes diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 058a760d3..c31d7225a 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -2,7 +2,7 @@ import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; -import { verifyPlivoWebhook } from "./webhook-security.js"; +import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js"; function canonicalizeBase64(input: string): string { return Buffer.from(input, "base64").toString("base64"); @@ -71,6 +71,26 @@ function plivoV3Signature(params: { return canonicalizeBase64(digest); } +function twilioSignature(params: { + authToken: string; + url: string; + postBody: string; +}): string { + let dataToSign = params.url; + const sortedParams = Array.from( + new URLSearchParams(params.postBody).entries(), + ).sort((a, b) => a[0].localeCompare(b[0])); + + for (const [key, value] of sortedParams) { + dataToSign += key + value; + } + + return crypto + .createHmac("sha1", params.authToken) + .update(dataToSign) + .digest("base64"); +} + describe("verifyPlivoWebhook", () => { it("accepts valid V2 signature", () => { const authToken = "test-auth-token"; @@ -154,3 +174,35 @@ describe("verifyPlivoWebhook", () => { }); }); +describe("verifyTwilioWebhook", () => { + it("uses request query when publicUrl omits it", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; + + const signature = twilioSignature({ + authToken, + url: urlWithQuery, + postBody, + }); + + const result = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(result.ok).toBe(true); + }); +}); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 0459f3b1e..2471d8841 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -98,6 +98,25 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string { return `${proto}://${host}${path}`; } +function buildTwilioVerificationUrl( + ctx: WebhookContext, + publicUrl?: string, +): string { + if (!publicUrl) { + return reconstructWebhookUrl(ctx); + } + + try { + const base = new URL(publicUrl); + const requestUrl = new URL(ctx.url); + base.pathname = requestUrl.pathname; + base.search = requestUrl.search; + return base.toString(); + } catch { + return publicUrl; + } +} + /** * Get a header value, handling both string and string[] types. */ @@ -154,7 +173,7 @@ export function verifyTwilioWebhook( } // Reconstruct the URL Twilio used - const verificationUrl = options?.publicUrl || reconstructWebhookUrl(ctx); + const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl); // Parse the body as URL-encoded params const params = new URLSearchParams(ctx.rawBody);