fix(browser): validate cdp websocket pivots

This commit is contained in:
Agustin Rivera 2026-04-03 18:26:32 +00:00 committed by Peter Steinberger
parent e4ea3c03cf
commit 80720b4994
4 changed files with 57 additions and 1 deletions

View File

@ -227,6 +227,23 @@ describe("cdp", () => {
expect(created.targetId).toBe("TARGET_LOCAL");
});
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
const httpPort = await startVersionHttpServer({
webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",
});
await expect(
createTargetViaCdp({
cdpUrl: `http://127.0.0.1:${httpPort}`,
url: "https://example.com",
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
},
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
});
it("evaluates javascript via CDP", async () => {
const wsPort = await startWsServerWithMessages((msg, socket) => {
if (msg.method === "Runtime.enable") {

View File

@ -1,6 +1,7 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import {
appendCdpPath,
assertCdpEndpointAllowed,
fetchJson,
isLoopbackHost,
isWebSocketUrl,
@ -194,6 +195,7 @@ export async function createTargetViaCdp(opts: {
if (!wsUrl) {
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
}
await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy);
}
return await withCdpSocket(wsUrl, async (send) => {

View File

@ -6,11 +6,13 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { WebSocketServer } from "ws";
import { SsrFBlockedError } from "../infra/net/ssrf.js";
import {
decorateOpenClawProfile,
ensureProfileCleanExit,
findChromeExecutableMac,
findChromeExecutableWindows,
getChromeWebSocketUrl,
isChromeCdpReady,
isChromeReachable,
resolveBrowserExecutableForPlatform,
@ -328,6 +330,39 @@ describe("browser chrome helpers", () => {
expect(fetchSpy).not.toHaveBeenCalled();
});
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
const server = createServer((req, res) => {
if (req.url === "/json/version") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/pivot",
}),
);
return;
}
res.writeHead(404);
res.end();
});
await new Promise<void>((resolve, reject) => {
server.listen(0, "127.0.0.1", () => resolve());
server.once("error", reject);
});
try {
const addr = server.address() as AddressInfo;
await expect(
getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 50, {
dangerouslyAllowPrivateNetwork: false,
allowedHostnames: ["127.0.0.1"],
}),
).rejects.toBeInstanceOf(SsrFBlockedError);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
await withMockChromeCdpServer({
wsPath: "/devtools/browser/health",

View File

@ -200,7 +200,9 @@ export async function getChromeWebSocketUrl(
if (!wsUrl) {
return null;
}
return normalizeCdpWsUrl(wsUrl, cdpUrl);
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, cdpUrl);
await assertCdpEndpointAllowed(normalizedWsUrl, ssrfPolicy);
return normalizedWsUrl;
}
async function canRunCdpHealthCommand(