diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e204965d7..b281bf45d98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146) - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. +- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc. ### Fixes diff --git a/src/channels/account-snapshot-fields.test.ts b/src/channels/account-snapshot-fields.test.ts index 6ccd03ccc21..b6cf92a7836 100644 --- a/src/channels/account-snapshot-fields.test.ts +++ b/src/channels/account-snapshot-fields.test.ts @@ -24,4 +24,14 @@ describe("projectSafeChannelAccountSnapshotFields", () => { signingSecretStatus: "configured_unavailable", // pragma: allowlist secret }); }); + + it("strips embedded credentials from baseUrl fields", () => { + const snapshot = projectSafeChannelAccountSnapshotFields({ + baseUrl: "https://bob:secret@chat.example.test", + }); + + expect(snapshot).toEqual({ + baseUrl: "https://chat.example.test/", + }); + }); }); diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index 72d745beac0..b6eddbe9adf 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -30,6 +30,20 @@ function readTrimmedString(record: Record, key: string): string return trimmed.length > 0 ? trimmed : undefined; } +function stripUrlUserInfo(value: string): string { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) { + return value; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return value; + } +} + function readBoolean(record: Record, key: string): boolean | undefined { return typeof record[key] === "boolean" ? record[key] : undefined; } @@ -203,7 +217,7 @@ export function projectSafeChannelAccountSnapshotFields( : {}), ...projectCredentialSnapshotFields(account), ...(readTrimmedString(record, "baseUrl") - ? { baseUrl: readTrimmedString(record, "baseUrl") } + ? { baseUrl: stripUrlUserInfo(readTrimmedString(record, "baseUrl")!) } : {}), ...(readBoolean(record, "allowUnmentionedGroups") !== undefined ? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") } diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index e173be34ec8..6bb76b3030c 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -163,6 +163,22 @@ describe("redactConfigSnapshot", () => { expect(result.config).toEqual(snapshot.config); }); + it("removes embedded credentials from URL-valued endpoint fields", () => { + const snapshot = makeSnapshot({ + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, + }); + + const result = redactConfigSnapshot(snapshot); + const cfg = result.config as typeof snapshot.config; + expect(cfg.models.providers.openai.baseUrl).toBe("https://example.test/v1"); + }); + it("does not redact maxTokens-style fields", () => { const snapshot = makeSnapshot({ maxTokens: 16384, diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index a80d1debb03..65f5e70da5c 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -28,6 +28,24 @@ function isWholeObjectSensitivePath(path: string): boolean { return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref"); } +function isUserInfoUrlPath(path: string): boolean { + return path.endsWith(".baseUrl") || path.endsWith(".httpUrl"); +} + +function stripUrlUserInfo(value: string): string { + try { + const parsed = new URL(value); + if (!parsed.username && !parsed.password) { + return value; + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } catch { + return value; + } +} + function collectSensitiveStrings(value: unknown, values: string[]): void { if (typeof value === "string") { if (!isEnvVarPlaceholder(value)) { @@ -212,6 +230,8 @@ function redactObjectWithLookup( ) { // Keep primitives at explicitly-sensitive paths fully redacted. result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + result[key] = stripUrlUserInfo(value); } break; } @@ -229,6 +249,8 @@ function redactObjectWithLookup( ) { result[key] = REDACTED_SENTINEL; values.push(value); + } else if (typeof value === "string" && isUserInfoUrlPath(path)) { + result[key] = stripUrlUserInfo(value); } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, path, values, hints); } @@ -293,6 +315,8 @@ function redactObjectGuessing( ) { collectSensitiveStrings(value, values); result[key] = REDACTED_SENTINEL; + } else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) { + result[key] = stripUrlUserInfo(value); } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, dotPath, values, hints); } else {