mirror of https://github.com/openclaw/openclaw.git
test: dedupe telnyx webhook test fixtures
This commit is contained in:
parent
d5d2fe1b0e
commit
b5010719d6
|
|
@ -22,6 +22,34 @@ function decodeBase64Url(input: string): Buffer {
|
|||
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: {
|
||||
publicKey: string;
|
||||
privateKey: crypto.KeyObject;
|
||||
|
|
@ -35,20 +63,8 @@ function expectWebhookVerificationSucceeds(params: {
|
|||
event_type: "call.initiated",
|
||||
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(
|
||||
createCtx({
|
||||
rawBody,
|
||||
headers: {
|
||||
"telnyx-signature-ed25519": signature,
|
||||
"telnyx-timestamp": timestamp,
|
||||
},
|
||||
}),
|
||||
createSignedTelnyxCtx({ privateKey: params.privateKey, rawBody }),
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
}
|
||||
|
|
@ -117,26 +133,12 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
|||
payload: { call_control_id: "call-replay-test" },
|
||||
nonce: crypto.randomUUID(),
|
||||
});
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
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 ctx = createSignedTelnyxCtx({ privateKey, rawBody });
|
||||
|
||||
const first = provider.verifyWebhook(ctx);
|
||||
const second = provider.verifyWebhook(ctx);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(first.verifiedRequestKey).toBeTruthy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
expectReplayVerification([first, second]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -98,6 +98,51 @@ function expectReplayResultPair(
|
|||
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", () => {
|
||||
it("accepts valid V2 signature", () => {
|
||||
const authToken = "test-auth-token";
|
||||
|
|
@ -127,8 +172,7 @@ describe("verifyPlivoWebhook", () => {
|
|||
authToken,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.version).toBe("v2");
|
||||
expectAcceptedWebhookVersion(result, "v2");
|
||||
});
|
||||
|
||||
it("accepts valid V3 signature (including multi-signature header)", () => {
|
||||
|
|
@ -161,8 +205,7 @@ describe("verifyPlivoWebhook", () => {
|
|||
authToken,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.version).toBe("v3");
|
||||
expectAcceptedWebhookVersion(result, "v3");
|
||||
});
|
||||
|
||||
it("rejects missing signatures", () => {
|
||||
|
|
@ -317,35 +360,10 @@ describe("verifyTwilioWebhook", () => {
|
|||
"i-twilio-idempotency-token": "idem-replay-1",
|
||||
};
|
||||
|
||||
const first = verifyTwilioWebhook(
|
||||
{
|
||||
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 },
|
||||
);
|
||||
const first = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
|
||||
const second = verifyTwilioSignedRequest({ headers, rawBody: postBody, authToken, publicUrl });
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(first.verifiedRequestKey).toBeTruthy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey);
|
||||
expectReplayResultPair(first, second);
|
||||
});
|
||||
|
||||
it("treats changed idempotency header as replay for identical signed requests", () => {
|
||||
|
|
@ -355,8 +373,7 @@ describe("verifyTwilioWebhook", () => {
|
|||
const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000";
|
||||
const signature = twilioSignature({ authToken, url: urlWithQuery, postBody });
|
||||
|
||||
const first = verifyTwilioWebhook(
|
||||
{
|
||||
const first = verifyTwilioSignedRequest({
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
|
|
@ -364,15 +381,10 @@ describe("verifyTwilioWebhook", () => {
|
|||
"i-twilio-idempotency-token": "idem-replay-a",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
const second = verifyTwilioWebhook(
|
||||
{
|
||||
publicUrl,
|
||||
});
|
||||
const second = verifyTwilioSignedRequest({
|
||||
headers: {
|
||||
host: "example.com",
|
||||
"x-forwarded-proto": "https",
|
||||
|
|
@ -380,20 +392,11 @@ describe("verifyTwilioWebhook", () => {
|
|||
"i-twilio-idempotency-token": "idem-replay-b",
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://local/voice/webhook?callId=abc",
|
||||
method: "POST",
|
||||
query: { callId: "abc" },
|
||||
},
|
||||
authToken,
|
||||
{ publicUrl },
|
||||
);
|
||||
publicUrl,
|
||||
});
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
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);
|
||||
expectReplayResultPair(first, second);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const authToken = "test-auth-token";
|
||||
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
|
||||
const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
|
||||
|
||||
const signature = twilioSignature({
|
||||
authToken,
|
||||
authToken: "test-auth-token",
|
||||
url: webhookUrl,
|
||||
postBody,
|
||||
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": signature,
|
||||
},
|
||||
rawBody: postBody,
|
||||
url: "http://127.0.0.1:3334/voice/webhook",
|
||||
method: "POST",
|
||||
remoteAddress: "127.0.0.1",
|
||||
},
|
||||
authToken,
|
||||
{ allowNgrokFreeTierLoopbackBypass: true },
|
||||
);
|
||||
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 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": "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 },
|
||||
);
|
||||
const result = verifyTwilioNgrokLoopback("invalid");
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.reason).toMatch(/Invalid signature/);
|
||||
|
|
|
|||
Loading…
Reference in New Issue