diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 60f37e822e6..75d1ca490d0 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { getHeader } from "./http-headers.js"; import type { WebhookContext } from "./types.js"; const REPLAY_WINDOW_MS = 10 * 60 * 1000; @@ -29,6 +30,10 @@ function sha256Hex(input: string): string { return crypto.createHash("sha256").update(input).digest("hex"); } +function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string { + return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`; +} + function pruneReplayCache(cache: ReplayCache, now: number): void { for (const [key, expiresAt] of cache.seenUntil) { if (expiresAt <= now) { @@ -361,20 +366,6 @@ function buildTwilioVerificationUrl( } } -/** - * Get a header value, handling both string and string[] types. - */ -function getHeader( - headers: Record, - name: string, -): string | undefined { - const value = headers[name.toLowerCase()]; - if (Array.isArray(value)) { - return value[0]; - } - return value; -} - function isLoopbackAddress(address?: string): boolean { if (!address) { return false; @@ -479,7 +470,14 @@ export function verifyTelnyxWebhook( }, ): TelnyxVerificationResult { if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("telnyx", ctx); + const isReplay = markReplay(telnyxReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } if (!publicKey) { @@ -569,7 +567,14 @@ export function verifyTwilioWebhook( ): TwilioVerificationResult { // Allow skipping verification for development/testing if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("twilio", ctx); + const isReplay = markReplay(twilioReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } const signature = getHeader(ctx.headers, "x-twilio-signature"); @@ -805,7 +810,14 @@ export function verifyPlivoWebhook( }, ): PlivoVerificationResult { if (options?.skipVerification) { - return { ok: true, reason: "verification skipped (dev mode)" }; + const replayKey = createSkippedVerificationReplayKey("plivo", ctx); + const isReplay = markReplay(plivoReplayCache, replayKey); + return { + ok: true, + reason: "verification skipped (dev mode)", + isReplay, + verifiedRequestKey: replayKey, + }; } const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3"); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 420faab8126..95d6628b5a8 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -15,6 +15,7 @@ import type { VoiceCallProvider } from "./providers/base.js"; import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js"; import type { TwilioProvider } from "./providers/twilio.js"; import type { NormalizedEvent, WebhookContext } from "./types.js"; +import { startStaleCallReaper } from "./webhook/stale-call-reaper.js"; const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024; @@ -28,7 +29,7 @@ export class VoiceCallWebhookServer { private manager: CallManager; private provider: VoiceCallProvider; private coreConfig: CoreConfig | null; - private staleCallReaperInterval: ReturnType | null = null; + private stopStaleCallReaper: (() => void) | null = null; /** Media stream handler for bidirectional audio (when streaming enabled) */ private mediaStreamHandler: MediaStreamHandler | null = null; @@ -217,48 +218,21 @@ export class VoiceCallWebhookServer { resolve(url); // Start the stale call reaper if configured - this.startStaleCallReaper(); + this.stopStaleCallReaper = startStaleCallReaper({ + manager: this.manager, + staleCallReaperSeconds: this.config.staleCallReaperSeconds, + }); }); }); } - /** - * Start a periodic reaper that ends calls older than the configured threshold. - * Catches calls stuck in unexpected states (e.g., notify-mode calls that never - * receive a terminal webhook from the provider). - */ - private startStaleCallReaper(): void { - const maxAgeSeconds = this.config.staleCallReaperSeconds; - if (!maxAgeSeconds || maxAgeSeconds <= 0) { - return; - } - - const CHECK_INTERVAL_MS = 30_000; // Check every 30 seconds - const maxAgeMs = maxAgeSeconds * 1000; - - this.staleCallReaperInterval = setInterval(() => { - const now = Date.now(); - for (const call of this.manager.getActiveCalls()) { - const age = now - call.startedAt; - if (age > maxAgeMs) { - console.log( - `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`, - ); - void this.manager.endCall(call.callId).catch((err) => { - console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err); - }); - } - } - }, CHECK_INTERVAL_MS); - } - /** * Stop the webhook server. */ async stop(): Promise { - if (this.staleCallReaperInterval) { - clearInterval(this.staleCallReaperInterval); - this.staleCallReaperInterval = null; + if (this.stopStaleCallReaper) { + this.stopStaleCallReaper(); + this.stopStaleCallReaper = null; } return new Promise((resolve) => { if (this.server) { @@ -341,6 +315,12 @@ export class VoiceCallWebhookServer { res.end("Unauthorized"); return; } + if (!verification.verifiedRequestKey) { + console.warn("[voice-call] Webhook verification succeeded without request identity key"); + res.statusCode = 401; + res.end("Unauthorized"); + return; + } // Parse events const result = this.provider.parseWebhookEvent(ctx, { diff --git a/extensions/voice-call/src/webhook/stale-call-reaper.ts b/extensions/voice-call/src/webhook/stale-call-reaper.ts new file mode 100644 index 00000000000..4c9661153d5 --- /dev/null +++ b/extensions/voice-call/src/webhook/stale-call-reaper.ts @@ -0,0 +1,33 @@ +import type { CallManager } from "../manager.js"; + +const CHECK_INTERVAL_MS = 30_000; + +export function startStaleCallReaper(params: { + manager: CallManager; + staleCallReaperSeconds?: number; +}): (() => void) | null { + const maxAgeSeconds = params.staleCallReaperSeconds; + if (!maxAgeSeconds || maxAgeSeconds <= 0) { + return null; + } + + const maxAgeMs = maxAgeSeconds * 1000; + const interval = setInterval(() => { + const now = Date.now(); + for (const call of params.manager.getActiveCalls()) { + const age = now - call.startedAt; + if (age > maxAgeMs) { + console.log( + `[voice-call] Reaping stale call ${call.callId} (age: ${Math.round(age / 1000)}s, state: ${call.state})`, + ); + void params.manager.endCall(call.callId).catch((err) => { + console.warn(`[voice-call] Reaper failed to end call ${call.callId}:`, err); + }); + } + } + }, CHECK_INTERVAL_MS); + + return () => { + clearInterval(interval); + }; +}