fix: secure twilio webhook verification

This commit is contained in:
Peter Steinberger
2026-01-26 16:18:29 +00:00
parent b623557a2e
commit 97200984f8
8 changed files with 41 additions and 23 deletions

View File

@@ -38,6 +38,7 @@ Status: unreleased.
### Fixes ### Fixes
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile. - Build: align memory-core peer dependency with lockfile.
- Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie. - Security: add mDNS discovery mode with minimal default to reduce information disclosure. (#1882) Thanks @orlyjamie.

View File

@@ -103,6 +103,8 @@ Notes:
- Plivo requires a **publicly reachable** webhook URL. - Plivo requires a **publicly reachable** webhook URL.
- `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.
- 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

@@ -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: true }, tunnel: { provider: "none", allowNgrokFreeTier: false },
streaming: { streaming: {
enabled: false, enabled: false,
sttProvider: "openai-realtime", sttProvider: "openai-realtime",

View File

@@ -217,13 +217,12 @@ 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 be logged but allowed through. Less secure, but necessary * will include extra diagnostics. Signature verification is still required.
* for ngrok free tier which may modify URLs.
*/ */
allowNgrokFreeTier: z.boolean().default(true), allowNgrokFreeTier: z.boolean().default(false),
}) })
.strict() .strict()
.default({ provider: "none", allowNgrokFreeTier: true }); .default({ provider: "none", allowNgrokFreeTier: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>; export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -418,11 +417,14 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
} }
// Tunnel Config // Tunnel Config
resolved.tunnel = resolved.tunnel ?? { provider: "none", allowNgrokFreeTier: true }; resolved.tunnel = resolved.tunnel ?? {
provider: "none",
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 =
resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN; resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
return resolved; return resolved;
} }

View File

@@ -11,7 +11,7 @@ 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 ?? true, allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false,
skipVerification: params.options.skipVerification, skipVerification: params.options.skipVerification,
}); });

View File

@@ -48,7 +48,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
authToken: config.twilio?.authToken, authToken: config.twilio?.authToken,
}, },
{ {
allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? true, allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false,
publicUrl: config.publicUrl, publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification, skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled streamPath: config.streaming?.enabled

View File

@@ -205,4 +205,29 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it("rejects invalid signatures even with ngrok free tier enabled", () => {
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": "attacker.ngrok-free.app",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
},
authToken,
{ allowNgrokFreeTier: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/Invalid signature/);
});
}); });

View File

@@ -195,18 +195,6 @@ export function verifyTwilioWebhook(
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok-free.app") ||
verificationUrl.includes(".ngrok.io"); verificationUrl.includes(".ngrok.io");
if (isNgrokFreeTier && options?.allowNgrokFreeTier) {
console.warn(
"[voice-call] Twilio signature validation failed (proceeding for ngrok free tier compatibility)",
);
return {
ok: true,
reason: "ngrok free tier compatibility mode",
verificationUrl,
isNgrokFreeTier: true,
};
}
return { return {
ok: false, ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`, reason: `Invalid signature for URL: ${verificationUrl}`,