fix: redact credentials in browser.cdpUrl config paths (#67679)

Merged via squash.

Prepared head SHA: 77bc2c50ce
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
This commit is contained in:
Ziy
2026-04-18 14:22:58 +08:00
committed by GitHub
parent c778562379
commit 4b5987829d
8 changed files with 171 additions and 3 deletions

View File

@@ -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.

View File

@@ -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.`,
);
}

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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",

View File

@@ -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<string, { cdpUrl?: string }>;
};
};
}>(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);

View File

@@ -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);

View File

@@ -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;
}