diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index a80af69b605..3134f18b729 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -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:/); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 75d1ca490d0..6267e21dfc0 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -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(); + 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");