mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:20:43 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user