diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bf0501a752..4d4d8f70e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/node.md b/docs/cli/node.md index e9cf5215c94..a7c967325f4 100644 --- a/docs/cli/node.md +++ b/docs/cli/node.md @@ -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 diff --git a/docs/tools/browser.md b/docs/tools/browser.md index 4797bc7409b..ea23264ff82 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -184,6 +184,8 @@ Notes: - The node host exposes its local browser control server via a **proxy command**. - Profiles come from the node’s 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 don’t want it: - On the node: `nodeHost.browserProxy.enabled=false` - On the gateway: `gateway.nodes.browser.mode="off"` diff --git a/src/browser/request-policy.ts b/src/browser/request-policy.ts new file mode 100644 index 00000000000..40df346c68c --- /dev/null +++ b/src/browser/request-policy.ts @@ -0,0 +1,46 @@ +type BrowserRequestProfileParams = { + query?: Record; + 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; +} diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index bddd0f2e3e2..39639df517f 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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: { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 28b1f552437..78ba36c5925 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -449,7 +449,7 @@ export const FIELD_HELP: Record = { "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": diff --git a/src/config/types.node-host.ts b/src/config/types.node-host.ts index aa13e6e370a..77610a341cb 100644 --- a/src/config/types.node-host.ts +++ b/src/config/types.node-host.ts @@ -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[]; }; diff --git a/src/gateway/server-methods/browser.ts b/src/gateway/server-methods/browser.ts index 0bb2db3dafd..72d88a4f4cf 100644 --- a/src/gateway/server-methods/browser.ts +++ b/src/gateway/server-methods/browser.ts @@ -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; - 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, diff --git a/src/node-host/invoke-browser.test.ts b/src/node-host/invoke-browser.test.ts index a5341841f03..8dcd2ac817d 100644 --- a/src/node-host/invoke-browser.test.ts +++ b/src/node-host/invoke-browser.test.ts @@ -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" }, + }), + ); + }); }); diff --git a/src/node-host/invoke-browser.ts b/src/node-host/invoke-browser.ts index 8a440dc905a..d352d2d8ea1 100644 --- a/src/node-host/invoke-browser.ts +++ b/src/node-host/invoke-browser.ts @@ -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 = {}; - 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;