mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
extensions/voice-call/src/webhook-exposure.test.ts
Normal file
33
extensions/voice-call/src/webhook-exposure.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user