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.
This commit is contained in:
clawsweeper[bot]
2026-05-01 07:27:56 +01:00
committed by GitHub
parent be14820b5d
commit be918636ab
9 changed files with 114 additions and 89 deletions

View File

@@ -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.

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
};
});
vi.mock("./src/voice-call-gateway.js", () => ({
joinMeetViaVoiceCallGateway: voiceCallMocks.joinMeetViaVoiceCallGateway,

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
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;

View File

@@ -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<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: fetchGuardMocks.fetchWithSsrFGuard,
};
});
function captureStdout() {
let output = "";

View File

@@ -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;
}

View File

@@ -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",

View File

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

View File

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

View File

@@ -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 {