mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:10:43 +00:00
fix: scope remote CDP host allowlist (#68207)
This commit is contained in:
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Agents/channels: route cross-agent subagent spawns through the target agent's bound channel account while preserving peer and workspace/role-scoped bindings, so child sessions no longer inherit the caller's account in shared rooms, workspaces, or multi-account setups. (#67508) Thanks @lukeboyett and @gumadeiras.
|
||||
- Telegram/callbacks: treat permanent callback edit errors as completed updates so stale command pagination buttons no longer wedge the update watermark and block newer Telegram updates. (#68588) Thanks @Lucenx9.
|
||||
- Browser/CDP: allow the selected remote CDP profile host for CDP health and control checks without widening browser navigation SSRF policy, so WSL-to-Windows Chrome endpoints no longer appear offline under strict defaults. Fixes #68108. (#68207) Thanks @Mlightsnow.
|
||||
|
||||
## 2026.4.18
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { assertBrowserNavigationAllowed } from "./navigation-guard.js";
|
||||
|
||||
function createProfile(overrides: Partial<ResolvedBrowserProfile>): ResolvedBrowserProfile {
|
||||
return {
|
||||
name: "remote",
|
||||
cdpPort: 9223,
|
||||
cdpUrl: "http://172.29.128.1:9223",
|
||||
cdpHost: "172.29.128.1",
|
||||
cdpIsLoopback: false,
|
||||
color: "#123456",
|
||||
driver: "openclaw",
|
||||
attachOnly: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("CDP reachability policy", () => {
|
||||
it("allows the selected remote profile CDP host without widening browser navigation policy", async () => {
|
||||
const browserPolicy = {};
|
||||
const profile = createProfile({});
|
||||
|
||||
expect(resolveCdpReachabilityPolicy(profile, browserPolicy)).toEqual({
|
||||
allowedHostnames: ["172.29.128.1"],
|
||||
});
|
||||
expect(browserPolicy).toEqual({});
|
||||
await expect(
|
||||
assertBrowserNavigationAllowed({
|
||||
url: "http://172.29.128.1/",
|
||||
ssrfPolicy: browserPolicy,
|
||||
}),
|
||||
).rejects.toThrow(/private\/internal\/special-use ip address/i);
|
||||
});
|
||||
|
||||
it("merges the selected remote profile CDP host with existing CDP policy hostnames", () => {
|
||||
const profile = createProfile({});
|
||||
|
||||
expect(
|
||||
resolveCdpReachabilityPolicy(profile, {
|
||||
allowedHostnames: ["metadata.internal"],
|
||||
}),
|
||||
).toEqual({
|
||||
allowedHostnames: ["metadata.internal", "172.29.128.1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps local managed loopback CDP control outside browser SSRF policy", () => {
|
||||
const profile = createProfile({
|
||||
cdpUrl: "http://127.0.0.1:18800",
|
||||
cdpHost: "127.0.0.1",
|
||||
cdpIsLoopback: true,
|
||||
});
|
||||
|
||||
expect(resolveCdpReachabilityPolicy(profile, {})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,25 @@
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { isPrivateNetworkAllowedByPolicy, type SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import type { ResolvedBrowserProfile } from "./config.js";
|
||||
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
|
||||
|
||||
function withCdpHostnameAllowed(
|
||||
profile: ResolvedBrowserProfile,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
): SsrFPolicy | undefined {
|
||||
if (!ssrfPolicy || !profile.cdpHost) {
|
||||
return ssrfPolicy;
|
||||
}
|
||||
if (isPrivateNetworkAllowedByPolicy(ssrfPolicy)) {
|
||||
return ssrfPolicy;
|
||||
}
|
||||
return {
|
||||
...ssrfPolicy,
|
||||
allowedHostnames: Array.from(
|
||||
new Set([...(ssrfPolicy.allowedHostnames ?? []), profile.cdpHost]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCdpReachabilityPolicy(
|
||||
profile: ResolvedBrowserProfile,
|
||||
ssrfPolicy?: SsrFPolicy,
|
||||
@@ -13,7 +31,7 @@ export function resolveCdpReachabilityPolicy(
|
||||
if (!capabilities.isRemote && profile.cdpIsLoopback && profile.driver === "openclaw") {
|
||||
return undefined;
|
||||
}
|
||||
return ssrfPolicy;
|
||||
return withCdpHostnameAllowed(profile, ssrfPolicy);
|
||||
}
|
||||
|
||||
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;
|
||||
|
||||
@@ -343,7 +343,7 @@ describe("browser config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-allowlists hostnames from user-configured profile cdpUrls", () => {
|
||||
it("keeps configured profile cdpUrls out of the shared browser SSRF policy", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
profiles: {
|
||||
remote: {
|
||||
@@ -352,41 +352,7 @@ describe("browser config", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy).toEqual({
|
||||
allowedHostnames: ["172.29.128.1"],
|
||||
});
|
||||
});
|
||||
|
||||
it("merges configured profile cdpUrl hostnames with existing ssrfPolicy allowedHostnames", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
ssrfPolicy: {
|
||||
allowedHostnames: ["metadata.internal"],
|
||||
},
|
||||
profiles: {
|
||||
remote: {
|
||||
color: "#123456",
|
||||
cdpUrl: "http://172.29.128.1:9223",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy?.allowedHostnames?.toSorted()).toEqual(
|
||||
["172.29.128.1", "metadata.internal"].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate hostnames already in allowedHostnames", () => {
|
||||
const resolved = resolveBrowserConfig({
|
||||
ssrfPolicy: {
|
||||
allowedHostnames: ["172.29.128.1"],
|
||||
},
|
||||
profiles: {
|
||||
remote: {
|
||||
color: "#123456",
|
||||
cdpUrl: "http://172.29.128.1:9223",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resolved.ssrfPolicy?.allowedHostnames).toEqual(["172.29.128.1"]);
|
||||
expect(resolved.ssrfPolicy).toEqual({});
|
||||
});
|
||||
|
||||
it("resolves existing-session profiles without cdpPort or cdpUrl", () => {
|
||||
|
||||
@@ -126,27 +126,11 @@ function resolveCdpPortRangeStart(
|
||||
|
||||
const normalizeStringList = normalizeOptionalTrimmedStringList;
|
||||
|
||||
function mergeAllowedHostnames(
|
||||
base: string[] | undefined,
|
||||
extra: readonly string[],
|
||||
): string[] | undefined {
|
||||
if (extra.length === 0) {
|
||||
return base;
|
||||
}
|
||||
return Array.from(new Set([...(base ?? []), ...extra]));
|
||||
}
|
||||
|
||||
function resolveBrowserSsrFPolicy(
|
||||
cfg: BrowserConfig | undefined,
|
||||
extraAllowedHostnames: readonly string[] = [],
|
||||
): SsrFPolicy | undefined {
|
||||
function resolveBrowserSsrFPolicy(cfg: BrowserConfig | undefined): SsrFPolicy | undefined {
|
||||
const rawPolicy = cfg?.ssrfPolicy as BrowserSsrFPolicyCompat | undefined;
|
||||
const allowPrivateNetwork = rawPolicy?.allowPrivateNetwork;
|
||||
const dangerouslyAllowPrivateNetwork = rawPolicy?.dangerouslyAllowPrivateNetwork;
|
||||
const allowedHostnames = mergeAllowedHostnames(
|
||||
normalizeStringList(rawPolicy?.allowedHostnames),
|
||||
extraAllowedHostnames,
|
||||
);
|
||||
const allowedHostnames = normalizeStringList(rawPolicy?.allowedHostnames);
|
||||
const hostnameAllowlist = normalizeStringList(rawPolicy?.hostnameAllowlist);
|
||||
const hasExplicitPrivateSetting =
|
||||
allowPrivateNetwork !== undefined || dangerouslyAllowPrivateNetwork !== undefined;
|
||||
@@ -175,30 +159,6 @@ function resolveBrowserSsrFPolicy(
|
||||
};
|
||||
}
|
||||
|
||||
function collectConfiguredCdpHostnames(cfg: BrowserConfig | undefined): string[] {
|
||||
const hostnames = new Set<string>();
|
||||
const addHostnameFromUrl = (rawUrl: string | undefined): void => {
|
||||
const trimmed = rawUrl?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hostname = new URL(trimmed).hostname;
|
||||
if (hostname) {
|
||||
hostnames.add(hostname);
|
||||
}
|
||||
} catch {
|
||||
// Ignore unparseable URLs; they will be rejected elsewhere with a proper error.
|
||||
}
|
||||
};
|
||||
|
||||
addHostnameFromUrl(cfg?.cdpUrl);
|
||||
for (const profile of Object.values(cfg?.profiles ?? {})) {
|
||||
addHostnameFromUrl(profile?.cdpUrl);
|
||||
}
|
||||
return Array.from(hostnames);
|
||||
}
|
||||
|
||||
function ensureDefaultProfile(
|
||||
profiles: Record<string, BrowserProfileConfig> | undefined,
|
||||
defaultColor: string,
|
||||
@@ -333,7 +293,7 @@ export function resolveBrowserConfig(
|
||||
attachOnly,
|
||||
defaultProfile,
|
||||
profiles,
|
||||
ssrfPolicy: resolveBrowserSsrFPolicy(cfg, collectConfiguredCdpHostnames(cfg)),
|
||||
ssrfPolicy: resolveBrowserSsrFPolicy(cfg),
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -143,14 +143,17 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
|
||||
await openclaw.closeTab("T2");
|
||||
});
|
||||
|
||||
it("blocks direct WebSocket tab operations when strict SSRF policy rejects the cdpUrl", async () => {
|
||||
it("blocks direct WebSocket tab operations when strict SSRF hostname allowlist rejects the cdpUrl", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
const state = makeState("openclaw");
|
||||
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
|
||||
state.resolved.ssrfPolicy = {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["browserless.example.com"],
|
||||
};
|
||||
state.resolved.profiles.openclaw = {
|
||||
cdpUrl: "ws://10.0.0.42:18800/devtools/browser/SESSION?token=abc",
|
||||
color: "#FF4500",
|
||||
|
||||
@@ -149,7 +149,7 @@ describe("browser remote profile tab ops via Playwright", () => {
|
||||
expect(state.profiles.get("remote")?.lastTargetId).toBe("T1");
|
||||
});
|
||||
|
||||
it("blocks remote Playwright tab operations when strict SSRF policy rejects the cdpUrl", async () => {
|
||||
it("blocks remote Playwright tab operations when strict SSRF hostname allowlist rejects the cdpUrl", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
{ targetId: "T1", title: "Tab 1", url: "https://example.com", type: "page" },
|
||||
]);
|
||||
@@ -163,7 +163,10 @@ describe("browser remote profile tab ops via Playwright", () => {
|
||||
} as unknown as Awaited<ReturnType<typeof deps.pwAiModule.getPwAiModule>>);
|
||||
|
||||
const state = deps.makeState("remote");
|
||||
state.resolved.ssrfPolicy = { dangerouslyAllowPrivateNetwork: false };
|
||||
state.resolved.ssrfPolicy = {
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
hostnameAllowlist: ["browserless.example.com"],
|
||||
};
|
||||
state.resolved.profiles.remote = {
|
||||
...state.resolved.profiles.remote,
|
||||
cdpUrl: "http://10.0.0.42:9222",
|
||||
|
||||
Reference in New Issue
Block a user