mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:30:44 +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:
@@ -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