fix(browser): unblock loopback CDP readiness under strict SSRF defaults (#66354)

Merged via squash.

Prepared head SHA: d9030ff2f0
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com>
Reviewed-by: @hxy91819
This commit is contained in:
Mason Huang
2026-04-14 16:30:43 +08:00
committed by GitHub
parent e59f5ecac3
commit 7eecfa411d
8 changed files with 248 additions and 9 deletions

View File

@@ -10,7 +10,7 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
};
});
import { fetchJson, fetchOk } from "./cdp.helpers.js";
import { assertCdpEndpointAllowed, fetchJson, fetchOk } from "./cdp.helpers.js";
describe("cdp helpers", () => {
afterEach(() => {
@@ -43,6 +43,23 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("allows loopback CDP endpoints in strict SSRF mode", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
});
it("still enforces hostname allowlist for loopback CDP endpoints", async () => {
await expect(
assertCdpEndpointAllowed("http://127.0.0.1:9222/json/version", {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).rejects.toThrow("browser endpoint blocked by policy");
});
it("releases guarded CDP fetches for bodyless requests", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -62,4 +79,62 @@ describe("cdp helpers", () => {
expect(release).toHaveBeenCalledTimes(1);
});
it("uses an exact loopback allowlist for guarded loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBeUndefined();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://127.0.0.1:9222/json/version",
policy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("preserves hostname allowlist while allowing exact loopback CDP fetches", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: {
ok: true,
status: 200,
},
release,
});
await expect(
fetchOk("http://127.0.0.1:9222/json/version", 250, undefined, {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
}),
).resolves.toBeUndefined();
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "http://127.0.0.1:9222/json/version",
policy: {
dangerouslyAllowPrivateNetwork: false,
hostnameAllowlist: ["*.corp.example"],
allowedHostnames: ["127.0.0.1"],
},
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -69,8 +69,16 @@ export async function assertCdpEndpointAllowed(
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
try {
const policy = isLoopbackHost(parsed.hostname)
? {
...ssrfPolicy,
allowedHostnames: Array.from(
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]),
),
}
: ssrfPolicy;
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
policy,
});
} catch (error) {
throw new BrowserCdpEndpointBlockedError({ cause: error });
@@ -263,11 +271,20 @@ export async function fetchCdpChecked(
try {
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
const res = await withNoProxyForCdpUrl(url, async () => {
const parsedUrl = new URL(url);
const policy = isLoopbackHost(parsedUrl.hostname)
? {
...ssrfPolicy,
allowedHostnames: Array.from(
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsedUrl.hostname]),
),
}
: (ssrfPolicy ?? { allowPrivateNetwork: true });
const guarded = await fetchWithSsrFGuard({
url,
init: { ...init, headers },
signal: ctrl.signal,
policy: ssrfPolicy ?? { allowPrivateNetwork: true },
policy,
auditContext: "browser-cdp",
});
guardedRelease = guarded.release;

View File

@@ -0,0 +1,70 @@
import { createServer, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import { getChromeWebSocketUrl, isChromeReachable } from "./chrome.js";
type RunningServer = {
server: Server;
baseUrl: string;
};
const runningServers: Server[] = [];
async function startLoopbackCdpServer(): Promise<RunningServer> {
const server = createServer((req, res) => {
if (req.url !== "/json/version") {
res.statusCode = 404;
res.end("not found");
return;
}
const address = server.address() as AddressInfo;
res.setHeader("content-type", "application/json");
res.end(
JSON.stringify({
Browser: "Chrome/999.0.0.0",
webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/browser/TEST`,
}),
);
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => resolve());
});
runningServers.push(server);
const address = server.address() as AddressInfo;
return {
server,
baseUrl: `http://127.0.0.1:${address.port}`,
};
}
afterEach(async () => {
await Promise.all(
runningServers
.splice(0)
.map(
(server) =>
new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
),
),
);
});
describe("chrome loopback SSRF integration", () => {
it("keeps loopback CDP HTTP reachability working under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(isChromeReachable(baseUrl, 500, {})).resolves.toBe(true);
});
it("returns the loopback websocket URL under strict default SSRF policy", async () => {
const { baseUrl } = await startLoopbackCdpServer();
await expect(getChromeWebSocketUrl(baseUrl, 500, {})).resolves.toMatch(
/\/devtools\/browser\/TEST$/,
);
});
});

View File

@@ -312,22 +312,28 @@ describe("browser chrome helpers", () => {
await expect(isChromeReachable("http://127.0.0.1:12345", 50)).resolves.toBe(false);
});
it("blocks private CDP probes when strict SSRF policy is enabled", async () => {
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
const fetchSpy = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
} as unknown as Response)
.mockRejectedValue(new Error("should not be called"));
vi.stubGlobal("fetch", fetchSpy);
await expect(
isChromeReachable("http://127.0.0.1:12345", 50, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBe(false);
).resolves.toBe(true);
await expect(
isChromeReachable("ws://127.0.0.1:19999", 50, {
isChromeReachable("http://169.254.169.254:12345", 50, {
dangerouslyAllowPrivateNetwork: false,
}),
).resolves.toBe(false);
expect(fetchSpy).not.toHaveBeenCalled();
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {

View File

@@ -71,7 +71,6 @@ export function createProfileAvailability({
const getCdpReachabilityPolicy = () =>
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
const isReachable = async (timeoutMs?: number) => {
if (capabilities.usesChromeMcp) {
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required