mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 18:30:44 +00:00
fix(browser): unblock loopback CDP readiness
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,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user