@openclaw/voice-call 2026.2.1 → 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 +7 -1
- package/package.json +1 -1
- package/src/allowlist.ts +19 -0
- package/src/config.test.ts +33 -0
- package/src/config.ts +49 -6
- package/src/core-bridge.ts +19 -60
- package/src/manager/events.ts +7 -5
- package/src/manager.test.ts +120 -1
- package/src/manager.ts +27 -6
- package/src/media-stream.ts +34 -2
- package/src/providers/plivo.ts +18 -5
- package/src/providers/telnyx.ts +16 -3
- package/src/providers/twilio/webhook.ts +4 -0
- package/src/providers/twilio.test.ts +5 -5
- package/src/providers/twilio.ts +47 -4
- package/src/runtime.ts +14 -6
- package/src/webhook-security.test.ts +130 -4
- package/src/webhook-security.ts +247 -23
- package/src/webhook.ts +38 -3
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 {
|
package/src/webhook.ts
CHANGED
|
@@ -11,6 +11,8 @@ import type { NormalizedEvent, WebhookContext } from "./types.js";
|
|
|
11
11
|
import { MediaStreamHandler } from "./media-stream.js";
|
|
12
12
|
import { OpenAIRealtimeSTTProvider } from "./providers/stt-openai-realtime.js";
|
|
13
13
|
|
|
14
|
+
const MAX_WEBHOOK_BODY_BYTES = 1024 * 1024;
|
|
15
|
+
|
|
14
16
|
/**
|
|
15
17
|
* HTTP server for receiving voice call webhooks from providers.
|
|
16
18
|
* Supports WebSocket upgrades for media streams when streaming is enabled.
|
|
@@ -69,6 +71,20 @@ export class VoiceCallWebhookServer {
|
|
|
69
71
|
|
|
70
72
|
const streamConfig: MediaStreamConfig = {
|
|
71
73
|
sttProvider,
|
|
74
|
+
shouldAcceptStream: ({ callId, token }) => {
|
|
75
|
+
const call = this.manager.getCallByProviderCallId(callId);
|
|
76
|
+
if (!call) {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (this.provider.name === "twilio") {
|
|
80
|
+
const twilio = this.provider as TwilioProvider;
|
|
81
|
+
if (!twilio.isValidStreamToken(callId, token)) {
|
|
82
|
+
console.warn(`[voice-call] Rejecting media stream: invalid token for ${callId}`);
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
},
|
|
72
88
|
onTranscript: (providerCallId, transcript) => {
|
|
73
89
|
console.log(`[voice-call] Transcript for ${providerCallId}: ${transcript}`);
|
|
74
90
|
|
|
@@ -224,7 +240,17 @@ export class VoiceCallWebhookServer {
|
|
|
224
240
|
}
|
|
225
241
|
|
|
226
242
|
// Read body
|
|
227
|
-
|
|
243
|
+
let body = "";
|
|
244
|
+
try {
|
|
245
|
+
body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
if (err instanceof Error && err.message === "PayloadTooLarge") {
|
|
248
|
+
res.statusCode = 413;
|
|
249
|
+
res.end("Payload Too Large");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
228
254
|
|
|
229
255
|
// Build webhook context
|
|
230
256
|
const ctx: WebhookContext = {
|
|
@@ -272,10 +298,19 @@ export class VoiceCallWebhookServer {
|
|
|
272
298
|
/**
|
|
273
299
|
* Read request body as string.
|
|
274
300
|
*/
|
|
275
|
-
private readBody(req: http.IncomingMessage): Promise<string> {
|
|
301
|
+
private readBody(req: http.IncomingMessage, maxBytes: number): Promise<string> {
|
|
276
302
|
return new Promise((resolve, reject) => {
|
|
277
303
|
const chunks: Buffer[] = [];
|
|
278
|
-
|
|
304
|
+
let totalBytes = 0;
|
|
305
|
+
req.on("data", (chunk: Buffer) => {
|
|
306
|
+
totalBytes += chunk.length;
|
|
307
|
+
if (totalBytes > maxBytes) {
|
|
308
|
+
req.destroy();
|
|
309
|
+
reject(new Error("PayloadTooLarge"));
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
chunks.push(chunk);
|
|
313
|
+
});
|
|
279
314
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
280
315
|
req.on("error", reject);
|
|
281
316
|
});
|