@openclaw/voice-call 2026.2.2 → 2026.2.6
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 +12 -0
- package/package.json +1 -1
- package/src/config.test.ts +5 -0
- package/src/config.ts +41 -6
- package/src/manager.test.ts +30 -0
- package/src/providers/plivo.ts +18 -5
- package/src/providers/twilio/webhook.ts +4 -0
- package/src/providers/twilio.ts +3 -1
- package/src/runtime.ts +3 -1
- package/src/webhook-security.test.ts +130 -4
- package/src/webhook-security.ts +247 -23
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
package/src/config.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/manager.test.ts
CHANGED
|
@@ -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,
|
package/src/providers/plivo.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
|
467
|
+
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
|
|
462
468
|
try {
|
|
463
|
-
const u = new URL(
|
|
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) {
|
package/src/providers/twilio.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/src/webhook-security.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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(
|
|
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 {
|