Browser: bypass SSRF for managed loopback CDP probes

This commit is contained in:
mbelinky
2026-04-13 17:55:56 +02:00
parent b2589ac451
commit cc54557c64
6 changed files with 66 additions and 12 deletions

View File

@@ -15,7 +15,7 @@ Docs: https://docs.openclaw.ai
- WhatsApp: patch installed Baileys media encryption writes during OpenClaw postinstall so the default npm/install.sh delivery path waits for encrypted media files to finish flushing before readback, avoiding transient `ENOENT` crashes on image sends. (#65896) Thanks @frankekn.
- Gateway/update: unify service entrypoint resolution around the canonical bundled gateway entrypoint so update, reinstall, and doctor repair stop drifting between stale `dist/entry.js` and current `dist/index.js` paths. (#65984) Thanks @mbelinky.
- Heartbeat/Telegram topics: keep isolated heartbeat replies on the bound forum topic when `target=last`, instead of dropping them into the group root chat. (#66035) Thanks @mbelinky.
- Browser/CDP: let managed local Chrome readiness, status probes, and managed loopback CDP control bypass browser SSRF policy for their own loopback control plane, so OpenClaw no longer misclassifies a healthy child browser as "not reachable after start". (#65695, #66043) Thanks @mbelinky.
## 2026.4.12
### Changes

View File

@@ -0,0 +1,19 @@
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
export function resolveCdpReachabilityPolicy(
profile: ResolvedBrowserProfile,
ssrfPolicy?: SsrFPolicy,
): SsrFPolicy | undefined {
const capabilities = getBrowserProfileCapabilities(profile);
if (
capabilities.mode === "local-managed" &&
profile.cdpIsLoopback &&
!profile.attachOnly &&
profile.driver === "openclaw"
) {
return undefined;
}
return ssrfPolicy;
}

View File

@@ -1,4 +1,5 @@
import fs from "node:fs";
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
CHROME_MCP_ATTACH_READY_POLL_MS,
CHROME_MCP_ATTACH_READY_WINDOW_MS,
@@ -67,6 +68,9 @@ export function createProfileAvailability({
remoteHandshakeTimeoutMs: state().resolved.remoteCdpHandshakeTimeoutMs,
});
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
@@ -78,7 +82,7 @@ export function createProfileAvailability({
profile.cdpUrl,
httpTimeoutMs,
wsTimeoutMs,
state().resolved.ssrfPolicy,
getCdpReachabilityPolicy(),
);
};
@@ -87,7 +91,7 @@ export function createProfileAvailability({
return await isReachable(timeoutMs);
}
const { httpTimeoutMs } = resolveTimeouts(timeoutMs);
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, state().resolved.ssrfPolicy);
return await isChromeReachable(profile.cdpUrl, httpTimeoutMs, getCdpReachabilityPolicy());
};
const attachRunning = (running: NonNullable<ProfileRuntimeState["running"]>) => {

View File

@@ -21,7 +21,7 @@ function setupEnsureBrowserAvailableHarness() {
const ctx = createBrowserRouteContext({ getState: () => state });
const profile = ctx.forProfile("openclaw");
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile };
return { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state };
}
afterEach(() => {
@@ -62,9 +62,10 @@ describe("browser server-context ensureBrowserAvailable", () => {
});
it("reuses a pre-existing loopback browser after an initial short probe miss", async () => {
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile } =
const { launchOpenClawChrome, stopOpenClawChrome, isChromeCdpReady, profile, state } =
setupEnsureBrowserAvailableHarness();
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
state.resolved.ssrfPolicy = {};
isChromeReachable.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
isChromeCdpReady.mockResolvedValueOnce(true);
@@ -75,17 +76,13 @@ describe("browser server-context ensureBrowserAvailable", () => {
1,
"http://127.0.0.1:18800",
PROFILE_HTTP_REACHABILITY_TIMEOUT_MS,
{
allowPrivateNetwork: true,
},
undefined,
);
expect(isChromeReachable).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:18800",
PROFILE_ATTACH_RETRY_TIMEOUT_MS,
{
allowPrivateNetwork: true,
},
undefined,
);
expect(launchOpenClawChrome).not.toHaveBeenCalled();
expect(stopOpenClawChrome).not.toHaveBeenCalled();

View File

@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import "./server-context.chrome-test-harness.js";
import * as chromeModule from "./chrome.js";
import { createBrowserRouteContext } from "./server-context.js";
import { makeBrowserServerState } from "./server-context.test-harness.js";
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("browser server-context listProfiles", () => {
it("bypasses SSRF gating when probing managed loopback profiles", async () => {
const state = makeBrowserServerState({
resolvedOverrides: {
ssrfPolicy: {},
},
});
const isChromeReachable = vi.mocked(chromeModule.isChromeReachable);
isChromeReachable.mockResolvedValue(true);
const ctx = createBrowserRouteContext({ getState: () => state });
const profiles = await ctx.listProfiles();
expect(isChromeReachable).toHaveBeenCalledWith("http://127.0.0.1:18800", 200, undefined);
expect(profiles).toEqual([
expect.objectContaining({
name: "openclaw",
running: true,
}),
]);
});
});

View File

@@ -1,3 +1,4 @@
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
@@ -189,7 +190,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const reachable = await isChromeReachable(
profile.cdpUrl,
200,
current.resolved.ssrfPolicy,
resolveCdpReachabilityPolicy(profile, current.resolved.ssrfPolicy),
);
if (reachable) {
running = true;