fix: include query in Twilio webhook verification

This commit is contained in:
Peter Steinberger
2026-01-18 04:25:17 +00:00
parent 82e49af5a7
commit fa1079214b
3 changed files with 76 additions and 2 deletions

View File

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

View File

@@ -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);
});
});

View File

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