mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
security(voice-call): detect Telnyx webhook replay
This commit is contained in:
committed by
Peter Steinberger
parent
53f9b7d4e7
commit
a3c4f56b0b
@@ -103,4 +103,37 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
||||
const spkiDerBase64 = spkiDer.toString("base64");
|
||||
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
|
||||
});
|
||||
|
||||
it("returns replay status when the same signed request is seen twice", () => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
||||
const provider = new TelnyxProvider(
|
||||
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") },
|
||||
{ skipVerification: false },
|
||||
);
|
||||
|
||||
const rawBody = JSON.stringify({
|
||||
event_type: "call.initiated",
|
||||
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 first = provider.verifyWebhook(ctx);
|
||||
const second = provider.verifyWebhook(ctx);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
||||
skipVerification: this.options.skipVerification,
|
||||
});
|
||||
|
||||
return { ok: result.ok, reason: result.reason };
|
||||
return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import crypto from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
|
||||
import {
|
||||
verifyPlivoWebhook,
|
||||
verifyTelnyxWebhook,
|
||||
verifyTwilioWebhook,
|
||||
} from "./webhook-security.js";
|
||||
|
||||
function canonicalizeBase64(input: string): string {
|
||||
return Buffer.from(input, "base64").toString("base64");
|
||||
@@ -199,6 +203,37 @@ describe("verifyPlivoWebhook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTelnyxWebhook", () => {
|
||||
it("marks replayed valid requests as replay without failing auth", () => {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
||||
const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString();
|
||||
const 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");
|
||||
const ctx = {
|
||||
headers: {
|
||||
"telnyx-signature-ed25519": signature,
|
||||
"telnyx-timestamp": timestamp,
|
||||
},
|
||||
rawBody,
|
||||
url: "https://example.com/voice/webhook",
|
||||
method: "POST" as const,
|
||||
};
|
||||
|
||||
const first = verifyTelnyxWebhook(ctx, pemPublicKey);
|
||||
const second = verifyTelnyxWebhook(ctx, pemPublicKey);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.isReplay).toBeFalsy();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.isReplay).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyTwilioWebhook", () => {
|
||||
it("uses request query when publicUrl omits it", () => {
|
||||
const authToken = "test-auth-token";
|
||||
|
||||
@@ -20,6 +20,11 @@ const plivoReplayCache: ReplayCache = {
|
||||
calls: 0,
|
||||
};
|
||||
|
||||
const telnyxReplayCache: ReplayCache = {
|
||||
seenUntil: new Map<string, number>(),
|
||||
calls: 0,
|
||||
};
|
||||
|
||||
function sha256Hex(input: string): string {
|
||||
return crypto.createHash("sha256").update(input).digest("hex");
|
||||
}
|
||||
@@ -392,6 +397,8 @@ export interface TwilioVerificationResult {
|
||||
export interface TelnyxVerificationResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
/** Request is cryptographically valid but was already processed recently. */
|
||||
isReplay?: boolean;
|
||||
}
|
||||
|
||||
function createTwilioReplayKey(params: {
|
||||
@@ -499,7 +506,9 @@ export function verifyTelnyxWebhook(
|
||||
return { ok: false, reason: "Timestamp too old" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
|
||||
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
||||
return { ok: true, isReplay };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
Reference in New Issue
Block a user