fix: include query in Twilio webhook verification
This commit is contained in:
@@ -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
|
- 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.
|
- 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
|
## 2026.1.18-1
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { verifyPlivoWebhook } from "./webhook-security.js";
|
import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
|
||||||
|
|
||||||
function canonicalizeBase64(input: string): string {
|
function canonicalizeBase64(input: string): string {
|
||||||
return Buffer.from(input, "base64").toString("base64");
|
return Buffer.from(input, "base64").toString("base64");
|
||||||
@@ -71,6 +71,26 @@ function plivoV3Signature(params: {
|
|||||||
return canonicalizeBase64(digest);
|
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", () => {
|
describe("verifyPlivoWebhook", () => {
|
||||||
it("accepts valid V2 signature", () => {
|
it("accepts valid V2 signature", () => {
|
||||||
const authToken = "test-auth-token";
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
|
|||||||
return `${proto}://${host}${path}`;
|
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.
|
* Get a header value, handling both string and string[] types.
|
||||||
*/
|
*/
|
||||||
@@ -154,7 +173,7 @@ export function verifyTwilioWebhook(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct the URL Twilio used
|
// Reconstruct the URL Twilio used
|
||||||
const verificationUrl = options?.publicUrl || reconstructWebhookUrl(ctx);
|
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
|
||||||
|
|
||||||
// Parse the body as URL-encoded params
|
// Parse the body as URL-encoded params
|
||||||
const params = new URLSearchParams(ctx.rawBody);
|
const params = new URLSearchParams(ctx.rawBody);
|
||||||
|
|||||||
Reference in New Issue
Block a user