fix: scope remote CDP host allowlist (#68207)

This commit is contained in:
Peter Steinberger
2026-04-18 22:52:52 +01:00
parent e90c89cf8b
commit 1fd049e307
7 changed files with 94 additions and 85 deletions

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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