fix: harden voice-call webhook verification

This commit is contained in:
Peter Steinberger
2026-02-03 23:46:54 -08:00
parent fa4b28d7af
commit a749db9820
11 changed files with 495 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { PlivoConfig } from "../config.js";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import type {
HangupCallInput,
InitiateCallInput,
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
skipVerification?: boolean;
/** Outbound ring timeout in seconds */
ringTimeoutSec?: number;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}
type PendingSpeak = { text: string; locale?: string };
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
const result = verifyPlivoWebhook(ctx, this.authToken, {
publicUrl: this.options.publicUrl,
skipVerification: this.options.skipVerification,
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
});
if (!result.ok) {
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
// Keep providerCallId mapping for later call control.
const callUuid = parsed.get("CallUUID") || undefined;
if (callUuid) {
const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
if (webhookBase) {
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
}
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
ctx: WebhookContext,
opts: { flow: string; callId?: string },
): string | null {
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
const base = this.baseWebhookUrlFromCtx(ctx);
if (!base) {
return null;
}
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
return u.toString();
}
private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
try {
const u = new URL(reconstructWebhookUrl(ctx));
const u = new URL(
reconstructWebhookUrl(ctx, {
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
}),
);
return `${u.origin}${u.pathname}`;
} catch {
return null;

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { TwilioConfig } from "../config.js";
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
import type { MediaStreamHandler } from "../media-stream.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type {
@@ -38,6 +38,8 @@ export interface TwilioProviderOptions {
streamPath?: string;
/** Skip webhook signature verification (development only) */
skipVerification?: boolean;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}
export class TwilioProvider implements VoiceCallProvider {

View File

@@ -12,6 +12,10 @@ export function verifyTwilioProviderWebhook(params: {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
allowedHosts: params.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
remoteIP: params.ctx.remoteAddress,
});
if (!result.ok) {