Gateway: scrub credentials from endpoint snapshots (#46799)

* Gateway: scrub credentials from endpoint snapshots

* Gateway: scrub raw endpoint credentials in snapshots

* Gateway: preserve config redaction round-trips

* Gateway: restore redacted endpoint URLs on apply
This commit is contained in:
Vincent Koc
2026-03-15 10:28:15 -07:00
committed by GitHub
parent d37e3d582f
commit f0202264d0
6 changed files with 115 additions and 5 deletions

View File

@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
- 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)
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- 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.
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.

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

@@ -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
@@ -203,7 +204,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,36 @@ describe("redactConfigSnapshot", () => {
expect(result.config).toEqual(snapshot.config);
});
it("removes embedded credentials from URL-valued endpoint fields", () => {
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(REDACTED_SENTINEL);
expect(result.raw).toContain(REDACTED_SENTINEL);
expect(result.raw).not.toContain("alice:secret@");
});
it("does not redact maxTokens-style fields", () => {
const snapshot = makeSnapshot({
maxTokens: 16384,
@@ -890,6 +920,25 @@ describe("redactConfigSnapshot", () => {
});
describe("restoreRedactedValues", () => {
it("restores redacted URL endpoint fields on round-trip", () => {
const incoming = {
models: {
providers: {
openai: { baseUrl: REDACTED_SENTINEL },
},
},
};
const original = {
models: {
providers: {
openai: { baseUrl: "https://alice:secret@example.test/v1" },
},
},
};
const result = restoreRedactedValues(incoming, original, mainSchemaHints);
expect(result.models.providers.openai.baseUrl).toBe("https://alice:secret@example.test/v1");
});
it("restores sentinel values from original config", () => {
const incoming = {
gateway: { auth: { token: REDACTED_SENTINEL } },

View File

@@ -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,
@@ -28,6 +29,10 @@ 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 collectSensitiveStrings(value: unknown, values: string[]): void {
if (typeof value === "string") {
if (!isEnvVarPlaceholder(value)) {
@@ -212,6 +217,14 @@ function redactObjectWithLookup(
) {
// Keep primitives at explicitly-sensitive paths fully redacted.
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "string" && isUserInfoUrlPath(path)) {
const scrubbed = stripUrlUserInfo(value);
if (scrubbed !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else {
result[key] = value;
}
}
break;
}
@@ -229,6 +242,14 @@ function redactObjectWithLookup(
) {
result[key] = REDACTED_SENTINEL;
values.push(value);
} else if (typeof value === "string" && isUserInfoUrlPath(path)) {
const scrubbed = stripUrlUserInfo(value);
if (scrubbed !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else {
result[key] = value;
}
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, path, values, hints);
}
@@ -293,6 +314,14 @@ function redactObjectGuessing(
) {
collectSensitiveStrings(value, values);
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) {
const scrubbed = stripUrlUserInfo(value);
if (scrubbed !== value) {
values.push(value);
result[key] = REDACTED_SENTINEL;
} else {
result[key] = value;
}
} else if (typeof value === "object" && value !== null) {
result[key] = redactObjectGuessing(value, dotPath, values, hints);
} else {
@@ -624,7 +653,10 @@ function restoreRedactedValuesWithLookup(
for (const candidate of [path, wildcardPath]) {
if (lookup.has(candidate)) {
matched = true;
if (value === REDACTED_SENTINEL) {
if (
value === REDACTED_SENTINEL &&
(hints[candidate]?.sensitive === true || isUserInfoUrlPath(path))
) {
result[key] = restoreOriginalValueOrThrow({ key, path: candidate, original: orig });
} else if (typeof value === "object" && value !== null) {
result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints);
@@ -634,7 +666,11 @@ function restoreRedactedValuesWithLookup(
}
if (!matched) {
const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]);
if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) {
if (
!markedNonSensitive &&
value === REDACTED_SENTINEL &&
(isSensitivePath(path) || isUserInfoUrlPath(path))
) {
result[key] = restoreOriginalValueOrThrow({ key, path, original: orig });
} else if (typeof value === "object" && value !== null) {
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
@@ -674,8 +710,8 @@ function restoreRedactedValuesGuessing(
const wildcardPath = prefix ? `${prefix}.*` : "*";
if (
!isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) &&
isSensitivePath(path) &&
value === REDACTED_SENTINEL
value === REDACTED_SENTINEL &&
(isSensitivePath(path) || isUserInfoUrlPath(path))
) {
result[key] = restoreOriginalValueOrThrow({ key, path, original: orig });
} else if (typeof value === "object" && value !== null) {

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