Skip to content

Commit a749db9

Browse files
committed
fix: harden voice-call webhook verification
1 parent fa4b28d commit a749db9

File tree

11 files changed

+495
-42
lines changed

11 files changed

+495
-42
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
1616
- Web UI: apply button styling to the new-messages indicator.
1717
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
18+
- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
1819

1920
## 2026.2.2-3
2021

docs/platforms/fly.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -446,15 +446,18 @@ Example voice-call config with ngrok:
446446
"enabled": true,
447447
"config": {
448448
"provider": "twilio",
449-
"tunnel": { "provider": "ngrok" }
449+
"tunnel": { "provider": "ngrok" },
450+
"webhookSecurity": {
451+
"allowedHosts": ["example.ngrok.app"]
452+
}
450453
}
451454
}
452455
}
453456
}
454457
}
455458
```
456459

457-
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself.
460+
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. Set `webhookSecurity.allowedHosts` to the public tunnel hostname so forwarded host headers are accepted.
458461

459462
### Security benefits
460463

docs/plugins/voice-call.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ Set config under `plugins.entries.voice-call.config`:
8181
path: "/voice/webhook",
8282
},
8383

84+
// Webhook security (recommended for tunnels/proxies)
85+
webhookSecurity: {
86+
allowedHosts: ["voice.example.com"],
87+
trustedProxyIPs: ["100.64.0.1"],
88+
},
89+
8490
// Public exposure (pick one)
8591
// publicUrl: "https://example.ngrok.app/voice/webhook",
8692
// tunnel: { provider: "ngrok" },
@@ -111,6 +117,38 @@ Notes:
111117
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
112118
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
113119

120+
## Webhook Security
121+
122+
When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the
123+
public URL for signature verification. These options control which forwarded
124+
headers are trusted.
125+
126+
`webhookSecurity.allowedHosts` allowlists hosts from forwarding headers.
127+
128+
`webhookSecurity.trustForwardingHeaders` trusts forwarded headers without an allowlist.
129+
130+
`webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request
131+
remote IP matches the list.
132+
133+
Example with a stable public host:
134+
135+
```json5
136+
{
137+
plugins: {
138+
entries: {
139+
"voice-call": {
140+
config: {
141+
publicUrl: "https://voice.example.com/voice/webhook",
142+
webhookSecurity: {
143+
allowedHosts: ["voice.example.com"],
144+
},
145+
},
146+
},
147+
},
148+
},
149+
}
150+
```
151+
114152
## TTS for calls
115153

116154
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for

extensions/voice-call/src/config.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
1717
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
1818
tailscale: { mode: "off", path: "/voice/webhook" },
1919
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
20+
webhookSecurity: {
21+
allowedHosts: [],
22+
trustForwardingHeaders: false,
23+
trustedProxyIPs: [],
24+
},
2025
streaming: {
2126
enabled: false,
2227
sttProvider: "openai-realtime",

extensions/voice-call/src/config.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
211211
* will be allowed only for loopback requests (ngrok local agent).
212212
*/
213213
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
214-
/**
215-
* Legacy ngrok free tier compatibility mode (deprecated).
216-
* Use allowNgrokFreeTierLoopbackBypass instead.
217-
*/
218-
allowNgrokFreeTier: z.boolean().optional(),
219214
})
220215
.strict()
221216
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
222217
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
223218

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+
224245
// -----------------------------------------------------------------------------
225246
// Outbound Call Configuration
226247
// -----------------------------------------------------------------------------
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
339360
/** Tunnel configuration (unified ngrok/tailscale) */
340361
tunnel: VoiceCallTunnelConfigSchema,
341362

363+
/** Webhook signature reconstruction and proxy trust configuration */
364+
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
365+
342366
/** Real-time audio streaming configuration */
343367
streaming: VoiceCallStreamingConfigSchema,
344368

@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
409433
allowNgrokFreeTierLoopbackBypass: false,
410434
};
411435
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
412-
resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
436+
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
413437
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
414438
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
415439

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+
416451
return resolved;
417452
}
418453

extensions/voice-call/src/providers/plivo.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from "node:crypto";
2-
import type { PlivoConfig } from "../config.js";
2+
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
33
import type {
44
HangupCallInput,
55
InitiateCallInput,
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
2323
skipVerification?: boolean;
2424
/** Outbound ring timeout in seconds */
2525
ringTimeoutSec?: number;
26+
/** Webhook security options (forwarded headers/allowlist) */
27+
webhookSecurity?: WebhookSecurityConfig;
2628
}
2729

2830
type PendingSpeak = { text: string; locale?: string };
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
9294
const result = verifyPlivoWebhook(ctx, this.authToken, {
9395
publicUrl: this.options.publicUrl,
9496
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,
95101
});
96102

97103
if (!result.ok) {
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
112118
// Keep providerCallId mapping for later call control.
113119
const callUuid = parsed.get("CallUUID") || undefined;
114120
if (callUuid) {
115-
const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
121+
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
116122
if (webhookBase) {
117123
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
118124
}
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
444450
ctx: WebhookContext,
445451
opts: { flow: string; callId?: string },
446452
): string | null {
447-
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
453+
const base = this.baseWebhookUrlFromCtx(ctx);
448454
if (!base) {
449455
return null;
450456
}
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
458464
return u.toString();
459465
}
460466

461-
private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
467+
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
462468
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+
);
464477
return `${u.origin}${u.pathname}`;
465478
} catch {
466479
return null;

extensions/voice-call/src/providers/twilio.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from "node:crypto";
2-
import type { TwilioConfig } from "../config.js";
2+
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
33
import type { MediaStreamHandler } from "../media-stream.js";
44
import type { TelephonyTtsProvider } from "../telephony-tts.js";
55
import type {
@@ -38,6 +38,8 @@ export interface TwilioProviderOptions {
3838
streamPath?: string;
3939
/** Skip webhook signature verification (development only) */
4040
skipVerification?: boolean;
41+
/** Webhook security options (forwarded headers/allowlist) */
42+
webhookSecurity?: WebhookSecurityConfig;
4143
}
4244

4345
export class TwilioProvider implements VoiceCallProvider {

extensions/voice-call/src/providers/twilio/webhook.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ export function verifyTwilioProviderWebhook(params: {
1212
publicUrl: params.currentPublicUrl || undefined,
1313
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
1414
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,
1519
});
1620

1721
if (!result.ok) {

extensions/voice-call/src/runtime.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
4444
const allowNgrokFreeTierLoopbackBypass =
4545
config.tunnel?.provider === "ngrok" &&
4646
isLoopbackBind(config.serve?.bind) &&
47-
(config.tunnel?.allowNgrokFreeTierLoopbackBypass || config.tunnel?.allowNgrokFreeTier || false);
47+
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
4848

4949
switch (config.provider) {
5050
case "telnyx":
@@ -70,6 +70,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
7070
publicUrl: config.publicUrl,
7171
skipVerification: config.skipSignatureVerification,
7272
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
73+
webhookSecurity: config.webhookSecurity,
7374
},
7475
);
7576
case "plivo":
@@ -82,6 +83,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
8283
publicUrl: config.publicUrl,
8384
skipVerification: config.skipSignatureVerification,
8485
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
86+
webhookSecurity: config.webhookSecurity,
8587
},
8688
);
8789
case "mock":

0 commit comments

Comments
 (0)