Compare commits

...

2 Commits

Author SHA1 Message Date
Agustin Rivera
5d815b216b fix(browser): skip trusted cdp dns gate 2026-04-03 18:21:25 +00:00
Agustin Rivera
6072e462a2 fix(browser): enforce profile cdp policy 2026-04-03 18:04:10 +00:00
3 changed files with 65 additions and 3 deletions

View File

@@ -23,17 +23,27 @@ export function isWebSocketUrl(url: string): boolean {
}
}
function shouldSkipCreationTimePolicyResolution(ssrfPolicy?: SsrFPolicy): boolean {
if (!ssrfPolicy) {
return true;
}
return (
ssrfPolicy.dangerouslyAllowPrivateNetwork === true &&
(!ssrfPolicy.hostnameAllowlist || ssrfPolicy.hostnameAllowlist.length === 0)
);
}
export async function assertCdpEndpointAllowed(
cdpUrl: string,
ssrfPolicy?: SsrFPolicy,
): Promise<void> {
if (!ssrfPolicy) {
return;
}
const parsed = new URL(cdpUrl);
if (!["http:", "https:", "ws:", "wss:"].includes(parsed.protocol)) {
throw new Error(`Invalid CDP URL protocol: ${parsed.protocol.replace(":", "")}`);
}
if (shouldSkipCreationTimePolicyResolution(ssrfPolicy)) {
return;
}
await resolvePinnedHostnameWithPolicy(parsed.hostname, {
policy: ssrfPolicy,
});

View File

@@ -141,6 +141,56 @@ describe("BrowserProfilesService", () => {
);
});
it("accepts remote hostnames without DNS resolution in trusted SSRF mode", async () => {
const resolved = resolveBrowserConfig({});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
const result = await service.createProfile({
name: "remote-hostname",
cdpUrl: "https://vpn-only.invalid:9222",
});
expect(result.cdpUrl).toBe("https://vpn-only.invalid:9222");
expect(result.cdpPort).toBe(9222);
expect(result.isRemote).toBe(true);
expect(writeConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
browser: expect.objectContaining({
profiles: expect.objectContaining({
"remote-hostname": expect.objectContaining({
cdpUrl: "https://vpn-only.invalid:9222",
}),
}),
}),
}),
);
});
it("rejects private-network cdpUrl when strict SSRF mode is enabled", async () => {
const resolved = resolveBrowserConfig({
ssrfPolicy: {
dangerouslyAllowPrivateNetwork: false,
},
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
const service = createBrowserProfilesService(ctx);
await expect(
service.createProfile({
name: "remote",
cdpUrl: "http://10.0.0.42:9222",
}),
).rejects.toThrow(/blocked hostname|private\/internal\/special-use ip address/i);
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("creates existing-session profiles as attach-only local entries", async () => {
const resolved = resolveBrowserConfig({});
const { ctx, state } = createCtx(resolved);

View File

@@ -4,6 +4,7 @@ import type { BrowserProfileConfig, OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { deriveDefaultBrowserCdpPortRange } from "../config/port-defaults.js";
import { resolveUserPath } from "../utils.js";
import { assertCdpEndpointAllowed } from "./cdp.helpers.js";
import { resolveOpenClawUserDataDir } from "./chrome.js";
import { parseHttpUrl, resolveProfile } from "./config.js";
import {
@@ -124,6 +125,7 @@ export function createBrowserProfilesService(ctx: BrowserRouteContext) {
let parsed: ReturnType<typeof parseHttpUrl>;
try {
parsed = parseHttpUrl(rawCdpUrl, "browser.profiles.cdpUrl");
await assertCdpEndpointAllowed(parsed.normalized, state.resolved.ssrfPolicy);
} catch (err) {
throw new BrowserValidationError(String(err));
}