fix: gate ngrok free-tier bypass to loopback

This commit is contained in:
Peter Steinberger
2026-01-26 22:26:22 +00:00
parent fe1f2d971a
commit b3a60af71c
14 changed files with 94 additions and 17 deletions

View File

@@ -104,6 +104,7 @@ Notes:
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only. - `skipSignatureVerification` is for local testing only.
- If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. - 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. - 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 ## TTS for calls

View File

@@ -6,6 +6,7 @@
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deepmerges with core). - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deepmerges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls. - Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields. - 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 ## 2026.1.23

View File

@@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`:
Notes: Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls). - `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 ## TTS for calls

View File

@@ -78,8 +78,8 @@
"label": "ngrok Domain", "label": "ngrok Domain",
"advanced": true "advanced": true
}, },
"tunnel.allowNgrokFreeTier": { "tunnel.allowNgrokFreeTierLoopbackBypass": {
"label": "Allow ngrok Free Tier", "label": "Allow ngrok Free Tier (Loopback Bypass)",
"advanced": true "advanced": true
}, },
"streaming.enabled": { "streaming.enabled": {
@@ -330,7 +330,7 @@
"ngrokDomain": { "ngrokDomain": {
"type": "string" "type": "string"
}, },
"allowNgrokFreeTier": { "allowNgrokFreeTierLoopbackBypass": {
"type": "boolean" "type": "boolean"
} }
} }

View File

@@ -62,8 +62,8 @@ const voiceCallConfigSchema = {
advanced: true, advanced: true,
}, },
"tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true }, "tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true },
"tunnel.allowNgrokFreeTier": { "tunnel.allowNgrokFreeTierLoopbackBypass": {
label: "Allow ngrok Free Tier", label: "Allow ngrok Free Tier (Loopback Bypass)",
advanced: true, advanced: true,
}, },
"streaming.enabled": { label: "Enable Streaming", advanced: true }, "streaming.enabled": { label: "Enable Streaming", advanced: true },

View File

@@ -19,7 +19,7 @@ function createBaseConfig(
maxConcurrentCalls: 1, maxConcurrentCalls: 1,
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTier: false }, tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
streaming: { streaming: {
enabled: false, enabled: false,
sttProvider: "openai-realtime", sttProvider: "openai-realtime",

View File

@@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z
/** /**
* Allow ngrok free tier compatibility mode. * Allow ngrok free tier compatibility mode.
* When true, signature verification failures on ngrok-free.app URLs * 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() .strict()
.default({ provider: "none", allowNgrokFreeTier: false }); .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>; export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
// Tunnel Config // Tunnel Config
resolved.tunnel = resolved.tunnel ?? { resolved.tunnel = resolved.tunnel ?? {
provider: "none", provider: "none",
allowNgrokFreeTier: false, allowNgrokFreeTierLoopbackBypass: false,
}; };
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ||
resolved.tunnel.allowNgrokFreeTier ||
false;
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken =
resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain =

View File

@@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
* @see https://www.twilio.com/docs/voice/media-streams * @see https://www.twilio.com/docs/voice/media-streams
*/ */
export interface TwilioProviderOptions { export interface TwilioProviderOptions {
/** Allow ngrok free tier compatibility mode (less secure) */ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTier?: boolean; allowNgrokFreeTierLoopbackBypass?: boolean;
/** Override public URL for signature verification */ /** Override public URL for signature verification */
publicUrl?: string; publicUrl?: string;
/** Path for media stream WebSocket (e.g., /voice/stream) */ /** Path for media stream WebSocket (e.g., /voice/stream) */

View File

@@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: {
}): WebhookVerificationResult { }): WebhookVerificationResult {
const result = verifyTwilioWebhook(params.ctx, params.authToken, { const result = verifyTwilioWebhook(params.ctx, params.authToken, {
publicUrl: params.currentPublicUrl || undefined, publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false, allowNgrokFreeTierLoopbackBypass:
params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification, skipVerification: params.options.skipVerification,
}); });

View File

@@ -33,7 +33,19 @@ type Logger = {
debug: (message: string) => void; 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 { 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) { switch (config.provider) {
case "telnyx": case "telnyx":
return new TelnyxProvider({ return new TelnyxProvider({
@@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken, authToken: config.twilio?.authToken,
}, },
{ {
allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false, allowNgrokFreeTierLoopbackBypass,
publicUrl: config.publicUrl, publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification, skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled streamPath: config.streaming?.enabled

View File

@@ -180,6 +180,7 @@ export type WebhookContext = {
url: string; url: string;
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
query?: Record<string, string | string[] | undefined>; query?: Record<string, string | string[] | undefined>;
remoteAddress?: string;
}; };
export type ProviderWebhookParseResult = { export type ProviderWebhookParseResult = {

View File

@@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody, rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook", url: "http://127.0.0.1:3334/voice/webhook",
method: "POST", method: "POST",
remoteAddress: "203.0.113.10",
}, },
authToken, authToken,
{ allowNgrokFreeTier: true }, { allowNgrokFreeTierLoopbackBypass: true },
); );
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true); expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/); 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/);
});
}); });

View File

@@ -131,6 +131,13 @@ function getHeader(
return value; 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. * Result of Twilio webhook verification with detailed info.
*/ */
@@ -155,8 +162,8 @@ export function verifyTwilioWebhook(
options?: { options?: {
/** Override the public URL (e.g., from config) */ /** Override the public URL (e.g., from config) */
publicUrl?: string; publicUrl?: string;
/** Allow ngrok free tier compatibility mode (less secure) */ /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
allowNgrokFreeTier?: boolean; allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */ /** Skip verification entirely (only for development) */
skipVerification?: boolean; skipVerification?: boolean;
}, },
@@ -195,6 +202,22 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io"); 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 { return {
ok: false, ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`, reason: `Invalid signature for URL: ${verificationUrl}`,

View File

@@ -252,6 +252,7 @@ export class VoiceCallWebhookServer {
url: `http://${req.headers.host}${req.url}`, url: `http://${req.headers.host}${req.url}`,
method: "POST", method: "POST",
query: Object.fromEntries(url.searchParams), query: Object.fromEntries(url.searchParams),
remoteAddress: req.socket.remoteAddress ?? undefined,
}; };
// Verify signature // Verify signature