mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 14:10:51 +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:
@@ -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: 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.
|
- 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.
|
- 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
|
## 2026.4.14-beta.1
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,20 @@ openclaw browser --browser-profile openclaw open https://example.com
|
|||||||
openclaw browser --browser-profile openclaw snapshot
|
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
|
## Lifecycle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -884,6 +884,63 @@ For Linux-specific issues (especially snap Chromium), see
|
|||||||
For WSL2 Gateway + Windows Chrome split-host setups, see
|
For WSL2 Gateway + Windows Chrome split-host setups, see
|
||||||
[WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting).
|
[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
|
## Agent tools + how control works
|
||||||
|
|
||||||
The agent gets **one tool** for browser automation:
|
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", () => {
|
describe("cdp helpers", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -43,6 +43,23 @@ describe("cdp helpers", () => {
|
|||||||
expect(release).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("releases guarded CDP fetches for bodyless requests", async () => {
|
||||||
const release = vi.fn(async () => {});
|
const release = vi.fn(async () => {});
|
||||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||||
@@ -62,4 +79,62 @@ describe("cdp helpers", () => {
|
|||||||
|
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
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(":", "")}`);
|
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
const policy = isLoopbackHost(parsed.hostname)
|
||||||
|
? {
|
||||||
|
...ssrfPolicy,
|
||||||
|
allowedHostnames: Array.from(
|
||||||
|
new Set([...(ssrfPolicy?.allowedHostnames ?? []), parsed.hostname]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: ssrfPolicy;
|
||||||
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
|
||||||
policy: ssrfPolicy,
|
policy,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new BrowserCdpEndpointBlockedError({ cause: error });
|
throw new BrowserCdpEndpointBlockedError({ cause: error });
|
||||||
@@ -263,11 +271,20 @@ export async function fetchCdpChecked(
|
|||||||
try {
|
try {
|
||||||
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
const headers = getHeadersWithAuth(url, (init?.headers as Record<string, string>) || {});
|
||||||
const res = await withNoProxyForCdpUrl(url, async () => {
|
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({
|
const guarded = await fetchWithSsrFGuard({
|
||||||
url,
|
url,
|
||||||
init: { ...init, headers },
|
init: { ...init, headers },
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
policy: ssrfPolicy ?? { allowPrivateNetwork: true },
|
policy,
|
||||||
auditContext: "browser-cdp",
|
auditContext: "browser-cdp",
|
||||||
});
|
});
|
||||||
guardedRelease = guarded.release;
|
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);
|
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 () => {
|
it("allows loopback CDP probes while still blocking non-loopback private targets in strict SSRF mode", async () => {
|
||||||
const fetchSpy = vi.fn().mockRejectedValue(new Error("should not be called"));
|
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);
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
isChromeReachable("http://127.0.0.1:12345", 50, {
|
isChromeReachable("http://127.0.0.1:12345", 50, {
|
||||||
dangerouslyAllowPrivateNetwork: false,
|
dangerouslyAllowPrivateNetwork: false,
|
||||||
}),
|
}),
|
||||||
).resolves.toBe(false);
|
).resolves.toBe(true);
|
||||||
await expect(
|
await expect(
|
||||||
isChromeReachable("ws://127.0.0.1:19999", 50, {
|
isChromeReachable("http://169.254.169.254:12345", 50, {
|
||||||
dangerouslyAllowPrivateNetwork: false,
|
dangerouslyAllowPrivateNetwork: false,
|
||||||
}),
|
}),
|
||||||
).resolves.toBe(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 () => {
|
it("blocks cross-host websocket pivots returned by /json/version in strict SSRF mode", async () => {
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ export function createProfileAvailability({
|
|||||||
|
|
||||||
const getCdpReachabilityPolicy = () =>
|
const getCdpReachabilityPolicy = () =>
|
||||||
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
|
resolveCdpReachabilityPolicy(profile, state().resolved.ssrfPolicy);
|
||||||
|
|
||||||
const isReachable = async (timeoutMs?: number) => {
|
const isReachable = async (timeoutMs?: number) => {
|
||||||
if (capabilities.usesChromeMcp) {
|
if (capabilities.usesChromeMcp) {
|
||||||
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
|
// listChromeMcpTabs creates the session if needed — no separate ensureChromeMcpAvailable call required
|
||||||
|
|||||||
Reference in New Issue
Block a user