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

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