From aabb1398ccb9bbb10d565dc5e8c79155feb310d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 14 Mar 2026 23:20:15 -0700 Subject: [PATCH] Gateway: scrub raw endpoint credentials in snapshots --- src/channels/account-snapshot-fields.ts | 15 +---------- src/config/redact-snapshot.test.ts | 26 ++++++++++++++----- src/config/redact-snapshot.ts | 33 ++++++++++++------------- src/shared/net/url-userinfo.ts | 13 ++++++++++ 4 files changed, 50 insertions(+), 37 deletions(-) create mode 100644 src/shared/net/url-userinfo.ts diff --git a/src/channels/account-snapshot-fields.ts b/src/channels/account-snapshot-fields.ts index b6eddbe9adf..bfdc7ed6381 100644 --- a/src/channels/account-snapshot-fields.ts +++ b/src/channels/account-snapshot-fields.ts @@ -1,3 +1,4 @@ +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; // Read-only status commands project a safe subset of account fields into snapshots @@ -30,20 +31,6 @@ 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; } diff --git a/src/config/redact-snapshot.test.ts b/src/config/redact-snapshot.test.ts index 6bb76b3030c..95be88ffd05 100644 --- a/src/config/redact-snapshot.test.ts +++ b/src/config/redact-snapshot.test.ts @@ -164,19 +164,33 @@ describe("redactConfigSnapshot", () => { }); it("removes embedded credentials from URL-valued endpoint fields", () => { - const snapshot = makeSnapshot({ - models: { - providers: { - openai: { - baseUrl: "https://alice:secret@example.test/v1", + const raw = `{ + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, + }, + }, +}`; + const snapshot = makeSnapshot( + { + models: { + providers: { + openai: { + baseUrl: "https://alice:secret@example.test/v1", + }, }, }, }, - }); + raw, + ); const result = redactConfigSnapshot(snapshot); const cfg = result.config as typeof snapshot.config; expect(cfg.models.providers.openai.baseUrl).toBe("https://example.test/v1"); + expect(result.raw).toContain("https://example.test/v1"); + expect(result.raw).not.toContain("alice:secret@"); }); it("does not redact maxTokens-style fields", () => { diff --git a/src/config/redact-snapshot.ts b/src/config/redact-snapshot.ts index 65f5e70da5c..054c4ccc924 100644 --- a/src/config/redact-snapshot.ts +++ b/src/config/redact-snapshot.ts @@ -1,5 +1,6 @@ import JSON5 from "json5"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { stripUrlUserInfo } from "../shared/net/url-userinfo.js"; import { replaceSensitiveValuesInRaw, shouldFallbackToStructuredRawRedaction, @@ -32,20 +33,6 @@ 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)) { @@ -231,7 +218,11 @@ 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); + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + } + result[key] = scrubbed; } break; } @@ -250,7 +241,11 @@ function redactObjectWithLookup( result[key] = REDACTED_SENTINEL; values.push(value); } else if (typeof value === "string" && isUserInfoUrlPath(path)) { - result[key] = stripUrlUserInfo(value); + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + } + result[key] = scrubbed; } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, path, values, hints); } @@ -316,7 +311,11 @@ function redactObjectGuessing( collectSensitiveStrings(value, values); result[key] = REDACTED_SENTINEL; } else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) { - result[key] = stripUrlUserInfo(value); + const scrubbed = stripUrlUserInfo(value); + if (scrubbed !== value) { + values.push(value); + } + result[key] = scrubbed; } else if (typeof value === "object" && value !== null) { result[key] = redactObjectGuessing(value, dotPath, values, hints); } else { diff --git a/src/shared/net/url-userinfo.ts b/src/shared/net/url-userinfo.ts new file mode 100644 index 00000000000..d9374a3d4c2 --- /dev/null +++ b/src/shared/net/url-userinfo.ts @@ -0,0 +1,13 @@ +export 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; + } +}