Browser: extend managed loopback CDP control bypass

This commit is contained in:
mbelinky
2026-04-13 18:09:04 +02:00
parent cc54557c64
commit c3d0a99ffa
7 changed files with 89 additions and 22 deletions

View File

@@ -17,3 +17,5 @@ export function resolveCdpReachabilityPolicy(
}
return ssrfPolicy;
}
export const resolveCdpControlPolicy = resolveCdpReachabilityPolicy;

View File

@@ -46,6 +46,21 @@ export function requiresInspectableBrowserNavigationRedirects(ssrfPolicy?: SsrFP
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
}
export function requiresInspectableBrowserNavigationRedirectsForUrl(
url: string,
ssrfPolicy?: SsrFPolicy,
): boolean {
if (!requiresInspectableBrowserNavigationRedirects(ssrfPolicy)) {
return false;
}
try {
const parsed = new URL(url);
return NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol);
} catch {
return false;
}
}
function isIpLiteralHostname(hostname: string): boolean {
return isIP(normalizeHostname(hostname)) !== 0;
}

View File

@@ -11,7 +11,7 @@ afterEach(() => {
});
describe("browser server-context loopback direct WebSocket profiles", () => {
it("uses an HTTP /json/list base when opening tabs", async () => {
it("uses an HTTP /json/list base when opening about:blank under strict SSRF", async () => {
const createTargetViaCdp = vi
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
@@ -25,7 +25,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
{
id: "CREATED",
title: "New Tab",
url: "http://127.0.0.1:8080",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
@@ -35,6 +35,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
state.resolved.profiles.openclaw = {
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
color: "#FF4500",
@@ -42,16 +43,16 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const opened = await openclaw.openTab("http://127.0.0.1:8080");
const opened = await openclaw.openTab("about:blank");
expect(opened.targetId).toBe("CREATED");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
url: "about:blank",
ssrfPolicy: undefined,
});
});
it("uses an HTTP /json base for focus and close", async () => {
it("uses an HTTP /json base for focus and close under strict SSRF", async () => {
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (u === "http://127.0.0.1:18800/json/list?token=abc") {
@@ -79,6 +80,7 @@ describe("browser server-context loopback direct WebSocket profiles", () => {
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
state.resolved.profiles.openclaw = {
cdpUrl: "ws://127.0.0.1:18800/devtools/browser/SESSION?token=abc",
color: "#FF4500",

View File

@@ -14,7 +14,7 @@ import { resolveTargetIdFromTabs } from "./target-id.js";
type SelectionDeps = {
profile: ResolvedBrowserProfile;
getProfileState: () => ProfileRuntimeState;
getSsrFPolicy: () => SsrFPolicy | undefined;
getCdpControlPolicy: () => SsrFPolicy | undefined;
ensureBrowserAvailable: () => Promise<void>;
listTabs: () => Promise<BrowserTab[]>;
openTab: (url: string) => Promise<BrowserTab>;
@@ -29,7 +29,7 @@ type SelectionOps = {
export function createProfileSelectionOps({
profile,
getProfileState,
getSsrFPolicy,
getCdpControlPolicy,
ensureBrowserAvailable,
listTabs,
openTab,
@@ -112,7 +112,7 @@ export function createProfileSelectionOps({
await focusPageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
ssrfPolicy: getSsrFPolicy(),
ssrfPolicy: getCdpControlPolicy(),
});
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
@@ -124,7 +124,7 @@ export function createProfileSelectionOps({
appendCdpPath(cdpHttpBase, `/json/activate/${resolvedTargetId}`),
undefined,
undefined,
getSsrFPolicy(),
getCdpControlPolicy(),
);
const profileState = getProfileState();
profileState.lastTargetId = resolvedTargetId;
@@ -147,7 +147,7 @@ export function createProfileSelectionOps({
await closePageByTargetIdViaPlaywright({
cdpUrl: profile.cdpUrl,
targetId: resolvedTargetId,
ssrfPolicy: getSsrFPolicy(),
ssrfPolicy: getCdpControlPolicy(),
});
return;
}
@@ -157,7 +157,7 @@ export function createProfileSelectionOps({
appendCdpPath(cdpHttpBase, `/json/close/${resolvedTargetId}`),
undefined,
undefined,
getSsrFPolicy(),
getCdpControlPolicy(),
);
};

View File

@@ -1,3 +1,4 @@
import { resolveCdpControlPolicy } from "./cdp-reachability-policy.js";
import { CDP_JSON_NEW_TIMEOUT_MS } from "./cdp-timeouts.js";
import {
assertCdpEndpointAllowed,
@@ -12,7 +13,7 @@ import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
InvalidBrowserNavigationUrlError,
requiresInspectableBrowserNavigationRedirects,
requiresInspectableBrowserNavigationRedirectsForUrl,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { getBrowserProfileCapabilities } from "./profile-capabilities.js";
@@ -69,7 +70,7 @@ export function createProfileTabOps({
}: TabOpsDeps): ProfileTabOps {
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(profile.cdpUrl);
const capabilities = getBrowserProfileCapabilities(profile);
const getSsrFPolicy = () => state().resolved.ssrfPolicy;
const getCdpControlPolicy = () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy);
const listTabs = async (): Promise<BrowserTab[]> => {
if (capabilities.usesChromeMcp) {
@@ -80,7 +81,7 @@ export function createProfileTabOps({
const mod = await getPwAiModule({ mode: "strict" });
const listPagesViaPlaywright = (mod as Partial<PwAiModule> | null)?.listPagesViaPlaywright;
if (typeof listPagesViaPlaywright === "function") {
const ssrfPolicy = getSsrFPolicy();
const ssrfPolicy = getCdpControlPolicy();
await assertCdpEndpointAllowed(profile.cdpUrl, ssrfPolicy);
const pages = await listPagesViaPlaywright({ cdpUrl: profile.cdpUrl, ssrfPolicy });
return pages.map((p) => ({
@@ -100,7 +101,7 @@ export function createProfileTabOps({
webSocketDebuggerUrl?: string;
type?: string;
}>
>(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getSsrFPolicy());
>(appendCdpPath(cdpHttpBase, "/json/list"), undefined, undefined, getCdpControlPolicy());
return raw
.map((t) => ({
targetId: t.id ?? "",
@@ -136,7 +137,7 @@ export function createProfileTabOps({
appendCdpPath(cdpHttpBase, `/json/close/${tab.targetId}`),
undefined,
undefined,
getSsrFPolicy(),
getCdpControlPolicy(),
).catch(() => {
// best-effort cleanup only
});
@@ -182,7 +183,7 @@ export function createProfileTabOps({
}
}
if (requiresInspectableBrowserNavigationRedirects(state().resolved.ssrfPolicy)) {
if (requiresInspectableBrowserNavigationRedirectsForUrl(url, state().resolved.ssrfPolicy)) {
throw new InvalidBrowserNavigationUrlError(
"Navigation blocked: strict browser SSRF policy requires Playwright-backed redirect-hop inspection",
);
@@ -191,7 +192,7 @@ export function createProfileTabOps({
const createdViaCdp = await createTargetViaCdp({
cdpUrl: profile.cdpUrl,
url,
...ssrfPolicyOpts,
ssrfPolicy: getCdpControlPolicy(),
})
.then((r) => r.targetId)
.catch(() => null);

View File

@@ -125,7 +125,51 @@ describe("browser server-context tab selection state", () => {
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
url: "http://127.0.0.1:8080",
ssrfPolicy: { allowPrivateNetwork: true },
ssrfPolicy: undefined,
});
});
it("can bootstrap a managed loopback tab under strict SSRF because CDP control stays local", async () => {
const createTargetViaCdp = vi
.spyOn(cdpModule, "createTargetViaCdp")
.mockResolvedValue({ targetId: "CREATED" });
let listCount = 0;
const fetchMock = vi.fn(async (url: unknown) => {
const u = String(url);
if (!u.includes("/json/list")) {
throw new Error(`unexpected fetch: ${u}`);
}
listCount += 1;
return {
ok: true,
json: async () =>
listCount === 1
? []
: [
{
id: "CREATED",
title: "New Tab",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/CREATED",
type: "page",
},
],
} as unknown as Response;
});
global.fetch = withFetchPreconnect(fetchMock);
const state = makeState("openclaw");
state.resolved.ssrfPolicy = {};
const ctx = createBrowserRouteContext({ getState: () => state });
const openclaw = ctx.forProfile("openclaw");
const selected = await openclaw.ensureTabAvailable();
expect(selected.targetId).toBe("CREATED");
expect(createTargetViaCdp).toHaveBeenCalledWith({
cdpUrl: "http://127.0.0.1:18800",
url: "about:blank",
ssrfPolicy: undefined,
});
});

View File

@@ -1,4 +1,7 @@
import { resolveCdpReachabilityPolicy } from "./cdp-reachability-policy.js";
import {
resolveCdpControlPolicy,
resolveCdpReachabilityPolicy,
} from "./cdp-reachability-policy.js";
import { isChromeReachable, resolveOpenClawUserDataDir } from "./chrome.js";
import type { ResolvedBrowserProfile } from "./config.js";
import { resolveProfile } from "./config.js";
@@ -87,7 +90,7 @@ function createProfileContext(
const { ensureTabAvailable, focusTab, closeTab } = createProfileSelectionOps({
profile,
getProfileState,
getSsrFPolicy: () => state().resolved.ssrfPolicy,
getCdpControlPolicy: () => resolveCdpControlPolicy(profile, state().resolved.ssrfPolicy),
ensureBrowserAvailable,
listTabs,
openTab,