fix(voice-call): retry Twilio signature verification without port in URL

Twilio signs webhook requests using the URL without the port component,
even when the publicUrl config includes a non-standard port. Add a fallback
that strips the port from the verification URL when initial validation fails,
matching the behavior of Twilio's official helper library.

Closes #6334
This commit is contained in:
drvoss
2026-02-24 15:25:54 +09:00
committed by Peter Steinberger
parent 381bb867ac
commit c5ddba52d7
2 changed files with 102 additions and 1 deletions

View File

@@ -605,7 +605,6 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(false);
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("returns a stable request key when verification is skipped", () => {
const ctx = {
headers: {},
@@ -621,4 +620,32 @@ describe("verifyTwilioWebhook", () => {
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
expect(second.isReplay).toBe(true);
});
it("succeeds when Twilio signs URL without port but server URL has port", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
// Twilio signs using URL without port.
const urlWithPort = "https://example.com:8443/voice/webhook";
const signedUrl = "https://example.com/voice/webhook";
const signature = twilioSignature({ authToken, url: signedUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "example.com:8443",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: urlWithPort,
method: "POST",
},
authToken,
{ publicUrl: urlWithPort },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(signedUrl);
expect(result.verifiedRequestKey).toMatch(/^twilio:req:/);
});
});

View File

@@ -379,6 +379,41 @@ function isLoopbackAddress(address?: string): boolean {
return false;
}
function stripPortFromUrl(url: string): string {
try {
const parsed = new URL(url);
if (!parsed.port) {
return url;
}
parsed.port = "";
return parsed.toString();
} catch {
return url;
}
}
function setPortOnUrl(url: string, port: string): string {
try {
const parsed = new URL(url);
parsed.port = port;
return parsed.toString();
} catch {
return url;
}
}
function extractPortFromHostHeader(hostHeader?: string): string | undefined {
if (!hostHeader) {
return undefined;
}
try {
const parsed = new URL(`https://${hostHeader}`);
return parsed.port || undefined;
} catch {
return undefined;
}
}
/**
* Result of Twilio webhook verification with detailed info.
*/
@@ -609,6 +644,45 @@ export function verifyTwilioWebhook(
return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
}
// Twilio webhook signatures can differ in whether port is included.
// Retry a small, deterministic set of URL variants before failing closed.
const variants = new Set<string>();
variants.add(verificationUrl);
variants.add(stripPortFromUrl(verificationUrl));
if (options?.publicUrl) {
try {
const publicPort = new URL(options.publicUrl).port;
if (publicPort) {
variants.add(setPortOnUrl(verificationUrl, publicPort));
}
} catch {
// ignore invalid publicUrl; primary verification already used best effort
}
}
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
if (hostHeaderPort) {
variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
}
for (const candidateUrl of variants) {
if (candidateUrl === verificationUrl) {
continue;
}
const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
if (!isValidCandidate) {
continue;
}
const replayKey = createTwilioReplayKey({
verificationUrl: candidateUrl,
signature,
requestParams: params,
});
const isReplay = markReplay(twilioReplayCache, replayKey);
return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
}
// Check if this is ngrok free tier - the URL might have different format
const isNgrokFreeTier =
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");