fix(browser): enforce node browser proxy allowProfiles

This commit is contained in:
Peter Steinberger
2026-03-23 00:55:23 -07:00
parent 3fd5d13315
commit eac93507c3
10 changed files with 218 additions and 54 deletions

View File

@@ -15,7 +15,7 @@ const dispatcherMocks = vi.hoisted(() => ({
const configMocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({
browser: {},
nodeHost: { browserProxy: { enabled: true } },
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
})),
}));
@@ -50,7 +50,7 @@ describe("runBrowserProxyCommand", () => {
controlServiceMocks.startBrowserControlServiceFromConfig.mockReset().mockResolvedValue(true);
configMocks.loadConfig.mockReset().mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true } },
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
});
browserConfigMocks.resolveBrowserConfig.mockReset().mockReturnValue({
enabled: true,
@@ -59,7 +59,7 @@ describe("runBrowserProxyCommand", () => {
({ runBrowserProxyCommand } = await import("./invoke-browser.js"));
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true } },
nodeHost: { browserProxy: { enabled: true, allowProfiles: [] as string[] } },
});
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
enabled: true,
@@ -183,4 +183,134 @@ describe("runBrowserProxyCommand", () => {
),
).rejects.toThrow("tab not found");
});
it("rejects unauthorized query.profile when allowProfiles is configured", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "GET",
path: "/snapshot",
query: { profile: "user" },
timeoutMs: 50,
}),
),
).rejects.toThrow("INVALID_REQUEST: browser profile not allowed");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
it("rejects unauthorized body.profile when allowProfiles is configured", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/stop",
body: { profile: "user" },
timeoutMs: 50,
}),
),
).rejects.toThrow("INVALID_REQUEST: browser profile not allowed");
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
it("rejects persistent profile creation when allowProfiles is configured", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
timeoutMs: 50,
}),
),
).rejects.toThrow(
"INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured",
);
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
it("rejects persistent profile deletion when allowProfiles is configured", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
});
await expect(
runBrowserProxyCommand(
JSON.stringify({
method: "DELETE",
path: "/profiles/poc",
timeoutMs: 50,
}),
),
).rejects.toThrow(
"INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured",
);
expect(dispatcherMocks.dispatch).not.toHaveBeenCalled();
});
it("canonicalizes an allowlisted body profile into the dispatched query", async () => {
configMocks.loadConfig.mockReturnValue({
browser: {},
nodeHost: { browserProxy: { enabled: true, allowProfiles: ["openclaw"] } },
});
dispatcherMocks.dispatch.mockResolvedValue({
status: 200,
body: { ok: true },
});
await runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/stop",
body: { profile: "openclaw" },
timeoutMs: 50,
}),
);
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
path: "/stop",
query: { profile: "openclaw" },
}),
);
});
it("preserves legacy proxy behavior when allowProfiles is empty", async () => {
dispatcherMocks.dispatch.mockResolvedValue({
status: 200,
body: { ok: true },
});
await runBrowserProxyCommand(
JSON.stringify({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
timeoutMs: 50,
}),
);
expect(dispatcherMocks.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
method: "POST",
path: "/profiles/create",
body: { name: "poc", cdpUrl: "http://127.0.0.1:9222" },
}),
);
});
});

View File

@@ -5,6 +5,11 @@ import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "../browser/control-service.js";
import {
isPersistentBrowserProfileMutation,
normalizeBrowserRequestPath,
resolveRequestedBrowserProfile,
} from "../browser/request-policy.js";
import { createBrowserRouteDispatcher } from "../browser/routes/dispatcher.js";
import { loadConfig } from "../config/config.js";
import { detectMime } from "../media/mime.js";
@@ -221,10 +226,23 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
await ensureBrowserControlService();
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = normalizeBrowserRequestPath(pathValue);
const body = params.body;
const requestedProfile =
resolveRequestedBrowserProfile({
query: params.query,
body,
profile: params.profile,
}) ?? "";
const allowedProfiles = proxyConfig.allowProfiles;
if (allowedProfiles.length > 0) {
if (pathValue !== "/profiles") {
if (isPersistentBrowserProfileMutation(method, path)) {
throw new Error(
"INVALID_REQUEST: browser.proxy cannot create or delete persistent browser profiles when allowProfiles is configured",
);
}
if (path !== "/profiles") {
const profileToCheck = requestedProfile || resolved.defaultProfile;
if (!isProfileAllowed({ allowProfiles: allowedProfiles, profile: profileToCheck })) {
throw new Error("INVALID_REQUEST: browser profile not allowed");
@@ -236,14 +254,8 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
}
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
const body = params.body;
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
const query: Record<string, unknown> = {};
if (requestedProfile) {
query.profile = requestedProfile;
}
const rawQuery = params.query ?? {};
for (const [key, value] of Object.entries(rawQuery)) {
if (value === undefined || value === null) {
@@ -251,6 +263,9 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
query[key] = typeof value === "string" ? value : String(value);
}
if (requestedProfile) {
query.profile = requestedProfile;
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
let response;