Gateway: scrub raw endpoint credentials in snapshots

This commit is contained in:
Vincent Koc 2026-03-14 23:20:15 -07:00
parent 0f19c4c3f7
commit aabb1398cc
4 changed files with 50 additions and 37 deletions

View File

@ -1,3 +1,4 @@
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
import type { ChannelAccountSnapshot } from "./plugins/types.core.js"; import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
// Read-only status commands project a safe subset of account fields into snapshots // Read-only status commands project a safe subset of account fields into snapshots
@ -30,20 +31,6 @@ function readTrimmedString(record: Record<string, unknown>, key: string): string
return trimmed.length > 0 ? trimmed : undefined; 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 { function readBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
return typeof record[key] === "boolean" ? record[key] : undefined; return typeof record[key] === "boolean" ? record[key] : undefined;
} }

View File

@ -164,19 +164,33 @@ describe("redactConfigSnapshot", () => {
}); });
it("removes embedded credentials from URL-valued endpoint fields", () => { it("removes embedded credentials from URL-valued endpoint fields", () => {
const snapshot = makeSnapshot({ const raw = `{
models: { models: {
providers: { providers: {
openai: { openai: {
baseUrl: "https://alice:secret@example.test/v1", 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 result = redactConfigSnapshot(snapshot);
const cfg = result.config as typeof snapshot.config; const cfg = result.config as typeof snapshot.config;
expect(cfg.models.providers.openai.baseUrl).toBe("https://example.test/v1"); 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", () => { it("does not redact maxTokens-style fields", () => {

View File

@ -1,5 +1,6 @@
import JSON5 from "json5"; import JSON5 from "json5";
import { createSubsystemLogger } from "../logging/subsystem.js"; import { createSubsystemLogger } from "../logging/subsystem.js";
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
import { import {
replaceSensitiveValuesInRaw, replaceSensitiveValuesInRaw,
shouldFallbackToStructuredRawRedaction, shouldFallbackToStructuredRawRedaction,
@ -32,20 +33,6 @@ function isUserInfoUrlPath(path: string): boolean {
return path.endsWith(".baseUrl") || path.endsWith(".httpUrl"); 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 { function collectSensitiveStrings(value: unknown, values: string[]): void {
if (typeof value === "string") { if (typeof value === "string") {
if (!isEnvVarPlaceholder(value)) { if (!isEnvVarPlaceholder(value)) {
@ -231,7 +218,11 @@ function redactObjectWithLookup(
// Keep primitives at explicitly-sensitive paths fully redacted. // Keep primitives at explicitly-sensitive paths fully redacted.
result[key] = REDACTED_SENTINEL; result[key] = REDACTED_SENTINEL;
} else if (typeof value === "string" && isUserInfoUrlPath(path)) { } 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; break;
} }
@ -250,7 +241,11 @@ function redactObjectWithLookup(
result[key] = REDACTED_SENTINEL; result[key] = REDACTED_SENTINEL;
values.push(value); values.push(value);
} else if (typeof value === "string" && isUserInfoUrlPath(path)) { } 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) { } else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, path, values, hints); result[key] = redactObjectGuessing(value, path, values, hints);
} }
@ -316,7 +311,11 @@ function redactObjectGuessing(
collectSensitiveStrings(value, values); collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL; result[key] = REDACTED_SENTINEL;
} else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) { } 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) { } else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, dotPath, values, hints); result[key] = redactObjectGuessing(value, dotPath, values, hints);
} else { } else {

View File

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