From 4b5987829d0f82ea44ae50f2f418ffe5ea445e7f Mon Sep 17 00:00:00 2001 From: Ziy Date: Sat, 18 Apr 2026 14:22:58 +0800 Subject: [PATCH] fix: redact credentials in browser.cdpUrl config paths (#67679) Merged via squash. Prepared head SHA: 77bc2c50ce4bbd10c925fcd9162dc139e05e8a9f Co-authored-by: Ziy1-Tan <49604965+Ziy1-Tan@users.noreply.github.com> Co-authored-by: hxy91819 <8814856+hxy91819@users.noreply.github.com> Reviewed-by: @hxy91819 --- CHANGELOG.md | 2 + .../browser/server-context.availability.ts | 4 +- ...wser-available.waits-for-cdp-ready.test.ts | 36 +++++++++++ src/config/redact-snapshot.test.ts | 63 +++++++++++++++++++ src/config/schema.base.generated.ts | 4 +- src/gateway/server.config-patch.test.ts | 56 +++++++++++++++++ src/shared/net/redact-sensitive-url.test.ts | 6 ++ src/shared/net/redact-sensitive-url.ts | 3 + 8 files changed, 171 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35fed52ac1d..9779a73a663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - macOS/gateway: add `screen.snapshot` support for macOS app nodes, including runtime plumbing, default macOS allowlisting, and docs for monitor preview flows. (#67954) Thanks @BunsDev. +- fix: redact credentials in browser.cdpUrl config paths (#67679). Thanks @Ziy1-Tan ### Fixes @@ -45,6 +46,7 @@ Docs: https://docs.openclaw.ai - Exec approvals/display: escape raw control characters (including newline and carriage return) in the shared and macOS approval-prompt command sanitizers, so trailing command payloads no longer render on hidden extra lines in the approval UI. (#68198) - Telegram/streaming: fence same-session stale preview and finalization work after aborts so Telegram no longer replays an older reply or flushes a hidden short preview after the abort confirmation lands. (#68100) Thanks @rubencu. - OpenAI Codex/OAuth + Pi: keep imported Codex CLI OAuth bootstrap, Pi auth export, and runtime overlay handling aligned so Codex sessions survive refresh and health checks without leaking transient CLI state into saved auth files. Thanks @vincentkoc. +- Config/redact: add `browser.cdpUrl` and `browser.profiles.*.cdpUrl` to sensitive URL config paths so embedded credentials (query tokens and HTTP Basic auth) are properly redacted in `config.get` API responses and availability error messages. (#67679) Thanks @Ziy1-Tan. - Agents/TTS: report failed speech synthesis as a real tool error so unconfigured providers no longer feed successful TTS failure output back into agent loops. (#67980) Thanks @lawrence3699. - Gateway/wake: allow unknown properties on wake payloads so external senders like Paperclip can attach opaque metadata without failing schema validation. (#68355) Thanks @kagura-agent. - Matrix: honor `channels.matrix.network.dangerouslyAllowPrivateNetwork` when creating clients for private-network homeservers. (#68332) Thanks @kagura-agent. diff --git a/extensions/browser/src/browser/server-context.availability.ts b/extensions/browser/src/browser/server-context.availability.ts index 531e625a630..146f9561b15 100644 --- a/extensions/browser/src/browser/server-context.availability.ts +++ b/extensions/browser/src/browser/server-context.availability.ts @@ -7,6 +7,7 @@ import { PROFILE_POST_RESTART_WS_TIMEOUT_MS, resolveCdpReachabilityTimeouts, } from "./cdp-timeouts.js"; +import { redactCdpUrl } from "./cdp.helpers.js"; import { closeChromeMcpSession, ensureChromeMcpAvailable, @@ -59,6 +60,7 @@ export function createProfileAvailability({ getProfileState, setProfileRunning, }: AvailabilityDeps): AvailabilityOps { + const redactedProfileCdpUrl = redactCdpUrl(profile.cdpUrl) ?? profile.cdpUrl; const capabilities = getBrowserProfileCapabilities(profile); const resolveTimeouts = (timeoutMs: number | undefined) => resolveCdpReachabilityTimeouts({ @@ -210,7 +212,7 @@ export function createProfileAvailability({ if (attachOnly || remoteCdp) { throw new BrowserProfileUnavailableError( remoteCdp - ? `Remote CDP for profile "${profile.name}" is not reachable at ${profile.cdpUrl}.` + ? `Remote CDP for profile "${profile.name}" is not reachable at ${redactedProfileCdpUrl}.` : `Browser attachOnly is enabled and profile "${profile.name}" is not running.`, ); } diff --git a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts index 39f3e5770ab..9ad87c4a403 100644 --- a/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts +++ b/extensions/browser/src/browser/server-context.ensure-browser-available.waits-for-cdp-ready.test.ts @@ -5,6 +5,7 @@ import { PROFILE_HTTP_REACHABILITY_TIMEOUT_MS, } from "./cdp-timeouts.js"; import * as chromeModule from "./chrome.js"; +import { BrowserProfileUnavailableError } from "./errors.js"; import { createBrowserRouteContext } from "./server-context.js"; import { makeBrowserServerState, mockLaunchedChrome } from "./server-context.test-harness.js"; @@ -175,4 +176,39 @@ describe("browser server-context ensureBrowserAvailable", () => { expect(launchOpenClawChrome).not.toHaveBeenCalled(); expect(stopOpenClawChrome).not.toHaveBeenCalled(); }); + + it("redacts credentials in remote CDP availability errors", async () => { + const { launchOpenClawChrome, stopOpenClawChrome } = setupEnsureBrowserAvailableHarness(); + const isChromeReachable = vi.mocked(chromeModule.isChromeReachable); + + const state = makeBrowserServerState({ + profile: { + name: "remote", + cdpUrl: "https://user:pass@browserless.example.com?token=supersecret123", + cdpHost: "browserless.example.com", + cdpIsLoopback: false, + cdpPort: 443, + color: "#00AA00", + driver: "openclaw", + attachOnly: false, + }, + resolvedOverrides: { + defaultProfile: "remote", + ssrfPolicy: {}, + }, + }); + const ctx = createBrowserRouteContext({ getState: () => state }); + const profile = ctx.forProfile("remote"); + + isChromeReachable.mockResolvedValue(false); + + const promise = profile.ensureBrowserAvailable(); + await expect(promise).rejects.toThrow(BrowserProfileUnavailableError); + await expect(promise).rejects.toThrow( + 'Remote CDP for profile "remote" is not reachable at https://browserless.example.com/?token=***.', + ); + + expect(launchOpenClawChrome).not.toHaveBeenCalled(); + expect(stopOpenClawChrome).not.toHaveBeenCalled(); + }); }); diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 60cb7e9e4ef..7972489c6b5 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -1162,4 +1162,67 @@ describe("redactConfigSnapshot", () => { expect(channels.slack.accounts[0].botToken).toBe(REDACTED_SENTINEL); expect(channels.slack.accounts[1].botToken).toBe(REDACTED_SENTINEL); }); + + it("redacts browser cdpUrl secrets while preserving bare endpoints", () => { + const hints = buildConfigSchema().uiHints; + const raw = `{ + browser: { + cdpUrl: "https://user:pass@chrome.browserless.io?token=supersecret123", + profiles: { + remote: { + cdpUrl: "https://chrome.staging.example.com?token=staging-secret", + }, + prod: { + cdpUrl: "https://alice:secret@chrome.prod.example.com", + }, + local: { + cdpUrl: "ws://localhost:9222", + }, + }, + }, +}`; + const snapshot = makeSnapshot( + { + browser: { + cdpUrl: "https://user:pass@chrome.browserless.io?token=supersecret123", + profiles: { + remote: { + cdpUrl: "https://chrome.staging.example.com?token=staging-secret", + }, + prod: { + cdpUrl: "https://alice:secret@chrome.prod.example.com", + }, + local: { + cdpUrl: "ws://localhost:9222", + }, + }, + }, + }, + raw, + ); + + const result = redactConfigSnapshot(snapshot, hints); + const cfg = result.config as typeof snapshot.config; + expect(cfg.browser.cdpUrl).toBe(REDACTED_SENTINEL); + expect(cfg.browser.profiles.remote.cdpUrl).toBe(REDACTED_SENTINEL); + expect(cfg.browser.profiles.prod.cdpUrl).toBe(REDACTED_SENTINEL); + expect(cfg.browser.profiles.local.cdpUrl).toBe("ws://localhost:9222"); + expect(result.raw).toContain(REDACTED_SENTINEL); + expect(result.raw).not.toContain("user:pass@"); + expect(result.raw).not.toContain("supersecret123"); + expect(result.raw).not.toContain("staging-secret"); + expect(result.raw).not.toContain("alice:secret@"); + + const restored = restoreRedactedValues(result.config, snapshot.config, hints); + expect(restored.browser.cdpUrl).toBe( + "https://user:pass@chrome.browserless.io?token=supersecret123", + ); + expect(restored.browser.profiles.remote.cdpUrl).toBe( + "https://chrome.staging.example.com?token=staging-secret", + ); + expect(restored.browser.profiles.prod.cdpUrl).toBe( + "https://alice:secret@chrome.prod.example.com", + ); + expect(restored.browser.profiles.local.cdpUrl).toBe("ws://localhost:9222"); + }); }); diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 40ebc852c7e..32574809bbe 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -23522,7 +23522,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { "browser.cdpUrl": { label: "Browser CDP URL", help: "Remote CDP websocket URL used to attach to an externally managed browser instance. Use this for centralized browser hosts and keep URL access restricted to trusted network paths.", - tags: ["advanced"], + tags: ["advanced", "url-secret"], }, "browser.color": { label: "Browser Accent Color", @@ -23572,7 +23572,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { "browser.profiles.*.cdpUrl": { label: "Browser Profile CDP URL", help: "Per-profile CDP websocket URL used for explicit remote browser routing by profile name. Use this when profile connections terminate on remote hosts or tunnels.", - tags: ["storage"], + tags: ["storage", "url-secret"], }, "browser.profiles.*.userDataDir": { label: "Browser Profile User Data Dir", diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.test.ts index 4be2cc9e8c4..fc87da639b3 100644 --- a/src/gateway/server.config-patch.test.ts +++ b/src/gateway/server.config-patch.test.ts @@ -157,6 +157,62 @@ describe("gateway config methods", () => { expect(res.payload?.config).toBeTruthy(); }); + it("redacts browser cdpUrl credentials from config.get responses", async () => { + const { createConfigIO, resetConfigRuntimeState } = await import("../config/config.js"); + const configPath = createConfigIO().configPath; + await fs.mkdir(path.dirname(configPath), { recursive: true }); + try { + await fs.writeFile( + configPath, + `${JSON.stringify( + { + browser: { + cdpUrl: "https://user:pass@chrome.browserless.io?token=supersecret123", + profiles: { + remote: { + cdpUrl: "https://alice:secret@chrome.remote.example.com?token=profile-secret", + }, + local: { + cdpUrl: "ws://127.0.0.1:9222", + }, + }, + }, + }, + null, + 2, + )}\n`, + "utf-8", + ); + resetConfigRuntimeState(); + + const after = await rpcReq<{ + raw?: string | null; + config?: { + browser?: { + cdpUrl?: string; + profiles?: Record; + }; + }; + }>(requireWs(), "config.get", {}); + expect(after.ok).toBe(true); + expect(after.payload?.config?.browser?.cdpUrl).toBe("__OPENCLAW_REDACTED__"); + expect(after.payload?.config?.browser?.profiles?.remote?.cdpUrl).toBe( + "__OPENCLAW_REDACTED__", + ); + expect(after.payload?.config?.browser?.profiles?.local?.cdpUrl).toBe("ws://127.0.0.1:9222"); + if (typeof after.payload?.raw === "string") { + expect(after.payload.raw).toContain("__OPENCLAW_REDACTED__"); + expect(after.payload.raw).not.toContain("supersecret123"); + expect(after.payload.raw).not.toContain("user:pass@"); + expect(after.payload.raw).not.toContain("profile-secret"); + expect(after.payload.raw).not.toContain("alice:secret@"); + } + } finally { + await fs.rm(configPath, { force: true }); + resetConfigRuntimeState(); + } + }); + it("does not reject config.set for unresolved auth-profile refs outside submitted config", async () => { const missingEnvVar = `OPENCLAW_MISSING_AUTH_PROFILE_REF_${Date.now()}`; await writeUnresolvedAuthProfileTokenRef(missingEnvVar); diff --git a/src/shared/net/redact-sensitive-url.test.ts b/src/shared/net/redact-sensitive-url.test.ts index d9dedf7e4ad..b72b90655cb 100644 --- a/src/shared/net/redact-sensitive-url.test.ts +++ b/src/shared/net/redact-sensitive-url.test.ts @@ -51,6 +51,12 @@ describe("sensitive URL config metadata", () => { expect(isSensitiveUrlConfigPath("gateway.remote.url")).toBe(false); }); + it("recognizes cdpUrl config paths as sensitive (browser CDP URLs can embed credentials)", () => { + expect(isSensitiveUrlConfigPath("browser.cdpUrl")).toBe(true); + expect(isSensitiveUrlConfigPath("browser.profiles.remote.cdpUrl")).toBe(true); + expect(isSensitiveUrlConfigPath("browser.profiles.staging.cdpUrl")).toBe(true); + }); + it("uses an explicit url-secret hint tag", () => { expect(SENSITIVE_URL_HINT_TAG).toBe("url-secret"); expect(hasSensitiveUrlHintTag({ tags: [SENSITIVE_URL_HINT_TAG] })).toBe(true); diff --git a/src/shared/net/redact-sensitive-url.ts b/src/shared/net/redact-sensitive-url.ts index 144d0ead19b..c2a3f379f27 100644 --- a/src/shared/net/redact-sensitive-url.ts +++ b/src/shared/net/redact-sensitive-url.ts @@ -25,6 +25,9 @@ export function isSensitiveUrlConfigPath(path: string): boolean { if (path.endsWith(".baseUrl") || path.endsWith(".httpUrl")) { return true; } + if (path.endsWith(".cdpUrl")) { + return true; + } if (path.endsWith(".request.proxy.url")) { return true; }