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
- 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.
- Build: align memory-core peer dependency with lockfile.
- 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.
- `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.
- 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -205,4 +205,29 @@ describe("verifyTwilioWebhook", () => {
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.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 {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,