From a749db9820eb6d6224032a5a34223d286d2dcc2f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Feb 2026 23:46:54 -0800 Subject: [PATCH] fix: harden voice-call webhook verification --- CHANGELOG.md | 1 + docs/platforms/fly.md | 7 +- docs/plugins/voice-call.md | 38 +++ extensions/voice-call/src/config.test.ts | 5 + extensions/voice-call/src/config.ts | 47 ++- extensions/voice-call/src/providers/plivo.ts | 23 +- extensions/voice-call/src/providers/twilio.ts | 4 +- .../src/providers/twilio/webhook.ts | 4 + extensions/voice-call/src/runtime.ts | 4 +- .../voice-call/src/webhook-security.test.ts | 134 ++++++++- extensions/voice-call/src/webhook-security.ts | 270 ++++++++++++++++-- 11 files changed, 495 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 97d8e08c48..80aa198371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai - Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo. - Web UI: apply button styling to the new-messages indicator. - Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin. +- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass. ## 2026.2.2-3 diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md index 78e9ad59f1..a3eadd9b41 100644 --- a/docs/platforms/fly.md +++ b/docs/platforms/fly.md @@ -446,7 +446,10 @@ Example voice-call config with ngrok: "enabled": true, "config": { "provider": "twilio", - "tunnel": { "provider": "ngrok" } + "tunnel": { "provider": "ngrok" }, + "webhookSecurity": { + "allowedHosts": ["example.ngrok.app"] + } } } } @@ -454,7 +457,7 @@ Example voice-call config with ngrok: } ``` -The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. +The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. Set `webhookSecurity.allowedHosts` to the public tunnel hostname so forwarded host headers are accepted. ### Security benefits diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index 32fbe0aab1..7e98da11e1 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -81,6 +81,12 @@ Set config under `plugins.entries.voice-call.config`: path: "/voice/webhook", }, + // Webhook security (recommended for tunnels/proxies) + webhookSecurity: { + allowedHosts: ["voice.example.com"], + trustedProxyIPs: ["100.64.0.1"], + }, + // Public exposure (pick one) // publicUrl: "https://example.ngrok.app/voice/webhook", // tunnel: { provider: "ngrok" }, @@ -111,6 +117,38 @@ Notes: - `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. - Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel. +## Webhook Security + +When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the +public URL for signature verification. These options control which forwarded +headers are trusted. + +`webhookSecurity.allowedHosts` allowlists hosts from forwarding headers. + +`webhookSecurity.trustForwardingHeaders` trusts forwarded headers without an allowlist. + +`webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request +remote IP matches the list. + +Example with a stable public host: + +```json5 +{ + plugins: { + entries: { + "voice-call": { + config: { + publicUrl: "https://voice.example.com/voice/webhook", + webhookSecurity: { + allowedHosts: ["voice.example.com"], + }, + }, + }, + }, + }, +} +``` + ## TTS for calls Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index b5f261f9ef..ef99544709 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -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", diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 80e7448347..cfe82b425f 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -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; +// ----------------------------------------------------------------------------- +// 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; + // ----------------------------------------------------------------------------- // 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; } diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 601ea6cdd6..44f03c755f 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -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; diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index aaa1eb389c..b1f03b2117 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -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 { diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index f2f2a671e8..ecbd8c573d 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -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) { diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index 046c4c208c..6d37d8ac25 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -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": diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 253b5904ec..7968829af1 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -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"); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 26fb7a1c99..6ee7a813da 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -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 | null { + if (!allowedHosts || allowedHosts.length === 0) { + return null; + } + const normalized = new Set(); + 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 {