mirror of https://github.com/openclaw/openclaw.git
Gateway: scrub raw endpoint credentials in snapshots
This commit is contained in:
parent
0f19c4c3f7
commit
aabb1398cc
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue