mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
committed by
Peter Steinberger
parent
381bb867ac
commit
c5ddba52d7
@@ -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:/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user