mirror of https://github.com/openclaw/openclaw.git
fix(browser): enforce node browser proxy allowProfiles
This commit is contained in:
parent
3fd5d13315
commit
eac93507c3
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue