@openclaw/voice-call 2026.2.2 → 2026.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 2026.2.3
4
+
5
+ ### Changes
6
+
7
+ - Version alignment with core OpenClaw release numbers.
8
+
3
9
  ## 2026.2.2
4
10
 
5
11
  ### Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/voice-call",
3
- "version": "2026.2.2",
3
+ "version": "2026.2.3",
4
4
  "description": "OpenClaw voice-call plugin",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
17
17
  serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
18
18
  tailscale: { mode: "off", path: "/voice/webhook" },
19
19
  tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
20
+ webhookSecurity: {
21
+ allowedHosts: [],
22
+ trustForwardingHeaders: false,
23
+ trustedProxyIPs: [],
24
+ },
20
25
  streaming: {
21
26
  enabled: false,
22
27
  sttProvider: "openai-realtime",
package/src/config.ts CHANGED
@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
211
211
  * will be allowed only for loopback requests (ngrok local agent).
212
212
  */
213
213
  allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
214
- /**
215
- * Legacy ngrok free tier compatibility mode (deprecated).
216
- * Use allowNgrokFreeTierLoopbackBypass instead.
217
- */
218
- allowNgrokFreeTier: z.boolean().optional(),
219
214
  })
220
215
  .strict()
221
216
  .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
222
217
  export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
223
218
 
219
+ // -----------------------------------------------------------------------------
220
+ // Webhook Security Configuration
221
+ // -----------------------------------------------------------------------------
222
+
223
+ export const VoiceCallWebhookSecurityConfigSchema = z
224
+ .object({
225
+ /**
226
+ * Allowed hostnames for webhook URL reconstruction.
227
+ * Only these hosts are accepted from forwarding headers.
228
+ */
229
+ allowedHosts: z.array(z.string().min(1)).default([]),
230
+ /**
231
+ * Trust X-Forwarded-* headers without a hostname allowlist.
232
+ * WARNING: Only enable if you trust your proxy configuration.
233
+ */
234
+ trustForwardingHeaders: z.boolean().default(false),
235
+ /**
236
+ * Trusted proxy IP addresses. Forwarded headers are only trusted when
237
+ * the remote IP matches one of these addresses.
238
+ */
239
+ trustedProxyIPs: z.array(z.string().min(1)).default([]),
240
+ })
241
+ .strict()
242
+ .default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
243
+ export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
244
+
224
245
  // -----------------------------------------------------------------------------
225
246
  // Outbound Call Configuration
226
247
  // -----------------------------------------------------------------------------
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
339
360
  /** Tunnel configuration (unified ngrok/tailscale) */
340
361
  tunnel: VoiceCallTunnelConfigSchema,
341
362
 
363
+ /** Webhook signature reconstruction and proxy trust configuration */
364
+ webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
365
+
342
366
  /** Real-time audio streaming configuration */
343
367
  streaming: VoiceCallStreamingConfigSchema,
344
368
 
@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
409
433
  allowNgrokFreeTierLoopbackBypass: false,
410
434
  };
411
435
  resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
412
- resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
436
+ resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
413
437
  resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
414
438
  resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
415
439
 
440
+ // Webhook Security Config
441
+ resolved.webhookSecurity = resolved.webhookSecurity ?? {
442
+ allowedHosts: [],
443
+ trustForwardingHeaders: false,
444
+ trustedProxyIPs: [],
445
+ };
446
+ resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
447
+ resolved.webhookSecurity.trustForwardingHeaders =
448
+ resolved.webhookSecurity.trustForwardingHeaders ?? false;
449
+ resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
450
+
416
451
  return resolved;
417
452
  }
418
453
 
@@ -135,6 +135,36 @@ describe("CallManager", () => {
135
135
  expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-missing");
136
136
  });
137
137
 
138
+ it("rejects inbound calls with anonymous caller ID when allowlist enabled", () => {
139
+ const config = VoiceCallConfigSchema.parse({
140
+ enabled: true,
141
+ provider: "plivo",
142
+ fromNumber: "+15550000000",
143
+ inboundPolicy: "allowlist",
144
+ allowFrom: ["+15550001234"],
145
+ });
146
+
147
+ const storePath = path.join(os.tmpdir(), `openclaw-voice-call-test-${Date.now()}`);
148
+ const provider = new FakeProvider();
149
+ const manager = new CallManager(config, storePath);
150
+ manager.initialize(provider, "https://example.com/voice/webhook");
151
+
152
+ manager.processEvent({
153
+ id: "evt-allowlist-anon",
154
+ type: "call.initiated",
155
+ callId: "call-anon",
156
+ providerCallId: "provider-anon",
157
+ timestamp: Date.now(),
158
+ direction: "inbound",
159
+ from: "anonymous",
160
+ to: "+15550000000",
161
+ });
162
+
163
+ expect(manager.getCallByProviderCallId("provider-anon")).toBeUndefined();
164
+ expect(provider.hangupCalls).toHaveLength(1);
165
+ expect(provider.hangupCalls[0]?.providerCallId).toBe("provider-anon");
166
+ });
167
+
138
168
  it("rejects inbound calls that only match allowlist suffixes", () => {
139
169
  const config = VoiceCallConfigSchema.parse({
140
170
  enabled: true,
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import type { PlivoConfig } from "../config.js";
2
+ import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
3
3
  import type {
4
4
  HangupCallInput,
5
5
  InitiateCallInput,
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
23
23
  skipVerification?: boolean;
24
24
  /** Outbound ring timeout in seconds */
25
25
  ringTimeoutSec?: number;
26
+ /** Webhook security options (forwarded headers/allowlist) */
27
+ webhookSecurity?: WebhookSecurityConfig;
26
28
  }
27
29
 
28
30
  type PendingSpeak = { text: string; locale?: string };
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
92
94
  const result = verifyPlivoWebhook(ctx, this.authToken, {
93
95
  publicUrl: this.options.publicUrl,
94
96
  skipVerification: this.options.skipVerification,
97
+ allowedHosts: this.options.webhookSecurity?.allowedHosts,
98
+ trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
99
+ trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
100
+ remoteIP: ctx.remoteAddress,
95
101
  });
96
102
 
97
103
  if (!result.ok) {
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
112
118
  // Keep providerCallId mapping for later call control.
113
119
  const callUuid = parsed.get("CallUUID") || undefined;
114
120
  if (callUuid) {
115
- const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
121
+ const webhookBase = this.baseWebhookUrlFromCtx(ctx);
116
122
  if (webhookBase) {
117
123
  this.callUuidToWebhookUrl.set(callUuid, webhookBase);
118
124
  }
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
444
450
  ctx: WebhookContext,
445
451
  opts: { flow: string; callId?: string },
446
452
  ): string | null {
447
- const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
453
+ const base = this.baseWebhookUrlFromCtx(ctx);
448
454
  if (!base) {
449
455
  return null;
450
456
  }
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
458
464
  return u.toString();
459
465
  }
460
466
 
461
- private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
467
+ private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
462
468
  try {
463
- const u = new URL(reconstructWebhookUrl(ctx));
469
+ const u = new URL(
470
+ reconstructWebhookUrl(ctx, {
471
+ allowedHosts: this.options.webhookSecurity?.allowedHosts,
472
+ trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
473
+ trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
474
+ remoteIP: ctx.remoteAddress,
475
+ }),
476
+ );
464
477
  return `${u.origin}${u.pathname}`;
465
478
  } catch {
466
479
  return null;
@@ -12,6 +12,10 @@ export function verifyTwilioProviderWebhook(params: {
12
12
  publicUrl: params.currentPublicUrl || undefined,
13
13
  allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
14
14
  skipVerification: params.options.skipVerification,
15
+ allowedHosts: params.options.webhookSecurity?.allowedHosts,
16
+ trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
17
+ trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
18
+ remoteIP: params.ctx.remoteAddress,
15
19
  });
16
20
 
17
21
  if (!result.ok) {
@@ -1,5 +1,5 @@
1
1
  import crypto from "node:crypto";
2
- import type { TwilioConfig } from "../config.js";
2
+ import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
3
3
  import type { MediaStreamHandler } from "../media-stream.js";
4
4
  import type { TelephonyTtsProvider } from "../telephony-tts.js";
5
5
  import type {
@@ -38,6 +38,8 @@ export interface TwilioProviderOptions {
38
38
  streamPath?: string;
39
39
  /** Skip webhook signature verification (development only) */
40
40
  skipVerification?: boolean;
41
+ /** Webhook security options (forwarded headers/allowlist) */
42
+ webhookSecurity?: WebhookSecurityConfig;
41
43
  }
42
44
 
43
45
  export class TwilioProvider implements VoiceCallProvider {
package/src/runtime.ts CHANGED
@@ -44,7 +44,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
44
44
  const allowNgrokFreeTierLoopbackBypass =
45
45
  config.tunnel?.provider === "ngrok" &&
46
46
  isLoopbackBind(config.serve?.bind) &&
47
- (config.tunnel?.allowNgrokFreeTierLoopbackBypass || config.tunnel?.allowNgrokFreeTier || false);
47
+ (config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
48
48
 
49
49
  switch (config.provider) {
50
50
  case "telnyx":
@@ -70,6 +70,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
70
70
  publicUrl: config.publicUrl,
71
71
  skipVerification: config.skipSignatureVerification,
72
72
  streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
73
+ webhookSecurity: config.webhookSecurity,
73
74
  },
74
75
  );
75
76
  case "plivo":
@@ -82,6 +83,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
82
83
  publicUrl: config.publicUrl,
83
84
  skipVerification: config.skipSignatureVerification,
84
85
  ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
86
+ webhookSecurity: config.webhookSecurity,
85
87
  },
86
88
  );
87
89
  case "mock":
@@ -197,7 +197,7 @@ describe("verifyTwilioWebhook", () => {
197
197
  expect(result.ok).toBe(true);
198
198
  });
199
199
 
200
- it("rejects invalid signatures even with ngrok free tier enabled", () => {
200
+ it("rejects invalid signatures even when attacker injects forwarded host", () => {
201
201
  const authToken = "test-auth-token";
202
202
  const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
203
203
 
@@ -212,14 +212,13 @@ describe("verifyTwilioWebhook", () => {
212
212
  rawBody: postBody,
213
213
  url: "http://127.0.0.1:3334/voice/webhook",
214
214
  method: "POST",
215
- remoteAddress: "203.0.113.10",
216
215
  },
217
216
  authToken,
218
- { allowNgrokFreeTierLoopbackBypass: true },
219
217
  );
220
218
 
221
219
  expect(result.ok).toBe(false);
222
- expect(result.isNgrokFreeTier).toBe(true);
220
+ // X-Forwarded-Host is ignored by default, so URL uses Host header
221
+ expect(result.isNgrokFreeTier).toBe(false);
223
222
  expect(result.reason).toMatch(/Invalid signature/);
224
223
  });
225
224
 
@@ -248,4 +247,131 @@ describe("verifyTwilioWebhook", () => {
248
247
  expect(result.isNgrokFreeTier).toBe(true);
249
248
  expect(result.reason).toMatch(/compatibility mode/);
250
249
  });
250
+
251
+ it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
252
+ const authToken = "test-auth-token";
253
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
254
+
255
+ // Attacker tries to inject their host - should be ignored
256
+ const result = verifyTwilioWebhook(
257
+ {
258
+ headers: {
259
+ host: "legitimate.example.com",
260
+ "x-forwarded-host": "attacker.evil.com",
261
+ "x-twilio-signature": "invalid",
262
+ },
263
+ rawBody: postBody,
264
+ url: "http://localhost:3000/voice/webhook",
265
+ method: "POST",
266
+ },
267
+ authToken,
268
+ );
269
+
270
+ expect(result.ok).toBe(false);
271
+ // Attacker's host is ignored - uses Host header instead
272
+ expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
273
+ });
274
+
275
+ it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => {
276
+ const authToken = "test-auth-token";
277
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
278
+ const webhookUrl = "https://myapp.ngrok.io/voice/webhook";
279
+
280
+ const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
281
+
282
+ const result = verifyTwilioWebhook(
283
+ {
284
+ headers: {
285
+ host: "localhost:3000",
286
+ "x-forwarded-proto": "https",
287
+ "x-forwarded-host": "myapp.ngrok.io",
288
+ "x-twilio-signature": signature,
289
+ },
290
+ rawBody: postBody,
291
+ url: "http://localhost:3000/voice/webhook",
292
+ method: "POST",
293
+ },
294
+ authToken,
295
+ { allowedHosts: ["myapp.ngrok.io"] },
296
+ );
297
+
298
+ expect(result.ok).toBe(true);
299
+ expect(result.verificationUrl).toBe(webhookUrl);
300
+ });
301
+
302
+ it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => {
303
+ const authToken = "test-auth-token";
304
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
305
+
306
+ const result = verifyTwilioWebhook(
307
+ {
308
+ headers: {
309
+ host: "localhost:3000",
310
+ "x-forwarded-host": "attacker.evil.com",
311
+ "x-twilio-signature": "invalid",
312
+ },
313
+ rawBody: postBody,
314
+ url: "http://localhost:3000/voice/webhook",
315
+ method: "POST",
316
+ },
317
+ authToken,
318
+ { allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] },
319
+ );
320
+
321
+ expect(result.ok).toBe(false);
322
+ // Attacker's host not in whitelist, falls back to Host header
323
+ expect(result.verificationUrl).toBe("https://localhost/voice/webhook");
324
+ });
325
+
326
+ it("trusts forwarding headers only from trusted proxy IPs", () => {
327
+ const authToken = "test-auth-token";
328
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
329
+ const webhookUrl = "https://proxy.example.com/voice/webhook";
330
+
331
+ const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
332
+
333
+ const result = verifyTwilioWebhook(
334
+ {
335
+ headers: {
336
+ host: "localhost:3000",
337
+ "x-forwarded-proto": "https",
338
+ "x-forwarded-host": "proxy.example.com",
339
+ "x-twilio-signature": signature,
340
+ },
341
+ rawBody: postBody,
342
+ url: "http://localhost:3000/voice/webhook",
343
+ method: "POST",
344
+ remoteAddress: "203.0.113.10",
345
+ },
346
+ authToken,
347
+ { trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
348
+ );
349
+
350
+ expect(result.ok).toBe(true);
351
+ expect(result.verificationUrl).toBe(webhookUrl);
352
+ });
353
+
354
+ it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => {
355
+ const authToken = "test-auth-token";
356
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
357
+
358
+ const result = verifyTwilioWebhook(
359
+ {
360
+ headers: {
361
+ host: "legitimate.example.com",
362
+ "x-forwarded-proto": "https",
363
+ "x-forwarded-host": "proxy.example.com",
364
+ "x-twilio-signature": "invalid",
365
+ },
366
+ rawBody: postBody,
367
+ url: "http://localhost:3000/voice/webhook",
368
+ method: "POST",
369
+ },
370
+ authToken,
371
+ { trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
372
+ );
373
+
374
+ expect(result.ok).toBe(false);
375
+ expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
376
+ });
251
377
  });
@@ -57,9 +57,119 @@ function timingSafeEqual(a: string, b: string): boolean {
57
57
  return crypto.timingSafeEqual(bufA, bufB);
58
58
  }
59
59
 
60
+ /**
61
+ * Configuration for secure URL reconstruction.
62
+ */
63
+ export interface WebhookUrlOptions {
64
+ /**
65
+ * Whitelist of allowed hostnames. If provided, only these hosts will be
66
+ * accepted from forwarding headers. This prevents host header injection attacks.
67
+ *
68
+ * SECURITY: You must provide this OR set trustForwardingHeaders=true to use
69
+ * X-Forwarded-Host headers. Without either, forwarding headers are ignored.
70
+ */
71
+ allowedHosts?: string[];
72
+ /**
73
+ * Explicitly trust X-Forwarded-* headers without a whitelist.
74
+ * WARNING: Only set this to true if you trust your proxy configuration
75
+ * and understand the security implications.
76
+ *
77
+ * @default false
78
+ */
79
+ trustForwardingHeaders?: boolean;
80
+ /**
81
+ * List of trusted proxy IP addresses. X-Forwarded-* headers will only be
82
+ * trusted if the request comes from one of these IPs.
83
+ * Requires remoteIP to be set for validation.
84
+ */
85
+ trustedProxyIPs?: string[];
86
+ /**
87
+ * The IP address of the incoming request (for proxy validation).
88
+ */
89
+ remoteIP?: string;
90
+ }
91
+
92
+ /**
93
+ * Validate that a hostname matches RFC 1123 format.
94
+ * Prevents injection of malformed hostnames.
95
+ */
96
+ function isValidHostname(hostname: string): boolean {
97
+ if (!hostname || hostname.length > 253) {
98
+ return false;
99
+ }
100
+ // RFC 1123 hostname: alphanumeric, hyphens, dots
101
+ // Also allow ngrok/tunnel subdomains
102
+ const hostnameRegex =
103
+ /^([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])?$/;
104
+ return hostnameRegex.test(hostname);
105
+ }
106
+
107
+ /**
108
+ * Safely extract hostname from a host header value.
109
+ * Handles IPv6 addresses and prevents injection via malformed values.
110
+ */
111
+ function extractHostname(hostHeader: string): string | null {
112
+ if (!hostHeader) {
113
+ return null;
114
+ }
115
+
116
+ let hostname: string;
117
+
118
+ // Handle IPv6 addresses: [::1]:8080
119
+ if (hostHeader.startsWith("[")) {
120
+ const endBracket = hostHeader.indexOf("]");
121
+ if (endBracket === -1) {
122
+ return null; // Malformed IPv6
123
+ }
124
+ hostname = hostHeader.substring(1, endBracket);
125
+ return hostname.toLowerCase();
126
+ }
127
+
128
+ // Handle IPv4/domain with optional port
129
+ // Check for @ which could indicate user info injection attempt
130
+ if (hostHeader.includes("@")) {
131
+ return null; // Reject potential injection: attacker.com:80@legitimate.com
132
+ }
133
+
134
+ hostname = hostHeader.split(":")[0];
135
+
136
+ // Validate the extracted hostname
137
+ if (!isValidHostname(hostname)) {
138
+ return null;
139
+ }
140
+
141
+ return hostname.toLowerCase();
142
+ }
143
+
144
+ function extractHostnameFromHeader(headerValue: string): string | null {
145
+ const first = headerValue.split(",")[0]?.trim();
146
+ if (!first) {
147
+ return null;
148
+ }
149
+ return extractHostname(first);
150
+ }
151
+
152
+ function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
153
+ if (!allowedHosts || allowedHosts.length === 0) {
154
+ return null;
155
+ }
156
+ const normalized = new Set<string>();
157
+ for (const host of allowedHosts) {
158
+ const extracted = extractHostname(host.trim());
159
+ if (extracted) {
160
+ normalized.add(extracted);
161
+ }
162
+ }
163
+ return normalized.size > 0 ? normalized : null;
164
+ }
165
+
60
166
  /**
61
167
  * Reconstruct the public webhook URL from request headers.
62
168
  *
169
+ * SECURITY: This function validates host headers to prevent host header
170
+ * injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
171
+ * always provide allowedHosts to whitelist valid hostnames.
172
+ *
63
173
  * When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
64
174
  * used by Twilio differs from the local request URL. We use standard
65
175
  * forwarding headers to reconstruct it.
@@ -70,17 +180,84 @@ function timingSafeEqual(a: string, b: string): boolean {
70
180
  * 3. Ngrok-Forwarded-Host (ngrok specific)
71
181
  * 4. Host header (direct connection)
72
182
  */
73
- export function reconstructWebhookUrl(ctx: WebhookContext): string {
183
+ export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
74
184
  const { headers } = ctx;
75
185
 
76
- const proto = getHeader(headers, "x-forwarded-proto") || "https";
186
+ // SECURITY: Only trust forwarding headers if explicitly configured.
187
+ // Either allowedHosts must be set (for whitelist validation) or
188
+ // trustForwardingHeaders must be true (explicit opt-in to trust).
189
+ const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
190
+ const hasAllowedHosts = allowedHosts !== null;
191
+ const explicitlyTrusted = options?.trustForwardingHeaders === true;
192
+
193
+ // Also check trusted proxy IPs if configured
194
+ const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
195
+ const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
196
+ const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
197
+ const fromTrustedProxy =
198
+ !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
199
+
200
+ // Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
201
+ const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
202
+
203
+ const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
204
+
205
+ // Determine protocol - only trust X-Forwarded-Proto from trusted proxies
206
+ let proto = "https";
207
+ if (shouldTrustForwardingHeaders) {
208
+ const forwardedProto = getHeader(headers, "x-forwarded-proto");
209
+ if (forwardedProto === "http" || forwardedProto === "https") {
210
+ proto = forwardedProto;
211
+ }
212
+ }
77
213
 
78
- const forwardedHost =
79
- getHeader(headers, "x-forwarded-host") ||
80
- getHeader(headers, "x-original-host") ||
81
- getHeader(headers, "ngrok-forwarded-host") ||
82
- getHeader(headers, "host") ||
83
- "";
214
+ // Determine host - with security validation
215
+ let host: string | null = null;
216
+
217
+ if (shouldTrustForwardingHeaders) {
218
+ // Try forwarding headers in priority order
219
+ const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
220
+
221
+ for (const headerName of forwardingHeaders) {
222
+ const headerValue = getHeader(headers, headerName);
223
+ if (headerValue) {
224
+ const extracted = extractHostnameFromHeader(headerValue);
225
+ if (extracted && isAllowedForwardedHost(extracted)) {
226
+ host = extracted;
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ // Fallback to Host header if no valid forwarding header found
234
+ if (!host) {
235
+ const hostHeader = getHeader(headers, "host");
236
+ if (hostHeader) {
237
+ const extracted = extractHostnameFromHeader(hostHeader);
238
+ if (extracted) {
239
+ host = extracted;
240
+ }
241
+ }
242
+ }
243
+
244
+ // Last resort: try to extract from ctx.url
245
+ if (!host) {
246
+ try {
247
+ const parsed = new URL(ctx.url);
248
+ const extracted = extractHostname(parsed.host);
249
+ if (extracted) {
250
+ host = extracted;
251
+ }
252
+ } catch {
253
+ // URL parsing failed - use empty string (will result in invalid URL)
254
+ host = "";
255
+ }
256
+ }
257
+
258
+ if (!host) {
259
+ host = "";
260
+ }
84
261
 
85
262
  // Extract path from the context URL (fallback to "/" on parse failure)
86
263
  let path = "/";
@@ -91,15 +268,16 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
91
268
  // URL parsing failed
92
269
  }
93
270
 
94
- // Remove port from host (ngrok URLs don't have ports)
95
- const host = forwardedHost.split(":")[0] || forwardedHost;
96
-
97
271
  return `${proto}://${host}${path}`;
98
272
  }
99
273
 
100
- function buildTwilioVerificationUrl(ctx: WebhookContext, publicUrl?: string): string {
274
+ function buildTwilioVerificationUrl(
275
+ ctx: WebhookContext,
276
+ publicUrl?: string,
277
+ urlOptions?: WebhookUrlOptions,
278
+ ): string {
101
279
  if (!publicUrl) {
102
- return reconstructWebhookUrl(ctx);
280
+ return reconstructWebhookUrl(ctx, urlOptions);
103
281
  }
104
282
 
105
283
  try {
@@ -154,9 +332,6 @@ export interface TwilioVerificationResult {
154
332
 
155
333
  /**
156
334
  * Verify Twilio webhook with full context and detailed result.
157
- *
158
- * Handles the special case of ngrok free tier where signature validation
159
- * may fail due to URL discrepancies (ngrok adds interstitial page handling).
160
335
  */
161
336
  export function verifyTwilioWebhook(
162
337
  ctx: WebhookContext,
@@ -168,6 +343,26 @@ export function verifyTwilioWebhook(
168
343
  allowNgrokFreeTierLoopbackBypass?: boolean;
169
344
  /** Skip verification entirely (only for development) */
170
345
  skipVerification?: boolean;
346
+ /**
347
+ * Whitelist of allowed hostnames for host header validation.
348
+ * Prevents host header injection attacks.
349
+ */
350
+ allowedHosts?: string[];
351
+ /**
352
+ * Explicitly trust X-Forwarded-* headers without a whitelist.
353
+ * WARNING: Only enable if you trust your proxy configuration.
354
+ * @default false
355
+ */
356
+ trustForwardingHeaders?: boolean;
357
+ /**
358
+ * List of trusted proxy IP addresses. X-Forwarded-* headers will only
359
+ * be trusted from these IPs.
360
+ */
361
+ trustedProxyIPs?: string[];
362
+ /**
363
+ * The remote IP address of the request (for proxy validation).
364
+ */
365
+ remoteIP?: string;
171
366
  },
172
367
  ): TwilioVerificationResult {
173
368
  // Allow skipping verification for development/testing
@@ -181,8 +376,16 @@ export function verifyTwilioWebhook(
181
376
  return { ok: false, reason: "Missing X-Twilio-Signature header" };
182
377
  }
183
378
 
379
+ const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
380
+ const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
381
+
184
382
  // Reconstruct the URL Twilio used
185
- const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
383
+ const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
384
+ allowedHosts: options?.allowedHosts,
385
+ trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
386
+ trustedProxyIPs: options?.trustedProxyIPs,
387
+ remoteIP: options?.remoteIP,
388
+ });
186
389
 
187
390
  // Parse the body as URL-encoded params
188
391
  const params = new URLSearchParams(ctx.rawBody);
@@ -198,11 +401,7 @@ export function verifyTwilioWebhook(
198
401
  const isNgrokFreeTier =
199
402
  verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
200
403
 
201
- if (
202
- isNgrokFreeTier &&
203
- options?.allowNgrokFreeTierLoopbackBypass &&
204
- isLoopbackAddress(ctx.remoteAddress)
205
- ) {
404
+ if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
206
405
  console.warn(
207
406
  "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
208
407
  );
@@ -384,6 +583,26 @@ export function verifyPlivoWebhook(
384
583
  publicUrl?: string;
385
584
  /** Skip verification entirely (only for development) */
386
585
  skipVerification?: boolean;
586
+ /**
587
+ * Whitelist of allowed hostnames for host header validation.
588
+ * Prevents host header injection attacks.
589
+ */
590
+ allowedHosts?: string[];
591
+ /**
592
+ * Explicitly trust X-Forwarded-* headers without a whitelist.
593
+ * WARNING: Only enable if you trust your proxy configuration.
594
+ * @default false
595
+ */
596
+ trustForwardingHeaders?: boolean;
597
+ /**
598
+ * List of trusted proxy IP addresses. X-Forwarded-* headers will only
599
+ * be trusted from these IPs.
600
+ */
601
+ trustedProxyIPs?: string[];
602
+ /**
603
+ * The remote IP address of the request (for proxy validation).
604
+ */
605
+ remoteIP?: string;
387
606
  },
388
607
  ): PlivoVerificationResult {
389
608
  if (options?.skipVerification) {
@@ -395,7 +614,12 @@ export function verifyPlivoWebhook(
395
614
  const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
396
615
  const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
397
616
 
398
- const reconstructed = reconstructWebhookUrl(ctx);
617
+ const reconstructed = reconstructWebhookUrl(ctx, {
618
+ allowedHosts: options?.allowedHosts,
619
+ trustForwardingHeaders: options?.trustForwardingHeaders,
620
+ trustedProxyIPs: options?.trustedProxyIPs,
621
+ remoteIP: options?.remoteIP,
622
+ });
399
623
  let verificationUrl = reconstructed;
400
624
  if (options?.publicUrl) {
401
625
  try {