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

@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
sttProvider: "openai-realtime",

View File

@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
* will be allowed only for loopback requests (ngrok local agent).
*/
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
/**
* Legacy ngrok free tier compatibility mode (deprecated).
* Use allowNgrokFreeTierLoopbackBypass instead.
*/
allowNgrokFreeTier: z.boolean().optional(),
})
.strict()
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Security Configuration
// -----------------------------------------------------------------------------
export const VoiceCallWebhookSecurityConfigSchema = z
.object({
/**
* Allowed hostnames for webhook URL reconstruction.
* Only these hosts are accepted from forwarding headers.
*/
allowedHosts: z.array(z.string().min(1)).default([]),
/**
* Trust X-Forwarded-* headers without a hostname allowlist.
* WARNING: Only enable if you trust your proxy configuration.
*/
trustForwardingHeaders: z.boolean().default(false),
/**
* Trusted proxy IP addresses. Forwarded headers are only trusted when
* the remote IP matches one of these addresses.
*/
trustedProxyIPs: z.array(z.string().min(1)).default([]),
})
.strict()
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
// -----------------------------------------------------------------------------
// Outbound Call Configuration
// -----------------------------------------------------------------------------
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
/** Tunnel configuration (unified ngrok/tailscale) */
tunnel: VoiceCallTunnelConfigSchema,
/** Webhook signature reconstruction and proxy trust configuration */
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
/** Real-time audio streaming configuration */
streaming: VoiceCallStreamingConfigSchema,
@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
allowNgrokFreeTierLoopbackBypass: false,
};
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
// Webhook Security Config
resolved.webhookSecurity = resolved.webhookSecurity ?? {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
};
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
resolved.webhookSecurity.trustForwardingHeaders =
resolved.webhookSecurity.trustForwardingHeaders ?? false;
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
return resolved;
}

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) {

View File

@@ -44,7 +44,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackBind(config.serve?.bind) &&
(config.tunnel?.allowNgrokFreeTierLoopbackBypass || config.tunnel?.allowNgrokFreeTier || false);
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
switch (config.provider) {
case "telnyx":
@@ -70,6 +70,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
webhookSecurity: config.webhookSecurity,
},
);
case "plivo":
@@ -82,6 +83,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
webhookSecurity: config.webhookSecurity,
},
);
case "mock":

View File

@@ -197,7 +197,7 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(true);
});
it("rejects invalid signatures even with ngrok free tier enabled", () => {
it("rejects invalid signatures even when attacker injects forwarded host", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
@@ -212,14 +212,13 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
// X-Forwarded-Host is ignored by default, so URL uses Host header
expect(result.isNgrokFreeTier).toBe(false);
expect(result.reason).toMatch(/Invalid signature/);
});
@@ -248,4 +247,131 @@ describe("verifyTwilioWebhook", () => {
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/compatibility mode/);
});
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
// Attacker tries to inject their host - should be ignored
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
);
expect(result.ok).toBe(false);
// Attacker's host is ignored - uses Host header instead
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://myapp.ngrok.io/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "myapp.ngrok.io",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] },
);
expect(result.ok).toBe(false);
// Attacker's host not in whitelist, falls back to Host header
expect(result.verificationUrl).toBe("https://localhost/voice/webhook");
});
it("trusts forwarding headers only from trusted proxy IPs", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://proxy.example.com/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(false);
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
});

View File

@@ -57,9 +57,119 @@ function timingSafeEqual(a: string, b: string): boolean {
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* Configuration for secure URL reconstruction.
*/
export interface WebhookUrlOptions {
/**
* Whitelist of allowed hostnames. If provided, only these hosts will be
* accepted from forwarding headers. This prevents host header injection attacks.
*
* SECURITY: You must provide this OR set trustForwardingHeaders=true to use
* X-Forwarded-Host headers. Without either, forwarding headers are ignored.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only set this to true if you trust your proxy configuration
* and understand the security implications.
*
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only be
* trusted if the request comes from one of these IPs.
* Requires remoteIP to be set for validation.
*/
trustedProxyIPs?: string[];
/**
* The IP address of the incoming request (for proxy validation).
*/
remoteIP?: string;
}
/**
* Validate that a hostname matches RFC 1123 format.
* Prevents injection of malformed hostnames.
*/
function isValidHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253) {
return false;
}
// RFC 1123 hostname: alphanumeric, hyphens, dots
// Also allow ngrok/tunnel subdomains
const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return hostnameRegex.test(hostname);
}
/**
* Safely extract hostname from a host header value.
* Handles IPv6 addresses and prevents injection via malformed values.
*/
function extractHostname(hostHeader: string): string | null {
if (!hostHeader) {
return null;
}
let hostname: string;
// Handle IPv6 addresses: [::1]:8080
if (hostHeader.startsWith("[")) {
const endBracket = hostHeader.indexOf("]");
if (endBracket === -1) {
return null; // Malformed IPv6
}
hostname = hostHeader.substring(1, endBracket);
return hostname.toLowerCase();
}
// Handle IPv4/domain with optional port
// Check for @ which could indicate user info injection attempt
if (hostHeader.includes("@")) {
return null; // Reject potential injection: attacker.com:80@legitimate.com
}
hostname = hostHeader.split(":")[0];
// Validate the extracted hostname
if (!isValidHostname(hostname)) {
return null;
}
return hostname.toLowerCase();
}
function extractHostnameFromHeader(headerValue: string): string | null {
const first = headerValue.split(",")[0]?.trim();
if (!first) {
return null;
}
return extractHostname(first);
}
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
if (!allowedHosts || allowedHosts.length === 0) {
return null;
}
const normalized = new Set<string>();
for (const host of allowedHosts) {
const extracted = extractHostname(host.trim());
if (extracted) {
normalized.add(extracted);
}
}
return normalized.size > 0 ? normalized : null;
}
/**
* Reconstruct the public webhook URL from request headers.
*
* SECURITY: This function validates host headers to prevent host header
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
* always provide allowedHosts to whitelist valid hostnames.
*
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
* used by Twilio differs from the local request URL. We use standard
* forwarding headers to reconstruct it.
@@ -70,17 +180,84 @@ function timingSafeEqual(a: string, b: string): boolean {
* 3. Ngrok-Forwarded-Host (ngrok specific)
* 4. Host header (direct connection)
*/
export function reconstructWebhookUrl(ctx: WebhookContext): string {
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
const { headers } = ctx;
const proto = getHeader(headers, "x-forwarded-proto") || "https";
// SECURITY: Only trust forwarding headers if explicitly configured.
// Either allowedHosts must be set (for whitelist validation) or
// trustForwardingHeaders must be true (explicit opt-in to trust).
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
const hasAllowedHosts = allowedHosts !== null;
const explicitlyTrusted = options?.trustForwardingHeaders === true;
const forwardedHost =
getHeader(headers, "x-forwarded-host") ||
getHeader(headers, "x-original-host") ||
getHeader(headers, "ngrok-forwarded-host") ||
getHeader(headers, "host") ||
"";
// Also check trusted proxy IPs if configured
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
const fromTrustedProxy =
!hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
// Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
let proto = "https";
if (shouldTrustForwardingHeaders) {
const forwardedProto = getHeader(headers, "x-forwarded-proto");
if (forwardedProto === "http" || forwardedProto === "https") {
proto = forwardedProto;
}
}
// Determine host - with security validation
let host: string | null = null;
if (shouldTrustForwardingHeaders) {
// Try forwarding headers in priority order
const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
for (const headerName of forwardingHeaders) {
const headerValue = getHeader(headers, headerName);
if (headerValue) {
const extracted = extractHostnameFromHeader(headerValue);
if (extracted && isAllowedForwardedHost(extracted)) {
host = extracted;
break;
}
}
}
}
// Fallback to Host header if no valid forwarding header found
if (!host) {
const hostHeader = getHeader(headers, "host");
if (hostHeader) {
const extracted = extractHostnameFromHeader(hostHeader);
if (extracted) {
host = extracted;
}
}
}
// Last resort: try to extract from ctx.url
if (!host) {
try {
const parsed = new URL(ctx.url);
const extracted = extractHostname(parsed.host);
if (extracted) {
host = extracted;
}
} catch {
// URL parsing failed - use empty string (will result in invalid URL)
host = "";
}
}
if (!host) {
host = "";
}
// Extract path from the context URL (fallback to "/" on parse failure)
let path = "/";
@@ -91,15 +268,16 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
// URL parsing failed
}
// Remove port from host (ngrok URLs don't have ports)
const host = forwardedHost.split(":")[0] || forwardedHost;
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(ctx: WebhookContext, publicUrl?: string): string {
function buildTwilioVerificationUrl(
ctx: WebhookContext,
publicUrl?: string,
urlOptions?: WebhookUrlOptions,
): string {
if (!publicUrl) {
return reconstructWebhookUrl(ctx);
return reconstructWebhookUrl(ctx, urlOptions);
}
try {
@@ -154,9 +332,6 @@ export interface TwilioVerificationResult {
/**
* Verify Twilio webhook with full context and detailed result.
*
* Handles the special case of ngrok free tier where signature validation
* may fail due to URL discrepancies (ngrok adds interstitial page handling).
*/
export function verifyTwilioWebhook(
ctx: WebhookContext,
@@ -168,6 +343,26 @@ export function verifyTwilioWebhook(
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): TwilioVerificationResult {
// Allow skipping verification for development/testing
@@ -181,8 +376,16 @@ export function verifyTwilioWebhook(
return { ok: false, reason: "Missing X-Twilio-Signature header" };
}
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
// Reconstruct the URL Twilio used
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
// Parse the body as URL-encoded params
const params = new URLSearchParams(ctx.rawBody);
@@ -198,11 +401,7 @@ export function verifyTwilioWebhook(
const isNgrokFreeTier =
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
if (
isNgrokFreeTier &&
options?.allowNgrokFreeTierLoopbackBypass &&
isLoopbackAddress(ctx.remoteAddress)
) {
if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
console.warn(
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
);
@@ -384,6 +583,26 @@ export function verifyPlivoWebhook(
publicUrl?: string;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): PlivoVerificationResult {
if (options?.skipVerification) {
@@ -395,7 +614,12 @@ export function verifyPlivoWebhook(
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
const reconstructed = reconstructWebhookUrl(ctx);
const reconstructed = reconstructWebhookUrl(ctx, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
let verificationUrl = reconstructed;
if (options?.publicUrl) {
try {