VYPR
High severityNVD Advisory· Published Mar 5, 2026· Updated Mar 11, 2026

OpenClaw voice-call < 2026.2.3 - Webhook Verification Bypass via Forwarded Headers

CVE-2026-28465

Description

OpenClaw's voice-call plugin versions before 2026.2.3 contain an improper authentication vulnerability in webhook verification that allows remote attackers to bypass verification by supplying untrusted forwarded headers. Attackers can spoof webhook events by manipulating Forwarded or X-Forwarded-* headers in reverse-proxy configurations that implicitly trust these headers.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

OpenClaw voice-call plugin before 2026.2.3 improperly trusts forwarded headers, allowing remote attackers to spoof webhook events and bypass authentication.

Vulnerability

Overview

CVE-2026-28465 is an improper authentication vulnerability in the webhook verification of OpenClaw's voice-call plugin. The plugin implicitly trusts Forwarded and X-Forwarded-* headers from reverse proxies without validating their origin. This design flaw allows an attacker to craft requests that appear to originate from a trusted source, bypassing authentication checks [1][2].

Exploitation

An attacker with network access to a reverse proxy that passes through these headers can spoof webhook events. No prior authentication is required; the attacker simply sends HTTP requests with manipulated forwarding headers. The proxy, configured to trust these headers, forwards them to the vulnerable plugin, which then accepts the spoofed event as legitimate [2].

Impact

Successful exploitation enables an attacker to impersonate legitimate webhook sources, potentially triggering arbitrary actions such as initiating voice calls, modifying configurations, or accessing sensitive data. The impact depends on the specific webhook handlers configured in the OpenClaw instance [1][2].

Mitigation

The vulnerability is fixed in OpenClaw version 2026.2.3. The patch introduces a WebhookSecurityConfig with fields for allowedHosts, trustedProxyIPs, and a trustForwardingHeaders flag. Users should update to the latest version and configure these settings to restrict which hosts and proxies are trusted for forwarded headers [3][4].

AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
@openclaw/voice-callnpm
< 2026.2.32026.2.3
@clawdbot/voice-callnpm
<= 2026.1.24

Affected products

2

Patches

1
a749db9820eb

fix: harden voice-call webhook verification

https://github.com/OpenClaw/OpenClawPeter SteinbergerFeb 4, 2026via ghsa
11 files changed · +495 42
  • CHANGELOG.md+1 0 modified
    @@ -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
     
    
  • docs/platforms/fly.md+5 2 modified
    @@ -446,15 +446,18 @@ Example voice-call config with ngrok:
             "enabled": true,
             "config": {
               "provider": "twilio",
    -          "tunnel": { "provider": "ngrok" }
    +          "tunnel": { "provider": "ngrok" },
    +          "webhookSecurity": {
    +            "allowedHosts": ["example.ngrok.app"]
    +          }
             }
           }
         }
       }
     }
     ```
     
    -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
     
    
  • docs/plugins/voice-call.md+38 0 modified
    @@ -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
    
  • extensions/voice-call/src/config.test.ts+5 0 modified
    @@ -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",
    
  • extensions/voice-call/src/config.ts+41 6 modified
    @@ -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;
     }
     
    
  • extensions/voice-call/src/providers/plivo.ts+18 5 modified
    @@ -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;
    
  • extensions/voice-call/src/providers/twilio.ts+3 1 modified
    @@ -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 {
    
  • extensions/voice-call/src/providers/twilio/webhook.ts+4 0 modified
    @@ -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) {
    
  • extensions/voice-call/src/runtime.ts+3 1 modified
    @@ -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":
    
  • extensions/voice-call/src/webhook-security.test.ts+130 4 modified
    @@ -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");
    +  });
     });
    
  • extensions/voice-call/src/webhook-security.ts+247 23 modified
    @@ -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;
    +
    +  // 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;
    +    }
    +  }
     
    -  const forwardedHost =
    -    getHeader(headers, "x-forwarded-host") ||
    -    getHeader(headers, "x-original-host") ||
    -    getHeader(headers, "ngrok-forwarded-host") ||
    -    getHeader(headers, "host") ||
    -    "";
    +  // 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 {
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.