From be918636abc12f3ed8ad524ec02ef2e5e6532046 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 07:27:56 +0100 Subject: [PATCH] fix: tighten webhook exposure host checks (#75465) Use the existing SSRF hostname/IP classifier for Voice Call and Google Meet webhook exposure checks so bracketed IPv6 loopback, unique-local, link-local, and IPv4-mapped local/private addresses fail before Twilio/Meet joins while public hostnames are not rejected by prefix accidents. Thanks @clawsweeper, @donkeykong91, and @PfanP. --- CHANGELOG.md | 2 +- extensions/google-meet/index.create.test.ts | 10 ++- extensions/google-meet/index.test.ts | 87 +++++++++++-------- extensions/google-meet/src/cli.test.ts | 10 ++- extensions/google-meet/src/setup.ts | 24 +---- extensions/voice-call/index.test.ts | 8 +- extensions/voice-call/src/runtime.test.ts | 8 +- .../voice-call/src/webhook-exposure.test.ts | 33 +++++++ extensions/voice-call/src/webhook-exposure.ts | 21 +---- 9 files changed, 114 insertions(+), 89 deletions(-) create mode 100644 extensions/voice-call/src/webhook-exposure.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 608a3c85d286..4e9cbc264105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai - Voice Call/Twilio: honor stored pre-connect TwiML before realtime webhook shortcuts and reject DTMF sequences outside conversation mode, so Meet PIN entry cannot be skipped or silently dropped. Thanks @donkeykong91 and @PfanP. - Google Meet/Voice Call: play Twilio Meet DTMF before opening the realtime media stream and carry the intro as the initial Voice Call message, so the greeting is generated after Meet admits the phone participant instead of racing a live-call TwiML update. Thanks @donkeykong91 and @PfanP. -- Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs before joins. Thanks @donkeykong91 and @PfanP. +- Google Meet/Voice Call: make Twilio setup preflight honor explicit `--transport twilio` and fail local/private Voice Call webhook URLs, including IPv6 loopback and unique-local forms, before joins. Thanks @donkeykong91 and @PfanP. - Voice Call/Twilio: retry transient 21220 live-call TwiML updates and catch answered-path initial-greeting failures, so a fast answered callback no longer crashes the Gateway or drops the Twilio greeting/listen transition. (#74606) Thanks @Sivan22. - Voice Call/Twilio: register accepted media streams immediately but wait for realtime transcription readiness before speaking the initial greeting, so reconnect grace handling stays live while OpenAI STT startup is no longer starved by TTS. Fixes #75197. (#75257) Thanks @donkeykong91 and @PfanP. - Voice Call CLI: delegate operational `voicecall` commands to the running Gateway runtime and skip webhook startup during CLI-only plugin loading, preventing webhook port conflicts and `setup --json` hangs. Fixes #72345. Thanks @serrurco and @DougButdorf. diff --git a/extensions/google-meet/index.create.test.ts b/extensions/google-meet/index.create.test.ts index 92e1c7878586..727dbf51f68b 100644 --- a/extensions/google-meet/index.create.test.ts +++ b/extensions/google-meet/index.create.test.ts @@ -36,9 +36,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, diff --git a/extensions/google-meet/index.test.ts b/extensions/google-meet/index.test.ts index 81f7b7732c6f..876f151abdc7 100644 --- a/extensions/google-meet/index.test.ts +++ b/extensions/google-meet/index.test.ts @@ -59,9 +59,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); vi.mock("./src/voice-call-gateway.js", () => ({ joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway, @@ -1481,48 +1485,55 @@ describe("google-meet plugin", () => { ); }); - it("reports local voice-call publicUrl as unusable for Twilio transport", async () => { - vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); - vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); - vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); - const { tools } = setup( - { defaultTransport: "twilio" }, - { - fullConfig: { - plugins: { - allow: ["google-meet", "voice-call"], - entries: { - "voice-call": { - enabled: true, - config: { - provider: "twilio", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])( + "reports local voice-call publicUrl %s as unusable for Twilio transport", + async (publicUrl) => { + vi.stubEnv("TWILIO_ACCOUNT_SID", "AC123"); + vi.stubEnv("TWILIO_AUTH_TOKEN", "secret"); + vi.stubEnv("TWILIO_FROM_NUMBER", "+15550001234"); + const { tools } = setup( + { defaultTransport: "twilio" }, + { + fullConfig: { + plugins: { + allow: ["google-meet", "voice-call"], + entries: { + "voice-call": { + enabled: true, + config: { + provider: "twilio", + publicUrl, + }, }, }, }, }, }, - }, - ); - const tool = tools[0] as { - execute: ( - id: string, - params: unknown, - ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>; - }; + ); + const tool = tools[0] as { + execute: ( + id: string, + params: unknown, + ) => Promise<{ details: { ok?: boolean; checks?: unknown[] } }>; + }; - const result = await tool.execute("id", { action: "setup_status" }); + const result = await tool.execute("id", { action: "setup_status" }); - expect(result.details.ok).toBe(false); - expect(result.details.checks).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "twilio-voice-call-webhook", - ok: false, - }), - ]), - ); - }); + expect(result.details.ok).toBe(false); + expect(result.details.checks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "twilio-voice-call-webhook", + ok: false, + }), + ]), + ); + }, + ); it("opens local Chrome Meet in observe-only mode without BlackHole checks", async () => { const originalPlatform = process.platform; diff --git a/extensions/google-meet/src/cli.test.ts b/extensions/google-meet/src/cli.test.ts index 815f4e03e527..b3ed642d2615 100644 --- a/extensions/google-meet/src/cli.test.ts +++ b/extensions/google-meet/src/cli.test.ts @@ -22,9 +22,13 @@ const fetchGuardMocks = vi.hoisted(() => ({ ), })); -vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, -})); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard, + }; +}); function captureStdout() { let output = ""; diff --git a/extensions/google-meet/src/setup.ts b/extensions/google-meet/src/setup.ts index 7b634fc5ae04..7ea95c83bc72 100644 --- a/extensions/google-meet/src/setup.ts +++ b/extensions/google-meet/src/setup.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; import type { GoogleMeetConfig, GoogleMeetMode, GoogleMeetTransport } from "./config.js"; export type SetupCheck = { @@ -24,31 +25,10 @@ function resolveUserPath(input: string): string { return input; } -function isLocalOnlyWebhookHost(hostname: string): boolean { - const host = hostname.trim().toLowerCase(); - if (!host) { - return false; - } - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "::" || - host === "::1" || - host.startsWith("127.") - ) { - return true; - } - if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) { - return true; - } - const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); - return private172 || host.startsWith("fc") || host.startsWith("fd"); -} - function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean { try { const parsed = new URL(webhookUrl); - return isLocalOnlyWebhookHost(parsed.hostname); + return isBlockedHostnameOrIp(parsed.hostname); } catch { return false; } diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 8a83c3284457..89f532203804 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -594,13 +594,17 @@ describe("voice-call plugin", () => { } }); - it("CLI setup rejects local public webhook URLs for Twilio", async () => { + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])("CLI setup rejects local public webhook URL %s for Twilio", async (publicUrl) => { const program = new Command(); const stdout = captureStdout(); await registerVoiceCallCli(program, { provider: "twilio", fromNumber: "+15550001234", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + publicUrl, twilio: { accountSid: "AC123", authToken: "token", diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts index 14f412344a54..71d3fc5db8f1 100644 --- a/extensions/voice-call/src/runtime.test.ts +++ b/extensions/voice-call/src/runtime.test.ts @@ -216,12 +216,16 @@ describe("createVoiceCallRuntime lifecycle", () => { }, ); - it("fails closed when Twilio publicUrl points at a local-only webhook", async () => { + it.each([ + "http://127.0.0.1:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fd00::1]/voice/webhook", + ])("fails closed when Twilio publicUrl %s points at a local-only webhook", async (publicUrl) => { await expect( createVoiceCallRuntime({ config: createExternalProviderConfig({ provider: "twilio", - publicUrl: "http://127.0.0.1:3334/voice/webhook", + publicUrl, }), coreConfig: {} as CoreConfig, agentRuntime: {} as never, diff --git a/extensions/voice-call/src/webhook-exposure.test.ts b/extensions/voice-call/src/webhook-exposure.test.ts new file mode 100644 index 000000000000..f96c76d4466f --- /dev/null +++ b/extensions/voice-call/src/webhook-exposure.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isLocalOnlyWebhookHost, isProviderUnreachableWebhookUrl } from "./webhook-exposure.js"; + +describe("webhook exposure host classification", () => { + it.each([ + "http://[::]:3334/voice/webhook", + "http://[::1]:3334/voice/webhook", + "http://[fc00::1]/voice/webhook", + "http://[fd00::1]/voice/webhook", + "http://[::ffff:127.0.0.1]/voice/webhook", + "http://[::ffff:10.0.0.1]/voice/webhook", + "http://[::ffff:192.168.0.1]/voice/webhook", + "http://[::ffff:172.16.0.1]/voice/webhook", + "http://[fe80::1]/voice/webhook", + ])("treats local/private webhook URL %s as provider-unreachable", (url) => { + expect(isProviderUnreachableWebhookUrl(url)).toBe(true); + }); + + it.each([ + "http://[::ffff:8.8.8.8]/voice/webhook", + "https://voice.example.com/voice/webhook", + "https://fcloud.example/voice/webhook", + ])("does not reject public webhook URL %s", (url) => { + expect(isProviderUnreachableWebhookUrl(url)).toBe(false); + }); + + it.each(["[::1]", "[fc00::1]", "[fd00::1]", "::ffff:7f00:1", "::ffff:a00:1", "[fe80::1]"])( + "normalizes local/private URL hostnames like %s", + (host) => { + expect(isLocalOnlyWebhookHost(host)).toBe(true); + }, + ); +}); diff --git a/extensions/voice-call/src/webhook-exposure.ts b/extensions/voice-call/src/webhook-exposure.ts index 9eb5dd627128..30830cd292f6 100644 --- a/extensions/voice-call/src/webhook-exposure.ts +++ b/extensions/voice-call/src/webhook-exposure.ts @@ -1,3 +1,5 @@ +import { isBlockedHostnameOrIp } from "../api.js"; + export type VoiceCallWebhookExposureConfig = { provider?: string; publicUrl?: string; @@ -20,24 +22,7 @@ export function providerRequiresPublicWebhook(providerName: string | undefined): } export function isLocalOnlyWebhookHost(hostname: string): boolean { - const host = hostname.trim().toLowerCase(); - if (!host) { - return false; - } - if ( - host === "localhost" || - host === "0.0.0.0" || - host === "::" || - host === "::1" || - host.startsWith("127.") - ) { - return true; - } - if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) { - return true; - } - const private172 = /^172\.(1[6-9]|2\d|3[0-1])\./.test(host); - return private172 || host.startsWith("fc") || host.startsWith("fd"); + return isBlockedHostnameOrIp(hostname); } export function isProviderUnreachableWebhookUrl(webhookUrl: string): boolean {