diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index cd574b26e..46713c939 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -104,6 +104,7 @@ Notes: - `mock` is a local dev provider (no network calls). - `skipSignatureVerification` is for local testing only. - If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. +- `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. - 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. ## TTS for calls diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index a8721d47d..588817858 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -6,6 +6,7 @@ - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core). - Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls. - Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields. +- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`. ## 2026.1.23 diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index d96f90392..5f009aa28 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`: Notes: - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - `mock` is a local dev provider (no network calls). +- `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. ## TTS for calls diff --git a/extensions/voice-call/clawdbot.plugin.json b/extensions/voice-call/clawdbot.plugin.json index 2a4f04466..cfac7ad9d 100644 --- a/extensions/voice-call/clawdbot.plugin.json +++ b/extensions/voice-call/clawdbot.plugin.json @@ -78,8 +78,8 @@ "label": "ngrok Domain", "advanced": true }, - "tunnel.allowNgrokFreeTier": { - "label": "Allow ngrok Free Tier", + "tunnel.allowNgrokFreeTierLoopbackBypass": { + "label": "Allow ngrok Free Tier (Loopback Bypass)", "advanced": true }, "streaming.enabled": { @@ -330,7 +330,7 @@ "ngrokDomain": { "type": "string" }, - "allowNgrokFreeTier": { + "allowNgrokFreeTierLoopbackBypass": { "type": "boolean" } } diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 60076bbe2..60cb64eb2 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -62,8 +62,8 @@ const voiceCallConfigSchema = { advanced: true, }, "tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true }, - "tunnel.allowNgrokFreeTier": { - label: "Allow ngrok Free Tier", + "tunnel.allowNgrokFreeTierLoopbackBypass": { + label: "Allow ngrok Free Tier (Loopback Bypass)", advanced: true, }, "streaming.enabled": { label: "Enable Streaming", advanced: true }, diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index aac9fe44c..dde17e122 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -19,7 +19,7 @@ function createBaseConfig( maxConcurrentCalls: 1, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" }, - tunnel: { provider: "none", allowNgrokFreeTier: false }, + tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false }, streaming: { enabled: false, sttProvider: "openai-realtime", diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 99916e49d..7784406e7 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z /** * Allow ngrok free tier compatibility mode. * When true, signature verification failures on ngrok-free.app URLs - * will include extra diagnostics. Signature verification is still required. + * will be allowed only for loopback requests (ngrok local agent). */ - allowNgrokFreeTier: z.boolean().default(false), + allowNgrokFreeTierLoopbackBypass: z.boolean().default(false), + /** + * Legacy ngrok free tier compatibility mode (deprecated). + * Use allowNgrokFreeTierLoopbackBypass instead. + */ + allowNgrokFreeTier: z.boolean().optional(), }) .strict() - .default({ provider: "none", allowNgrokFreeTier: false }); + .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false }); export type VoiceCallTunnelConfig = z.infer; // ----------------------------------------------------------------------------- @@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig // Tunnel Config resolved.tunnel = resolved.tunnel ?? { provider: "none", - allowNgrokFreeTier: false, + allowNgrokFreeTierLoopbackBypass: false, }; + resolved.tunnel.allowNgrokFreeTierLoopbackBypass = + resolved.tunnel.allowNgrokFreeTierLoopbackBypass || + resolved.tunnel.allowNgrokFreeTier || + false; resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; resolved.tunnel.ngrokDomain = diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index be9dd6eda..87c0f244d 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; * @see https://www.twilio.com/docs/voice/media-streams */ export interface TwilioProviderOptions { - /** Allow ngrok free tier compatibility mode (less secure) */ - allowNgrokFreeTier?: boolean; + /** Allow ngrok free tier compatibility mode (loopback only, less secure) */ + allowNgrokFreeTierLoopbackBypass?: boolean; /** Override public URL for signature verification */ publicUrl?: string; /** Path for media stream WebSocket (e.g., /voice/stream) */ diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 1cddcb164..d5c3abb95 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: { }): WebhookVerificationResult { const result = verifyTwilioWebhook(params.ctx, params.authToken, { publicUrl: params.currentPublicUrl || undefined, - allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false, + allowNgrokFreeTierLoopbackBypass: + params.options.allowNgrokFreeTierLoopbackBypass ?? false, skipVerification: params.options.skipVerification, }); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index ffa95ddff..6f638ab5b 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -33,7 +33,19 @@ type Logger = { debug: (message: string) => void; }; +function isLoopbackBind(bind: string | undefined): boolean { + if (!bind) return false; + return bind === "127.0.0.1" || bind === "::1" || bind === "localhost"; +} + function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { + const allowNgrokFreeTierLoopbackBypass = + config.tunnel?.provider === "ngrok" && + isLoopbackBind(config.serve?.bind) && + (config.tunnel?.allowNgrokFreeTierLoopbackBypass || + config.tunnel?.allowNgrokFreeTier || + false); + switch (config.provider) { case "telnyx": return new TelnyxProvider({ @@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { authToken: config.twilio?.authToken, }, { - allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false, + allowNgrokFreeTierLoopbackBypass, publicUrl: config.publicUrl, skipVerification: config.skipSignatureVerification, streamPath: config.streaming?.enabled diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 7f3928778..68cca11e6 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -180,6 +180,7 @@ export type WebhookContext = { url: string; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; query?: Record; + remoteAddress?: string; }; export type ProviderWebhookParseResult = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 98d8a451c..3db2983ec 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => { rawBody: postBody, url: "http://127.0.0.1:3334/voice/webhook", method: "POST", + remoteAddress: "203.0.113.10", }, authToken, - { allowNgrokFreeTier: true }, + { allowNgrokFreeTierLoopbackBypass: true }, ); expect(result.ok).toBe(false); expect(result.isNgrokFreeTier).toBe(true); expect(result.reason).toMatch(/Invalid signature/); }); + + it("allows invalid signatures for ngrok free tier only on loopback", () => { + const authToken = "test-auth-token"; + const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; + + const result = verifyTwilioWebhook( + { + headers: { + host: "127.0.0.1:3334", + "x-forwarded-proto": "https", + "x-forwarded-host": "local.ngrok-free.app", + "x-twilio-signature": "invalid", + }, + rawBody: postBody, + url: "http://127.0.0.1:3334/voice/webhook", + method: "POST", + remoteAddress: "127.0.0.1", + }, + authToken, + { allowNgrokFreeTierLoopbackBypass: true }, + ); + + expect(result.ok).toBe(true); + expect(result.isNgrokFreeTier).toBe(true); + expect(result.reason).toMatch(/compatibility mode/); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 98b1d9837..6c7d4d9ab 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -131,6 +131,13 @@ function getHeader( return value; } +function isLoopbackAddress(address?: string): boolean { + if (!address) return false; + if (address === "127.0.0.1" || address === "::1") return true; + if (address.startsWith("::ffff:127.")) return true; + return false; +} + /** * Result of Twilio webhook verification with detailed info. */ @@ -155,8 +162,8 @@ export function verifyTwilioWebhook( options?: { /** Override the public URL (e.g., from config) */ publicUrl?: string; - /** Allow ngrok free tier compatibility mode (less secure) */ - allowNgrokFreeTier?: boolean; + /** Allow ngrok free tier compatibility mode (loopback only, less secure) */ + allowNgrokFreeTierLoopbackBypass?: boolean; /** Skip verification entirely (only for development) */ skipVerification?: boolean; }, @@ -195,6 +202,22 @@ export function verifyTwilioWebhook( verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io"); + if ( + isNgrokFreeTier && + options?.allowNgrokFreeTierLoopbackBypass && + isLoopbackAddress(ctx.remoteAddress) + ) { + console.warn( + "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)", + ); + return { + ok: true, + reason: "ngrok free tier compatibility mode (loopback only)", + verificationUrl, + isNgrokFreeTier: true, + }; + } + return { ok: false, reason: `Invalid signature for URL: ${verificationUrl}`, diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 6ab4d0eed..09e96ffed 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -252,6 +252,7 @@ export class VoiceCallWebhookServer { url: `http://${req.headers.host}${req.url}`, method: "POST", query: Object.fromEntries(url.searchParams), + remoteAddress: req.socket.remoteAddress ?? undefined, }; // Verify signature