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
No known key found for this signature in database
10 changed files with 218 additions and 54 deletions

View File

@ -119,6 +119,7 @@ Docs: https://docs.openclaw.ai
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- CLI/configure: clarify fresh-setup memory-search warnings so they say semantic recall needs at least one embedding provider, and scope the initial model allowlist picker to the provider selected in configure. Thanks @vincentkoc.
- CLI/status: keep `status --json` stdout clean by skipping plugin compatibility scans that were not rendered in the JSON payload. (#52449) Thanks @cgdusek.
- Browser/node proxy: enforce `nodeHost.browserProxy.allowProfiles` across `query.profile` and `body.profile`, block proxy-side profile create/delete when the allowlist is set, and keep the default full proxy surface when the allowlist is empty.
- Web tools/Exa: align the bundled Exa plugin with the current Exa API by supporting newer search types and richer `contents` options, while fixing the result-count cap to honor Exa's higher limit. Thanks @vincentkoc.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Mattermost/threading: honor `replyToMode: "off"` for already-threaded inbound posts so threaded follow-ups can fall back to top-level replies when configured. (#52543) Thanks @RichardCao.

View File

@ -31,6 +31,11 @@ Node hosts automatically advertise a browser proxy if `browser.enabled` is not
disabled on the node. This lets the agent use browser automation on that node
without extra configuration.
By default, the proxy exposes the node's normal browser profile surface. If you
set `nodeHost.browserProxy.allowProfiles`, the proxy becomes restrictive:
non-allowlisted profile targeting is rejected, and persistent profile
create/delete routes are blocked through the proxy.
Disable it on the node if needed:
```json5

View File

@ -184,6 +184,8 @@ Notes:
- The node host exposes its local browser control server via a **proxy command**.
- Profiles come from the nodes own `browser.profiles` config (same as local).
- `nodeHost.browserProxy.allowProfiles` is optional. Leave it empty for the legacy/default behavior: all configured profiles remain reachable through the proxy, including profile create/delete routes.
- If you set `nodeHost.browserProxy.allowProfiles`, OpenClaw treats it as a least-privilege boundary: only allowlisted profiles can be targeted, and persistent profile create/delete routes are blocked on the proxy surface.
- Disable if you dont want it:
- On the node: `nodeHost.browserProxy.enabled=false`
- On the gateway: `gateway.nodes.browser.mode="off"`

View File

@ -0,0 +1,46 @@
type BrowserRequestProfileParams = {
query?: Record<string, unknown>;
body?: unknown;
profile?: string | null;
};
export function normalizeBrowserRequestPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withLeadingSlash.length <= 1) {
return withLeadingSlash;
}
return withLeadingSlash.replace(/\/+$/, "");
}
export function isPersistentBrowserProfileMutation(method: string, path: string): boolean {
const normalizedPath = normalizeBrowserRequestPath(path);
if (method === "POST" && normalizedPath === "/profiles/create") {
return true;
}
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
}
export function resolveRequestedBrowserProfile(
params: BrowserRequestProfileParams,
): string | undefined {
const queryProfile =
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
if (queryProfile) {
return queryProfile;
}
if (params.body && typeof params.body === "object") {
const bodyProfile =
"profile" in params.body && typeof params.body.profile === "string"
? params.body.profile.trim()
: undefined;
if (bodyProfile) {
return bodyProfile;
}
}
const explicitProfile = typeof params.profile === "string" ? params.profile.trim() : undefined;
return explicitProfile || undefined;
}

View File

@ -13161,7 +13161,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = {
},
"nodeHost.browserProxy.allowProfiles": {
label: "Node Browser Proxy Allowed Profiles",
help: "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.",
help: "Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to preserve the default full profile surface, including profile create/delete routes. When set, OpenClaw enforces least-privilege profile access and blocks persistent profile create/delete through the proxy.",
tags: ["access", "network", "storage"],
},
media: {

View File

@ -449,7 +449,7 @@ export const FIELD_HELP: Record<string, string> = {
"nodeHost.browserProxy.enabled":
"Expose the local browser control server through node proxy routing so remote clients can use this host's browser capabilities. Keep disabled unless remote automation explicitly depends on it.",
"nodeHost.browserProxy.allowProfiles":
"Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to expose all configured profiles, or use a tight list to enforce least-privilege profile access.",
"Optional allowlist of browser profile names exposed through node proxy routing. Leave empty to preserve the default full profile surface, including profile create/delete routes. When set, OpenClaw enforces least-privilege profile access and blocks persistent profile create/delete through the proxy.",
media:
"Top-level media behavior shared across providers and tools that handle inbound files. Keep defaults unless you need stable filenames for external processing pipelines or longer-lived inbound media retention.",
"media.preserveFilenames":

View File

@ -1,7 +1,7 @@
export type NodeHostBrowserProxyConfig = {
/** Enable the browser proxy on the node host (default: true). */
enabled?: boolean;
/** Optional allowlist of profile names exposed via the proxy. */
/** Optional allowlist of profile names exposed via the proxy; when set, create/delete profile routes are blocked on the proxy surface. */
allowProfiles?: string[];
};

View File

@ -4,6 +4,10 @@ import {
startBrowserControlServiceFromConfig,
} from "../../browser/control-service.js";
import { applyBrowserProxyPaths, persistBrowserProxyFiles } from "../../browser/proxy-files.js";
import {
isPersistentBrowserProfileMutation,
resolveRequestedBrowserProfile,
} from "../../browser/request-policy.js";
import { createBrowserRouteDispatcher } from "../../browser/routes/dispatcher.js";
import { loadConfig } from "../../config/config.js";
import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js";
@ -20,45 +24,6 @@ type BrowserRequestParams = {
timeoutMs?: number;
};
function normalizeBrowserRequestPath(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return trimmed;
}
const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withLeadingSlash.length <= 1) {
return withLeadingSlash;
}
return withLeadingSlash.replace(/\/+$/, "");
}
function isPersistentBrowserProfileMutation(method: string, path: string): boolean {
const normalizedPath = normalizeBrowserRequestPath(path);
if (method === "POST" && normalizedPath === "/profiles/create") {
return true;
}
return method === "DELETE" && /^\/profiles\/[^/]+$/.test(normalizedPath);
}
function resolveRequestedProfile(params: {
query?: Record<string, unknown>;
body?: unknown;
}): string | undefined {
const queryProfile =
typeof params.query?.profile === "string" ? params.query.profile.trim() : undefined;
if (queryProfile) {
return queryProfile;
}
if (!params.body || typeof params.body !== "object") {
return undefined;
}
const bodyProfile =
"profile" in params.body && typeof params.body.profile === "string"
? params.body.profile.trim()
: undefined;
return bodyProfile || undefined;
}
type BrowserProxyFile = {
path: string;
base64: string;
@ -237,7 +202,7 @@ export const browserHandlers: GatewayRequestHandlers = {
query,
body,
timeoutMs,
profile: resolveRequestedProfile({ query, body }),
profile: resolveRequestedBrowserProfile({ query, body }),
};
const res = await context.nodeRegistry.invoke({
nodeId: nodeTarget.nodeId,

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;