fix(voice-call): harden inbound policy

This commit is contained in:
Peter Steinberger
2026-02-03 09:33:25 -08:00
parent fc40ba8e7e
commit f8dfd034f5
13 changed files with 328 additions and 33 deletions

View File

@@ -21,15 +21,21 @@ import type { VoiceCallProvider } from "./base.js";
* Uses Telnyx Call Control API v2 for managing calls.
* @see https://developers.telnyx.com/docs/api/v2/call-control
*/
export interface TelnyxProviderOptions {
/** Allow unsigned webhooks when no public key is configured */
allowUnsignedWebhooks?: boolean;
}
export class TelnyxProvider implements VoiceCallProvider {
readonly name = "telnyx" as const;
private readonly apiKey: string;
private readonly connectionId: string;
private readonly publicKey: string | undefined;
private readonly options: TelnyxProviderOptions;
private readonly baseUrl = "https://api.telnyx.com/v2";
constructor(config: TelnyxConfig) {
constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) {
if (!config.apiKey) {
throw new Error("Telnyx API key is required");
}
@@ -40,6 +46,7 @@ export class TelnyxProvider implements VoiceCallProvider {
this.apiKey = config.apiKey;
this.connectionId = config.connectionId;
this.publicKey = config.publicKey;
this.options = options;
}
/**
@@ -76,8 +83,14 @@ export class TelnyxProvider implements VoiceCallProvider {
*/
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
if (!this.publicKey) {
// No public key configured, skip verification (not recommended for production)
return { ok: true };
if (this.options.allowUnsignedWebhooks) {
console.warn("[telnyx] Webhook verification skipped (no public key configured)");
return { ok: true, reason: "verification skipped (no public key configured)" };
}
return {
ok: false,
reason: "Missing telnyx.publicKey (configure to verify webhooks)",
};
}
const signature = ctx.headers["telnyx-signature-ed25519"];

View File

@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
const STREAM_URL_PREFIX = "wss://example.ngrok.app/voice/stream?token=";
function createProvider(): TwilioProvider {
return new TwilioProvider(
@@ -24,13 +24,13 @@ function createContext(rawBody: string, query?: WebhookContext["query"]): Webhoo
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api", {
const ctx = createContext("CallStatus=initiated&Direction=outbound-api&CallSid=CA123", {
callId: "call-1",
});
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
expect(result.providerResponseBody).toContain("<Connect>");
});
@@ -50,11 +50,11 @@ describe("TwilioProvider", () => {
it("returns streaming TwiML for inbound calls", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=inbound");
const ctx = createContext("CallStatus=ringing&Direction=inbound&CallSid=CA456");
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain(STREAM_URL_PREFIX);
expect(result.providerResponseBody).toContain("<Connect>");
});
});

View File

@@ -60,6 +60,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Map of call SID to stream SID for media streams */
private callStreamMap = new Map<string, string>();
/** Per-call tokens for media stream authentication */
private streamAuthTokens = new Map<string, string>();
/** Storage for TwiML content (for notify mode with URL-based TwiML) */
private readonly twimlStorage = new Map<string, string>();
@@ -94,6 +96,7 @@ export class TwilioProvider implements VoiceCallProvider {
}
this.deleteStoredTwiml(callIdMatch[1]);
this.streamAuthTokens.delete(providerCallId);
}
constructor(config: TwilioConfig, options: TwilioProviderOptions = {}) {
@@ -138,6 +141,19 @@ export class TwilioProvider implements VoiceCallProvider {
this.callStreamMap.delete(callSid);
}
isValidStreamToken(callSid: string, token?: string): boolean {
const expected = this.streamAuthTokens.get(callSid);
if (!expected || !token) {
return false;
}
if (expected.length !== token.length) {
const dummy = Buffer.from(expected);
crypto.timingSafeEqual(dummy, dummy);
return false;
}
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(token));
}
/**
* Clear TTS queue for a call (barge-in).
* Used when user starts speaking to interrupt current TTS playback.
@@ -271,11 +287,13 @@ export class TwilioProvider implements VoiceCallProvider {
case "busy":
case "no-answer":
case "failed":
this.streamAuthTokens.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
return { ...baseEvent, type: "call.ended", reason: callStatus };
case "canceled":
this.streamAuthTokens.delete(callSid);
if (callIdOverride) {
this.deleteStoredTwiml(callIdOverride);
}
@@ -308,6 +326,7 @@ export class TwilioProvider implements VoiceCallProvider {
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const isOutbound = direction?.startsWith("outbound") ?? false;
const callSid = params.get("CallSid") || undefined;
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
@@ -330,7 +349,7 @@ export class TwilioProvider implements VoiceCallProvider {
// Conversation mode: return streaming TwiML immediately for outbound calls.
if (isOutbound) {
const streamUrl = this.getStreamUrl();
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
}
@@ -343,7 +362,7 @@ export class TwilioProvider implements VoiceCallProvider {
// Handle subsequent webhook requests (status callbacks, etc.)
// For inbound calls, answer immediately with stream
if (direction === "inbound") {
const streamUrl = this.getStreamUrl();
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
@@ -352,7 +371,7 @@ export class TwilioProvider implements VoiceCallProvider {
return TwilioProvider.EMPTY_TWIML;
}
const streamUrl = this.getStreamUrl();
const streamUrl = callSid ? this.getStreamUrlForCall(callSid) : null;
return streamUrl ? this.getStreamConnectXml(streamUrl) : TwilioProvider.PAUSE_TWIML;
}
@@ -380,6 +399,27 @@ export class TwilioProvider implements VoiceCallProvider {
return `${wsOrigin}${path}`;
}
private getStreamAuthToken(callSid: string): string {
const existing = this.streamAuthTokens.get(callSid);
if (existing) {
return existing;
}
const token = crypto.randomBytes(16).toString("base64url");
this.streamAuthTokens.set(callSid, token);
return token;
}
private getStreamUrlForCall(callSid: string): string | null {
const baseUrl = this.getStreamUrl();
if (!baseUrl) {
return null;
}
const token = this.getStreamAuthToken(callSid);
const url = new URL(baseUrl);
url.searchParams.set("token", token);
return url.toString();
}
/**
* Generate TwiML to connect a call to a WebSocket media stream.
* This enables bidirectional audio streaming for real-time STT/TTS.
@@ -444,6 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
this.deleteStoredTwimlForProviderCall(input.providerCallId);
this.callWebhookUrls.delete(input.providerCallId);
this.streamAuthTokens.delete(input.providerCallId);
await this.apiRequest(
`/Calls/${input.providerCallId}.json`,