fix: secure twilio webhook verification
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user