mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 15:53:39 +02: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:
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/SSRF: restore hostname navigation under the default browser SSRF policy while keeping explicit strict mode reachable from config, and keep managed loopback CDP `/json/new` fallback requests on the local CDP control policy so browser follow-up fixes stop regressing normal navigation or self-blocking local CDP control. (#66386) Thanks @obviyus.
|
||||
- Browser/SSRF: preserve explicit strict browser navigation mode for legacy `browser.ssrfPolicy.allowPrivateNetwork: false` configs by normalizing the legacy alias to the canonical strict marker instead of silently widening those installs to the default non-strict hostname-navigation path.
|
||||
- Agents/subagents: emit the subagent registry lazy-runtime stub on the stable dist path that both source and bundled runtime imports resolve, so the follow-up dist fix no longer still fails with `ERR_MODULE_NOT_FOUND` at runtime. (#66420) Thanks @obviyus.
|
||||
- Browser: keep loopback CDP readiness checks reachable under strict SSRF defaults so OpenClaw can reconnect to locally started managed Chrome. (#66354) Thanks @hxy91819.
|
||||
|
||||
## 2026.4.14-beta.1
|
||||
|
||||
|
||||
@@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com
|
||||
openclaw browser --browser-profile openclaw snapshot
|
||||
```
|
||||
|
||||
## Quick troubleshooting
|
||||
|
||||
If `start` fails with `not reachable after start`, troubleshoot CDP readiness first. If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is healthy and the failure is usually navigation SSRF policy.
|
||||
|
||||
Minimal sequence:
|
||||
|
||||
```bash
|
||||
openclaw browser --browser-profile openclaw start
|
||||
openclaw browser --browser-profile openclaw tabs
|
||||
openclaw browser --browser-profile openclaw open https://example.com
|
||||
```
|
||||
|
||||
Detailed guidance: [Browser troubleshooting](/tools/browser#cdp-startup-failure-vs-navigation-ssrf-block)
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```bash
|
||||
|
||||
@@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see
|
||||
For WSL2 Gateway + Windows Chrome split-host setups, see
|
||||
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
|
||||
|
||||
### CDP startup failure vs navigation SSRF block
|
||||
|
||||
These are different failure classes and they point to different code paths.
|
||||
|
||||
- **CDP startup or readiness failure** means OpenClaw cannot confirm that the browser control plane is healthy.
|
||||
- **Navigation SSRF block** means the browser control plane is healthy, but a page navigation target is rejected by policy.
|
||||
|
||||
Common examples:
|
||||
|
||||
- CDP startup or readiness failure:
|
||||
- `Chrome CDP websocket for profile "openclaw" is not reachable after start`
|
||||
- `Remote CDP for profile "<name>" is not reachable at <cdpUrl>`
|
||||
- Navigation SSRF block:
|
||||
- `open`, `navigate`, snapshot, or tab-opening flows fail with a browser/network policy error while `start` and `tabs` still work
|
||||
|
||||
Use this minimal sequence to separate the two:
|
||||
|
||||
```bash
|
||||
openclaw browser --browser-profile openclaw start
|
||||
openclaw browser --browser-profile openclaw tabs
|
||||
openclaw browser --browser-profile openclaw open https://example.com
|
||||
```
|
||||
|
||||
How to read the results:
|
||||
|
||||
- If `start` fails with `not reachable after start`, troubleshoot CDP readiness first.
|
||||
- If `start` succeeds but `tabs` fails, the control plane is still unhealthy. Treat this as a CDP reachability problem, not a page-navigation problem.
|
||||
- If `start` and `tabs` succeed but `open` or `navigate` fails, the browser control plane is up and the failure is in navigation policy or the target page.
|
||||
- If `start`, `tabs`, and `open` all succeed, the basic managed-browser control path is healthy.
|
||||
|
||||
Important behavior details:
|
||||
|
||||
- Browser config defaults to a fail-closed SSRF policy object even when you do not configure `browser.ssrfPolicy`.
|
||||
- For the local loopback `openclaw` managed profile, CDP health checks intentionally skip browser SSRF reachability enforcement for OpenClaw's own local control plane.
|
||||
- Navigation protection is separate. A successful `start` or `tabs` result does not mean a later `open` or `navigate` target is allowed.
|
||||
|
||||
Security guidance:
|
||||
|
||||
- Do **not** relax browser SSRF policy by default.
|
||||
- Prefer narrow host exceptions such as `hostnameAllowlist` or `allowedHostnames` over broad private-network access.
|
||||
- Use `dangerouslyAllowPrivateNetwork: true` only in intentionally trusted environments where private-network browser access is required and reviewed.
|
||||
|
||||
Example: navigation blocked, control plane healthy
|
||||
|
||||
- `start` succeeds
|
||||
- `tabs` succeeds
|
||||
- `open http://internal.example` fails
|
||||
|
||||
That usually means browser startup is fine and the navigation target needs policy review.
|
||||
|
||||
Example: startup blocked before navigation matters
|
||||
|
||||
- `start` fails with `not reachable after start`
|
||||
- `tabs` also fails or cannot run
|
||||
|
||||
That points to browser launch or CDP reachability, not a page URL allowlist problem.
|
||||
|
||||
## Agent tools + how control works
|
||||
|
||||
The agent gets **one tool** for browser automation:
|
||||
|
||||
@@ -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