Skip to content

Commit 80720b4

Browse files
eleqtrizitsteipete
authored andcommitted
fix(browser): validate cdp websocket pivots
1 parent e4ea3c0 commit 80720b4

4 files changed

Lines changed: 57 additions & 1 deletion

File tree

extensions/browser/src/browser/cdp.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,23 @@ describe("cdp", () => {
227227
expect(created.targetId).toBe("TARGET_LOCAL");
228228
});
229229

230+
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
231+
const httpPort = await startVersionHttpServer({
232+
webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/PIVOT",
233+
});
234+
235+
await expect(
236+
createTargetViaCdp({
237+
cdpUrl: `http://127.0.0.1:${httpPort}`,
238+
url: "https://example.com",
239+
ssrfPolicy: {
240+
dangerouslyAllowPrivateNetwork: false,
241+
allowedHostnames: ["127.0.0.1"],
242+
},
243+
}),
244+
).rejects.toBeInstanceOf(SsrFBlockedError);
245+
});
246+
230247
it("evaluates javascript via CDP", async () => {
231248
const wsPort = await startWsServerWithMessages((msg, socket) => {
232249
if (msg.method === "Runtime.enable") {

extensions/browser/src/browser/cdp.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { SsrFPolicy } from "../infra/net/ssrf.js";
22
import {
33
appendCdpPath,
4+
assertCdpEndpointAllowed,
45
fetchJson,
56
isLoopbackHost,
67
isWebSocketUrl,
@@ -194,6 +195,7 @@ export async function createTargetViaCdp(opts: {
194195
if (!wsUrl) {
195196
throw new Error("CDP /json/version missing webSocketDebuggerUrl");
196197
}
198+
await assertCdpEndpointAllowed(wsUrl, opts.ssrfPolicy);
197199
}
198200

199201
return await withCdpSocket(wsUrl, async (send) => {

extensions/browser/src/browser/chrome.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import os from "node:os";
66
import path from "node:path";
77
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
88
import { WebSocketServer } from "ws";
9+
import { SsrFBlockedError } from "../infra/net/ssrf.js";
910
import {
1011
decorateOpenClawProfile,
1112
ensureProfileCleanExit,
1213
findChromeExecutableMac,
1314
findChromeExecutableWindows,
15+
getChromeWebSocketUrl,
1416
isChromeCdpReady,
1517
isChromeReachable,
1618
resolveBrowserExecutableForPlatform,
@@ -328,6 +330,39 @@ describe("browser chrome helpers", () => {
328330
expect(fetchSpy).not.toHaveBeenCalled();
329331
});
330332

333+
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
334+
const server = createServer((req, res) => {
335+
if (req.url === "/json/version") {
336+
res.writeHead(200, { "Content-Type": "application/json" });
337+
res.end(
338+
JSON.stringify({
339+
webSocketDebuggerUrl: "ws://169.254.169.254:9222/devtools/browser/pivot",
340+
}),
341+
);
342+
return;
343+
}
344+
res.writeHead(404);
345+
res.end();
346+
});
347+
348+
await new Promise<void>((resolve, reject) => {
349+
server.listen(0, "127.0.0.1", () => resolve());
350+
server.once("error", reject);
351+
});
352+
353+
try {
354+
const addr = server.address() as AddressInfo;
355+
await expect(
356+
getChromeWebSocketUrl(`http://127.0.0.1:${addr.port}`, 50, {
357+
dangerouslyAllowPrivateNetwork: false,
358+
allowedHostnames: ["127.0.0.1"],
359+
}),
360+
).rejects.toBeInstanceOf(SsrFBlockedError);
361+
} finally {
362+
await new Promise<void>((resolve) => server.close(() => resolve()));
363+
}
364+
});
365+
331366
it("reports cdpReady only when Browser.getVersion command succeeds", async () => {
332367
await withMockChromeCdpServer({
333368
wsPath: "/devtools/browser/health",

extensions/browser/src/browser/chrome.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ export async function getChromeWebSocketUrl(
200200
if (!wsUrl) {
201201
return null;
202202
}
203-
return normalizeCdpWsUrl(wsUrl, cdpUrl);
203+
const normalizedWsUrl = normalizeCdpWsUrl(wsUrl, cdpUrl);
204+
await assertCdpEndpointAllowed(normalizedWsUrl, ssrfPolicy);
205+
return normalizedWsUrl;
204206
}
205207

206208
async function canRunCdpHealthCommand(

0 commit comments

Comments
 (0)