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