fix: gate ngrok free-tier bypass to loopback
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
|
- 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.
|
- 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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user