test: dedupe telnyx webhook test fixtures

This commit is contained in:
Peter Steinberger 2026-03-13 21:35:50 +00:00
parent d5d2fe1b0e
commit b5010719d6
2 changed files with 104 additions and 134 deletions

View File

@ -22,6 +22,34 @@ function decodeBase64Url(input: string): Buffer {
return Buffer.from(padded, "base64"); return Buffer.from(padded, "base64");
} }
function createSignedTelnyxCtx(params: {
privateKey: crypto.KeyObject;
rawBody: string;
}): WebhookContext {
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${params.rawBody}`;
const signature = crypto
.sign(null, Buffer.from(signedPayload), params.privateKey)
.toString("base64");
return createCtx({
rawBody: params.rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
});
}
function expectReplayVerification(
results: Array<{ ok: boolean; isReplay?: boolean; verifiedRequestKey?: string }>,
) {
expect(results.map((result) => result.ok)).toEqual([true, true]);
expect(results.map((result) => Boolean(result.isReplay))).toEqual([false, true]);
expect(results[0]?.verifiedRequestKey).toEqual(expect.any(String));
expect(results[1]?.verifiedRequestKey).toBe(results[0]?.verifiedRequestKey);
}
function expectWebhookVerificationSucceeds(params: { function expectWebhookVerificationSucceeds(params: {
publicKey: string; publicKey: string;
privateKey: crypto.KeyObject; privateKey: crypto.KeyObject;
@ -35,20 +63,8 @@ function expectWebhookVerificationSucceeds(params: {
event_type: "call.initiated", event_type: "call.initiated",
payload: { call_control_id: "x" }, payload: { call_control_id: "x" },
}); });
const timestamp = String(Math.floor(Date.now() / 1000));
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto
.sign(null, Buffer.from(signedPayload), params.privateKey)
.toString("base64");
const result = provider.verifyWebhook( const result = provider.verifyWebhook(
createCtx({ createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }),
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
}),
); );
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
} }
@ -117,26 +133,12 @@ describe("TelnyxProvider.verifyWebhook", () => {
payload: { call_control_id: "call-replay-test" }, payload: { call_control_id: "call-replay-test" },
nonce: crypto.randomUUID(), nonce: crypto.randomUUID(),
}); });
const timestamp = String(Math.floor(Date.now() / 1000)); const ctx = createSignedTelnyxCtx({ privateKey, rawBody });
const signedPayload = `${timestamp}|${rawBody}`;
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
const ctx = createCtx({
rawBody,
headers: {
"telnyx-signature-ed25519": signature,
"telnyx-timestamp": timestamp,
},
});
const first = provider.verifyWebhook(ctx); const first = provider.verifyWebhook(ctx);
const second = provider.verifyWebhook(ctx); const second = provider.verifyWebhook(ctx);
expect(first.ok).toBe(true); expectReplayVerification([first, second]);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
}); });
}); });

View File

@ -98,6 +98,51 @@ function expectReplayResultPair(
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
} }
function expectAcceptedWebhookVersion(
result: { ok: boolean; version?: string },
version: "v2" | "v3",
) {
expect(result).toMatchObject({ ok: true, 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<string, string>;
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 },
);
}
describe("verifyPlivoWebhook", () => { describe("verifyPlivoWebhook", () => {
it("accepts valid V2 signature", () => { it("accepts valid V2 signature", () => {
const authToken = "test-auth-token"; const authToken = "test-auth-token";
@ -127,8 +172,7 @@ describe("verifyPlivoWebhook", () => {
authToken, authToken,
); );
expect(result.ok).toBe(true); expectAcceptedWebhookVersion(result, "v2");
expect(result.version).toBe("v2");
}); });
it("accepts valid V3 signature (including multi-signature header)", () => { it("accepts valid V3 signature (including multi-signature header)", () => {
@ -161,8 +205,7 @@ describe("verifyPlivoWebhook", () => {
authToken, authToken,
); );
expect(result.ok).toBe(true); expectAcceptedWebhookVersion(result, "v3");
expect(result.version).toBe("v3");
}); });
it("rejects missing signatures", () => { it("rejects missing signatures", () => {
@ -317,35 +360,10 @@ describe("verifyTwilioWebhook", () => {
"i-twilio-idempotency-token": "idem-replay-1", "i-twilio-idempotency-token": "idem-replay-1",
}; };
const first = verifyTwilioWebhook( const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
{ const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
headers,
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
},
authToken,
{ publicUrl },
);
const second = verifyTwilioWebhook(
{
headers,
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
},
authToken,
{ publicUrl },
);
expect(first.ok).toBe(true); expectReplayResultPair(first, second);
expect(first.isReplay).toBeFalsy();
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
}); });
it("treats changed idempotency header as replay for identical signed requests", () => { it("treats changed idempotency header as replay for identical signed requests", () => {
@ -355,45 +373,30 @@ describe("verifyTwilioWebhook", () => {
const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
const first = verifyTwilioWebhook( const first = verifyTwilioSignedRequest({
{ headers: {
headers: { host: "example.com",
host: "example.com", "x-forwarded-proto": "https",
"x-forwarded-proto": "https", "x-twilio-signature": signature,
"x-twilio-signature": signature, "i-twilio-idempotency-token": "idem-replay-a",
"i-twilio-idempotency-token": "idem-replay-a",
},
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
}, },
rawBody: postBody,
authToken, authToken,
{ publicUrl }, publicUrl,
); });
const second = verifyTwilioWebhook( const second = verifyTwilioSignedRequest({
{ headers: {
headers: { host: "example.com",
host: "example.com", "x-forwarded-proto": "https",
"x-forwarded-proto": "https", "x-twilio-signature": signature,
"x-twilio-signature": signature, "i-twilio-idempotency-token": "idem-replay-b",
"i-twilio-idempotency-token": "idem-replay-b",
},
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
}, },
rawBody: postBody,
authToken, authToken,
{ publicUrl }, publicUrl,
); });
expect(first.ok).toBe(true); expectReplayResultPair(first, second);
expect(first.isReplay).toBe(false);
expect(first.verifiedRequestKey).toBeTruthy();
expect(second.ok).toBe(true);
expect(second.isReplay).toBe(true);
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
}); });
it("rejects invalid signatures even when attacker injects forwarded host", () => { it("rejects invalid signatures even when attacker injects forwarded host", () => {
@ -422,57 +425,22 @@ describe("verifyTwilioWebhook", () => {
}); });
it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => { it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://local.ngrok-free.app/voice/webhook"; const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
const signature = twilioSignature({ const signature = twilioSignature({
authToken, authToken: "test-auth-token",
url: webhookUrl, url: webhookUrl,
postBody, postBody: "CallSid=CS123&CallStatus=completed&From=%2B15550000000",
}); });
const result = verifyTwilioWebhook( const result = verifyTwilioNgrokLoopback(signature);
{
headers: {
host: "127.0.0.1:3334",
"x-forwarded-proto": "https",
"x-forwarded-host": "local.ngrok-free.app",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "127.0.0.1",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl); expect(result.verificationUrl).toBe(webhookUrl);
}); });
it("does not allow invalid signatures for ngrok free tier on loopback", () => { it("does not allow invalid signatures for ngrok free tier on loopback", () => {
const authToken = "test-auth-token"; const result = verifyTwilioNgrokLoopback("invalid");
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": "local.ngrok-free.app",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "127.0.0.1",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.reason).toMatch(/Invalid signature/); expect(result.reason).toMatch(/Invalid signature/);