refactor(voice-call): enforce verified webhook key contract

This commit is contained in:
Peter Steinberger
2026-02-26 21:54:02 +01:00
parent 6f0b4caa26
commit 535ef8991c
3 changed files with 77 additions and 52 deletions

View File

@@ -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<string, string | string[] | undefined>,
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");

View File

@@ -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<typeof setInterval> | 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<void> {
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, {

View File

@@ -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);
};
}