Gateway: scrub credentials from endpoint snapshots

This commit is contained in:
Vincent Koc 2026-03-14 20:19:22 -07:00
parent db20141993
commit 0f19c4c3f7
5 changed files with 66 additions and 1 deletions

View File

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

View File

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

View File

@ -30,6 +30,20 @@ function readTrimmedString(record: Record<string, unknown>, 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<string, unknown>, 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") }

View File

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

View File

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