fix(voice-call): require Twilio signature in ngrok loopback mode

This commit is contained in:
Peter Steinberger 2026-02-14 18:10:22 +01:00
parent 571c195c54
commit ff11d8793b
4 changed files with 47 additions and 18 deletions

View File

@ -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/<email>` 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.

View File

@ -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),
})

View File

@ -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", () => {

View File

@ -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}`,