mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-08 21:09:23 +08:00
fix: harden voice-call webhook verification
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user