fix(browser): unblock loopback CDP readiness

This commit is contained in:
Mason Huang
2026-04-13 23:54:50 +08:00
parent e59f5ecac3
commit f6971d6f28
5 changed files with 69 additions and 10 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,14 @@ 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("releases guarded CDP fetches for bodyless requests", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
@@ -62,4 +70,32 @@ 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);
});
});

View File

@@ -68,6 +68,11 @@ export async function assertCdpEndpointAllowed(
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
// Loopback CDP endpoints are internal browser-control hops, not
// agent-controlled navigation targets.
if (isLoopbackHost(parsed.hostname)) {
return;
}
try {
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
@@ -263,11 +268,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

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

View File

@@ -76,13 +76,17 @@ describe("browser server-context ensureBrowserAvailable", () => {
1,
"http://127.0.0.1:18800",
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
undefined,
{
allowPrivateNetwork: true,
},
);
expect(isChromeReachable).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:18800",
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
undefined,
{
allowPrivateNetwork: true,
},
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();