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");
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue