// Voice Call tests cover webhook security plugin behavior. import crypto from "node:crypto"; import { MAX_DATE_TIMESTAMP_MS } from "openclaw/plugin-sdk/number-runtime"; import { describe, expect, it, vi } from "vitest"; import { reconstructWebhookUrl, verifyPlivoWebhook, verifyTelnyxWebhook, verifyTwilioWebhook, } from "./webhook-security.js"; function canonicalizeBase64(input: string): string { return Buffer.from(input, "base64").toString("base64"); } function plivoV2Signature(params: { authToken: string; urlNoQuery: string; nonce: string; }): string { const digest = crypto .createHmac("sha256", params.authToken) .update(params.urlNoQuery + params.nonce) .digest("base64"); return canonicalizeBase64(digest); } function plivoV3Signature(params: { authToken: string; urlWithQuery: string; postBody: string; nonce: string; }): string { const u = new URL(params.urlWithQuery); const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`; const queryPairs: Array<[string, string]> = []; for (const [k, v] of u.searchParams.entries()) { queryPairs.push([k, v]); } const queryMap = new Map(); for (const [k, v] of queryPairs) { queryMap.set(k, (queryMap.get(k) ?? []).concat(v)); } const sortedQuery = Array.from(queryMap.keys()) .toSorted() .flatMap((k) => [...(queryMap.get(k) ?? [])].toSorted().map((v) => `${k}=${v}`)) .join("&"); const postParams = new URLSearchParams(params.postBody); const postMap = new Map(); for (const [k, v] of postParams.entries()) { postMap.set(k, (postMap.get(k) ?? []).concat(v)); } const sortedPost = Array.from(postMap.keys()) .toSorted() .flatMap((k) => [...(postMap.get(k) ?? [])].toSorted().map((v) => `${k}${v}`)) .join(""); const hasPost = sortedPost.length > 0; let baseUrl = baseNoQuery; if (sortedQuery.length > 0 || hasPost) { baseUrl = `${baseNoQuery}?${sortedQuery}`; } if (sortedQuery.length > 0 && hasPost) { baseUrl = `${baseUrl}.`; } baseUrl = `${baseUrl}${sortedPost}`; const digest = crypto .createHmac("sha256", params.authToken) .update(`${baseUrl}.${params.nonce}`) .digest("base64"); 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()).toSorted((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"); } function expectReplayResultPair( first: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string; releaseReplay?: () => void; }, second: { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string; releaseReplay?: () => void; }, ) { expect(first.ok).toBe(true); expect(first.isReplay).not.toBe(true); if (!first.verifiedRequestKey) { throw new Error("verified webhook request did not produce a request key"); } expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(first.releaseReplay).toEqual(expect.any(Function)); expect(second.releaseReplay).toBeUndefined(); } function expectAcceptedWebhookVersion( result: { ok: boolean; version?: string }, version: "v2" | "v3", ) { expect(result.ok).toBe(true); expect(result.version).toBe(version); } function verifyTwilioNgrokLoopback(signature: string) { return verifyTwilioWebhook( { headers: { host: "127.0.0.1:3334", "x-forwarded-proto": "https", "x-forwarded-host": "local.ngrok-free.app", "x-twilio-signature": signature, }, rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000", url: "http://127.0.0.1:3334/voice/webhook", method: "POST", remoteAddress: "127.0.0.1", }, "test-auth-token", { allowNgrokFreeTierLoopbackBypass: true }, ); } function verifyTwilioSignedRequest(params: { headers: Record; rawBody: string; authToken: string; publicUrl: string; }) { return verifyTwilioWebhook( { headers: params.headers, rawBody: params.rawBody, url: "http://local/voice/webhook?callId=abc", method: "POST", query: { callId: "abc" }, }, params.authToken, { publicUrl: params.publicUrl }, ); } function createSignedTelnyxWebhookRequest(options?: { timestamp?: string }) { const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }); const timestamp = options?.timestamp ?? String(Math.floor(Date.now() / 1000)); const rawBody = JSON.stringify({ data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } }, nonce: crypto.randomUUID(), }); const signedPayload = `${timestamp}|${rawBody}`; const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); return { pemPublicKey, timestamp, rawBody, signature, makeCtx(signatureValue = signature) { return { headers: { "telnyx-signature-ed25519": signatureValue, "telnyx-timestamp": timestamp, }, rawBody, url: "https://example.com/voice/webhook", method: "POST" as const, }; }, }; } const skipVerificationRequestKeyCases: Array<{ name: string; prefix: RegExp; verify: () => { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }; }> = [ { name: "Plivo", prefix: /^plivo:skip:/, verify: () => verifyPlivoWebhook( { headers: {}, rawBody: "CallUUID=uuid&CallStatus=in-progress", url: "https://example.com/voice/webhook", method: "POST" as const, }, "token", { skipVerification: true }, ), }, { name: "Telnyx", prefix: /^telnyx:skip:/, verify: () => verifyTelnyxWebhook( { headers: {}, rawBody: JSON.stringify({ data: { event_type: "call.initiated" } }), url: "https://example.com/voice/webhook", method: "POST" as const, }, undefined, { skipVerification: true }, ), }, { name: "Twilio", prefix: /^twilio:skip:/, verify: () => verifyTwilioWebhook( { headers: {}, rawBody: "CallSid=CS123&CallStatus=completed", url: "https://example.com/voice/webhook", method: "POST" as const, }, "token", { skipVerification: true }, ), }, ]; describe("skip verification request keys", () => { it.each(skipVerificationRequestKeyCases)( "$name returns a stable request key when verification is skipped", ({ prefix, verify }) => { const first = verify(); const second = verify(); expect(first.ok).toBe(true); expect(first.verifiedRequestKey).toMatch(prefix); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(second.isReplay).toBe(true); }, ); it("does not keep replay keys whose expiry would exceed the Date range", () => { const dateNow = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS); const verify = () => verifyTwilioWebhook( { headers: {}, rawBody: "CallSid=CS-overflow&CallStatus=completed", url: "https://example.com/voice/webhook", method: "POST" as const, }, "token", { skipVerification: true }, ); try { const first = verify(); expect(first.ok).toBe(true); expect(first.isReplay).not.toBe(true); dateNow.mockReturnValue(Date.parse("2026-05-29T12:00:00.000Z")); const second = verify(); expect(second.ok).toBe(true); expect(second.isReplay).not.toBe(true); } finally { dateNow.mockRestore(); } }); }); const verifiedReplayRequestCases: Array<{ name: string; verifyPair: () => [ { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }, { ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }, ]; }> = [ { name: "Telnyx", verifyPair: () => { const request = createSignedTelnyxWebhookRequest(); return [ verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey), verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey), ]; }, }, { name: "Twilio", verifyPair: () => { const authToken = "test-auth-token"; const publicUrl = "https://example.com/voice/webhook"; const urlWithQuery = `${publicUrl}?callId=abc`; const postBody = "CallSid=CS777&CallStatus=completed&From=%2B15550000000"; const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); const headers = { host: "example.com", "x-forwarded-proto": "https", "x-twilio-signature": signature, "i-twilio-idempotency-token": "idem-replay-1", }; return [ verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl }), verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl }), ]; }, }, ]; describe("verified webhook replay detection", () => { it.each(verifiedReplayRequestCases)( "$name marks replayed valid requests as replay without failing auth", ({ verifyPair }) => { const [first, second] = verifyPair(); expectReplayResultPair(first, second); }, ); }); describe("verifyPlivoWebhook", () => { it("accepts valid V2 signature", () => { const authToken = "test-auth-token"; const nonce = "nonce-123"; const ctxUrl = "http://local/voice/webhook?flow=answer&callId=abc"; const verificationUrl = "https://example.com/voice/webhook"; const signature = plivoV2Signature({ authToken, urlNoQuery: verificationUrl, nonce, }); const result = verifyPlivoWebhook( { headers: { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v2": signature, "x-plivo-signature-v2-nonce": nonce, }, rawBody: "CallUUID=uuid&CallStatus=in-progress", url: ctxUrl, method: "POST", query: { flow: "answer", callId: "abc" }, }, authToken, ); expectAcceptedWebhookVersion(result, "v2"); }); it("accepts a V3 signature from a canonically equivalent allowed IPv6 proxy host", () => { const authToken = "test-ipv6-auth-token"; const nonce = "nonce-ipv6-v3"; const postBody = "CallUUID=ipv6-uuid&CallStatus=in-progress"; const webhookUrl = "https://[2001:db8::1]/voice/webhook?flow=answer&callId=ipv6"; const signature = plivoV3Signature({ authToken, urlWithQuery: webhookUrl, postBody, nonce, }); const result = verifyPlivoWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "[2001:db8::1]:8443", "x-plivo-signature-v3": signature, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook?flow=answer&callId=ipv6", method: "POST", query: { flow: "answer", callId: "ipv6" }, }, authToken, { allowedHosts: ["[2001:0db8:0000:0000:0000:0000:0000:0001]"], }, ); expectAcceptedWebhookVersion(result, "v3"); expect(result.verificationUrl).toBe(webhookUrl); }); it("accepts valid V3 signature (including multi-signature header)", () => { const authToken = "test-auth-token"; const nonce = "nonce-456"; const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc"; const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; const good = plivoV3Signature({ authToken, urlWithQuery, postBody, nonce, }); const result = verifyPlivoWebhook( { headers: { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v3": `bad, ${good}`, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: urlWithQuery, method: "POST", query: { flow: "answer", callId: "abc" }, }, authToken, ); expectAcceptedWebhookVersion(result, "v3"); }); it("pins Plivo publicUrl verification to the configured path", () => { const authToken = "test-auth-token"; const nonce = "nonce-public-url-path"; const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; const attackerPathUrl = "https://voice.openclaw.ai/admin?flow=answer&callId=abc"; const signature = plivoV3Signature({ authToken, urlWithQuery: attackerPathUrl, postBody, nonce, }); const result = verifyPlivoWebhook( { headers: { host: "voice.openclaw.ai", "x-forwarded-proto": "https", "x-plivo-signature-v3": signature, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: attackerPathUrl, method: "POST", query: { flow: "answer", callId: "abc" }, }, authToken, { publicUrl: "https://voice.openclaw.ai/voice/webhook?provider=plivo" }, ); expect(result.ok).toBe(false); expect(result.version).toBe("v3"); expect(result.verificationUrl).toBe( "https://voice.openclaw.ai/voice/webhook?flow=answer&callId=abc", ); }); it("matches trusted proxies for Plivo when Node reports an IPv4-mapped remote address", () => { const authToken = "test-auth-token"; const nonce = "nonce-ipv4-mapped-plivo"; const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; const webhookUrl = "https://proxy.example.com/voice/webhook?flow=answer&callId=abc"; const signature = plivoV3Signature({ authToken, urlWithQuery: webhookUrl, postBody, nonce, }); const result = verifyPlivoWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "proxy.example.com", "x-plivo-signature-v3": signature, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook?flow=answer&callId=abc", method: "POST", query: { flow: "answer", callId: "abc" }, remoteAddress: "::ffff:127.0.0.1", }, authToken, { trustForwardingHeaders: true, trustedProxyIPs: ["127.0.0.1"] }, ); expectAcceptedWebhookVersion(result, "v3"); expect(result.verificationUrl).toBe(webhookUrl); }); it("rejects missing signatures", () => { const result = verifyPlivoWebhook( { headers: { host: "example.com", "x-forwarded-proto": "https" }, rawBody: "", url: "https://example.com/voice/webhook", method: "POST", }, "token", ); expect(result.ok).toBe(false); expect(result.reason).toMatch(/Missing Plivo signature headers/); }); it("marks replayed valid V3 requests as replay without failing auth", () => { const authToken = "test-auth-token"; const nonce = "nonce-replay-v3"; const urlWithQuery = "https://example.com/voice/webhook?flow=answer&callId=abc"; const postBody = "CallUUID=uuid&CallStatus=in-progress&From=%2B15550000000"; const signature = plivoV3Signature({ authToken, urlWithQuery, postBody, nonce, }); const ctx = { headers: { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v3": signature, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: urlWithQuery, method: "POST" as const, query: { flow: "answer", callId: "abc" }, }; const first = verifyPlivoWebhook(ctx, authToken); const second = verifyPlivoWebhook(ctx, authToken); expectReplayResultPair(first, second); }); it("treats query-only V2 variants as the same verified request", () => { const authToken = "test-auth-token"; const nonce = "nonce-replay-v2"; const verificationUrl = "https://example.com/voice/webhook"; const signature = plivoV2Signature({ authToken, urlNoQuery: verificationUrl, nonce, }); const baseHeaders = { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v2": signature, "x-plivo-signature-v2-nonce": nonce, }; const rawBody = "CallUUID=uuid&CallStatus=in-progress"; const first = verifyPlivoWebhook( { headers: baseHeaders, rawBody, url: `${verificationUrl}?flow=answer&callId=abc`, method: "POST", query: { flow: "answer", callId: "abc" }, }, authToken, ); const second = verifyPlivoWebhook( { headers: baseHeaders, rawBody, url: `${verificationUrl}?flow=getinput&callId=abc`, method: "POST", query: { flow: "getinput", callId: "abc" }, }, authToken, ); expect(first.ok).toBe(true); expect(first.verifiedRequestKey).toBeTypeOf("string"); expect(first.verifiedRequestKey).not.toBe(""); expect(second.ok).toBe(true); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(second.isReplay).toBe(true); }); it("detects V3 replay when query parameters are reordered", () => { const authToken = "test-auth-token"; const nonce = "nonce-v3-reorder"; const postBody = "CallUUID=uuid&CallStatus=in-progress"; const urlA = "https://example.com/voice/webhook?flow=answer&callId=abc"; const urlB = "https://example.com/voice/webhook?callId=abc&flow=answer"; const signatureA = plivoV3Signature({ authToken, urlWithQuery: urlA, postBody, nonce }); const signatureB = plivoV3Signature({ authToken, urlWithQuery: urlB, postBody, nonce }); expect(signatureA).toBe(signatureB); const first = verifyPlivoWebhook( { headers: { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v3": signatureA, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: urlA, method: "POST", query: { flow: "answer", callId: "abc" }, }, authToken, ); const second = verifyPlivoWebhook( { headers: { host: "example.com", "x-forwarded-proto": "https", "x-plivo-signature-v3": signatureB, "x-plivo-signature-v3-nonce": nonce, }, rawBody: postBody, url: urlB, method: "POST", query: { callId: "abc", flow: "answer" }, }, authToken, ); expectReplayResultPair(first, second); }); }); describe("verifyTelnyxWebhook", () => { it("rejects signed timestamps with trailing characters", () => { const request = createSignedTelnyxWebhookRequest({ timestamp: `${Math.floor(Date.now() / 1000)}abc`, }); expect(verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey)).toEqual({ ok: false, reason: "Invalid timestamp header", }); }); it("treats Base64 and Base64URL signatures as the same replayed request", () => { const request = createSignedTelnyxWebhookRequest(); const urlSafeSignature = request.signature .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); const first = verifyTelnyxWebhook(request.makeCtx(), request.pemPublicKey); const second = verifyTelnyxWebhook(request.makeCtx(urlSafeSignature), request.pemPublicKey); expectReplayResultPair(first, second); }); }); describe("reconstructWebhookUrl", () => { it("re-brackets an IPv6 Host header into a URL the native parser accepts", () => { const verificationUrl = reconstructWebhookUrl({ headers: { host: "[2001:db8::1]:8443" }, rawBody: "", url: "http://[::1]:3000/voice/webhook?callId=ipv6", method: "POST", }); expect(verificationUrl).toBe("https://[2001:db8::1]/voice/webhook?callId=ipv6"); expect(new URL(verificationUrl)).toMatchObject({ hostname: "[2001:db8::1]", pathname: "/voice/webhook", search: "?callId=ipv6", }); }); it.each([ "[::1]evil", "[not-ipv6]:8443", "[::1]:invalid", "[ ::1 ]", "[fe80::1%eth0]", "[fe80::1%25eth0]", ])("rejects malformed bracketed Host value %s and falls back to the request URL", (host) => { const verificationUrl = reconstructWebhookUrl({ headers: { host }, rawBody: "", url: "https://fallback.example/voice/webhook?callId=ipv6", method: "POST", }); expect(verificationUrl).toBe("https://fallback.example/voice/webhook?callId=ipv6"); }); it("skips a scoped forwarded IPv6 host and uses the direct Host fallback", () => { const verificationUrl = reconstructWebhookUrl( { headers: { host: "fallback.example", "x-forwarded-host": "[fe80::1%25eth0]", }, rawBody: "", url: "http://localhost:3000/voice/webhook?callId=ipv6", method: "POST", }, { trustForwardingHeaders: true }, ); expect(verificationUrl).toBe("https://fallback.example/voice/webhook?callId=ipv6"); }); }); describe("verifyTwilioWebhook", () => { it("verifies a signature reconstructed from an allowed bracketed IPv6 proxy host", () => { const authToken = "test-ipv6-auth-token"; const postBody = "CallSid=CS-IPV6&CallStatus=completed"; const webhookUrl = "https://[2001:db8::1]/voice/webhook?callId=ipv6"; const signature = twilioSignature({ authToken, url: webhookUrl, postBody }); const result = verifyTwilioWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "[2001:db8::1]:8443", "x-twilio-signature": signature, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook?callId=ipv6", method: "POST", }, authToken, { allowedHosts: ["[2001:db8::1]"] }, ); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("uses the configured public path with the request query", () => { const authToken = "test-auth-token"; const publicUrl = "https://example.com/proxy/voice/webhook"; const urlWithQuery = `${publicUrl}?callId=abc`; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const verifySignedUrl = (signedUrl: string) => verifyTwilioSignedRequest({ headers: { host: "example.com", "x-forwarded-proto": "https", "x-twilio-signature": twilioSignature({ authToken, url: signedUrl, postBody }), }, rawBody: postBody, authToken, publicUrl, }); const result = verifySignedUrl(urlWithQuery); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(urlWithQuery); const localPathResult = verifySignedUrl("https://example.com/voice/webhook?callId=abc"); expect(localPathResult.ok).toBe(false); expect(localPathResult.reason).toContain(`${publicUrl}?callId=***`); }); it("redacts query params from invalid Twilio signature diagnostics", () => { const result = verifyTwilioWebhook( { headers: { host: "example.com", "x-twilio-signature": "invalid", }, rawBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000", url: "https://example.com/voice/webhook?callId=call-1&turnToken=secret-turn-token", method: "POST", query: { callId: "call-1", turnToken: "secret-turn-token" }, }, "test-auth-token", { publicUrl: "https://user:pass@example.com/callback#fragment-secret" }, ); expect(result.ok).toBe(false); expect(result.reason).toContain("Invalid signature for URL:"); expect(result.reason).toContain("turnToken=***"); expect(result.reason).toContain("callId=***"); expect(result.reason).not.toContain("secret-turn-token"); expect(result.reason).not.toContain("user:pass"); expect(result.reason).not.toContain("fragment-secret"); }); it("does not echo malformed verification URLs in diagnostics", () => { const result = verifyTwilioWebhook( { headers: { "x-twilio-signature": "invalid" }, rawBody: "CallSid=CS123", url: "https://local/voice/webhook", method: "POST", }, "test-auth-token", { publicUrl: "not a url?turnToken=secret-turn-token" }, ); expect(result).toMatchObject({ ok: false, reason: "Invalid signature for URL: ", }); expect(result.reason).not.toContain("secret-turn-token"); }); it("treats changed idempotency header as replay for identical signed requests", () => { const authToken = "test-auth-token"; const publicUrl = "https://example.com/voice/webhook"; const urlWithQuery = `${publicUrl}?callId=abc`; const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); const first = verifyTwilioSignedRequest({ headers: { host: "example.com", "x-forwarded-proto": "https", "x-twilio-signature": signature, "i-twilio-idempotency-token": "idem-replay-a", }, rawBody: postBody, authToken, publicUrl, }); const second = verifyTwilioSignedRequest({ headers: { host: "example.com", "x-forwarded-proto": "https", "x-twilio-signature": signature, "i-twilio-idempotency-token": "idem-replay-b", }, rawBody: postBody, authToken, publicUrl, }); expectReplayResultPair(first, second); }); it("rejects invalid signatures even when attacker injects forwarded host", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const result = verifyTwilioWebhook( { headers: { host: "127.0.0.1:3334", "x-forwarded-proto": "https", "x-forwarded-host": "attacker.ngrok-free.app", "x-twilio-signature": "invalid", }, rawBody: postBody, url: "http://127.0.0.1:3334/voice/webhook", method: "POST", }, authToken, ); expect(result.ok).toBe(false); // X-Forwarded-Host is ignored by default, so URL uses Host header expect(result.isNgrokFreeTier).toBe(false); expect(result.reason).toMatch(/Invalid signature/); }); it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => { const webhookUrl = "https://local.ngrok-free.app/voice/webhook"; const signature = twilioSignature({ authToken: "test-auth-token", url: webhookUrl, postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000", }); const result = verifyTwilioNgrokLoopback(signature); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("does not allow invalid signatures for ngrok free tier on loopback", () => { const result = verifyTwilioNgrokLoopback("invalid"); expect(result.ok).toBe(false); expect(result.reason).toMatch(/Invalid signature/); expect(result.isNgrokFreeTier).toBe(true); }); it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; // Attacker tries to inject their host - should be ignored const result = verifyTwilioWebhook( { headers: { host: "legitimate.example.com", "x-forwarded-host": "attacker.evil.com", "x-twilio-signature": "invalid", }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", }, authToken, ); expect(result.ok).toBe(false); // Attacker's host is ignored - uses Host header instead expect(result.reason).toContain("https://legitimate.example.com/voice/webhook"); expect(result.verificationUrl).toBeUndefined(); }); it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const webhookUrl = "https://myapp.ngrok.io/voice/webhook"; const signature = twilioSignature({ authToken, url: webhookUrl, postBody }); const result = verifyTwilioWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "myapp.ngrok.io", "x-twilio-signature": signature, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", }, authToken, { allowedHosts: ["myapp.ngrok.io"] }, ); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("verifies Twilio signatures for Cloudflare Tunnel publicUrl requests", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CA123&CallStatus=ringing&Direction=inbound&From=%2B15550000000"; const webhookUrl = "https://oc1.example.com/voice/webhook"; const signature = twilioSignature({ authToken, url: webhookUrl, postBody }); const result = verifyTwilioWebhook( { headers: { host: "localhost:8765", "cf-connecting-ip": "203.0.113.42", "x-forwarded-proto": "https", "x-twilio-signature": signature, }, rawBody: postBody, url: "http://localhost:8765/voice/webhook", method: "POST", remoteAddress: "127.0.0.1", }, authToken, { publicUrl: webhookUrl, allowedHosts: ["oc1.example.com"], trustForwardingHeaders: true, }, ); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const result = verifyTwilioWebhook( { headers: { host: "localhost:3000", "x-forwarded-host": "attacker.evil.com", "x-twilio-signature": "invalid", }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", }, authToken, { allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] }, ); expect(result.ok).toBe(false); // Attacker's host not in whitelist, falls back to Host header expect(result.reason).toContain("https://localhost/voice/webhook"); expect(result.verificationUrl).toBeUndefined(); }); it("trusts forwarding headers only from trusted proxy IPs", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const webhookUrl = "https://proxy.example.com/voice/webhook"; const signature = twilioSignature({ authToken, url: webhookUrl, postBody }); const result = verifyTwilioWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "proxy.example.com", "x-twilio-signature": signature, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", remoteAddress: "203.0.113.10", }, authToken, { trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] }, ); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("matches trusted proxies when Node reports an IPv4-mapped remote address", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const webhookUrl = "https://proxy.example.com/voice/webhook"; const signature = twilioSignature({ authToken, url: webhookUrl, postBody }); const result = verifyTwilioWebhook( { headers: { host: "localhost:3000", "x-forwarded-proto": "https", "x-forwarded-host": "proxy.example.com", "x-twilio-signature": signature, }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", remoteAddress: "::ffff:127.0.0.1", }, authToken, { trustForwardingHeaders: true, trustedProxyIPs: ["127.0.0.1"] }, ); expect(result.ok).toBe(true); expect(result.verificationUrl).toBe(webhookUrl); }); it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; const result = verifyTwilioWebhook( { headers: { host: "legitimate.example.com", "x-forwarded-proto": "https", "x-forwarded-host": "proxy.example.com", "x-twilio-signature": "invalid", }, rawBody: postBody, url: "http://localhost:3000/voice/webhook", method: "POST", }, authToken, { trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] }, ); expect(result.ok).toBe(false); expect(result.reason).toContain("https://legitimate.example.com/voice/webhook"); expect(result.verificationUrl).toBeUndefined(); }); 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:/); }); });