diff --git a/CHANGELOG.md b/CHANGELOG.md index d694e96f585..96973c7ad20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Security/Discovery: stop treating Bonjour TXT records as authoritative routing (prefer resolved service endpoints) and prevent discovery from overriding stored TLS pins; autoconnect now requires a previously trusted gateway. Thanks @simecek. - macOS: hard-limit unkeyed `openclaw://agent` deep links and ignore `deliver` / `to` / `channel` unless a valid unattended key is provided. Thanks @Cillian-Collins. - Plugins: suppress false duplicate plugin id warnings when the same extension is discovered via multiple paths (config/workspace/global vs bundled), while still warning on genuine duplicates. (#16222) Thanks @shadril238. +- Security/Voice Call: require valid Twilio webhook signatures even when ngrok free tier loopback compatibility mode is enabled. Thanks @p80n-sec. - Security/Google Chat: deprecate `users/` allowlists (treat `users/...` as immutable user id only); keep raw email allowlists for usability. Thanks @vincentkoc. - Security/Google Chat: reject ambiguous shared-path webhook routing when multiple webhook targets verify successfully (prevents cross-account policy-context misrouting). Thanks @vincentkoc. - Security/Browser: block cross-origin mutating requests to loopback browser control routes (CSRF hardening). Thanks @vincentkoc. diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index cfe82b425f3..610c63869f9 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -207,8 +207,10 @@ export const VoiceCallTunnelConfigSchema = z ngrokDomain: z.string().min(1).optional(), /** * Allow ngrok free tier compatibility mode. - * When true, signature verification failures on ngrok-free.app URLs - * will be allowed only for loopback requests (ngrok local agent). + * When true, forwarded headers may be trusted for loopback requests + * to reconstruct the public ngrok URL used for signing. + * + * IMPORTANT: This does NOT bypass signature verification. */ allowNgrokFreeTierLoopbackBypass: z.boolean().default(false), }) diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 7968829af10..9ad662726a1 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -222,7 +222,39 @@ describe("verifyTwilioWebhook", () => { expect(result.reason).toMatch(/Invalid signature/); }); - it("allows invalid signatures for ngrok free tier only on loopback", () => { + it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => { + const authToken = "test-auth-token"; + const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; + const webhookUrl = "https://local.ngrok-free.app/voice/webhook"; + + const signature = twilioSignature({ + authToken, + url: webhookUrl, + postBody, + }); + + const result = verifyTwilioWebhook( + { + headers: { + host: "127.0.0.1:3334", + "x-forwarded-proto": "https", + "x-forwarded-host": "local.ngrok-free.app", + "x-twilio-signature": signature, + }, + 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.verificationUrl).toBe(webhookUrl); + }); + + it("does not allow invalid signatures for ngrok free tier on loopback", () => { const authToken = "test-auth-token"; const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; @@ -243,9 +275,9 @@ describe("verifyTwilioWebhook", () => { { allowNgrokFreeTierLoopbackBypass: true }, ); - expect(result.ok).toBe(true); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/Invalid signature/); expect(result.isNgrokFreeTier).toBe(true); - expect(result.reason).toMatch(/compatibility mode/); }); it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => { diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 6ee7a813da9..f4896809dcd 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -339,7 +339,13 @@ export function verifyTwilioWebhook( options?: { /** Override the public URL (e.g., from config) */ publicUrl?: string; - /** Allow ngrok free tier compatibility mode (loopback only, less secure) */ + /** + * Allow ngrok free tier compatibility mode (loopback only). + * + * IMPORTANT: This does NOT bypass signature verification. + * It only enables trusting forwarded headers on loopback so we can + * reconstruct the public ngrok URL that Twilio used for signing. + */ allowNgrokFreeTierLoopbackBypass?: boolean; /** Skip verification entirely (only for development) */ skipVerification?: boolean; @@ -401,18 +407,6 @@ export function verifyTwilioWebhook( const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io"); - if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) { - 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 { ok: false, reason: `Invalid signature for URL: ${verificationUrl}`,