Skip to content

Commit f020226

Browse files
authored
Gateway: scrub credentials from endpoint snapshots (openclaw#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
1 parent d37e3d5 commit f020226

File tree

6 files changed

+115
-5
lines changed

6 files changed

+115
-5
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- 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)
3131
- 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.
3232
- 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`.
33+
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
3334
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
3435
- 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.
3536
- 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.

src/channels/account-snapshot-fields.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,14 @@ describe("projectSafeChannelAccountSnapshotFields", () => {
2424
signingSecretStatus: "configured_unavailable", // pragma: allowlist secret
2525
});
2626
});
27+
28+
it("strips embedded credentials from baseUrl fields", () => {
29+
const snapshot = projectSafeChannelAccountSnapshotFields({
30+
baseUrl: "https://bob:[email protected]",
31+
});
32+
33+
expect(snapshot).toEqual({
34+
baseUrl: "https://chat.example.test/",
35+
});
36+
});
2737
});

src/channels/account-snapshot-fields.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
12
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
23

34
// Read-only status commands project a safe subset of account fields into snapshots
@@ -203,7 +204,7 @@ export function projectSafeChannelAccountSnapshotFields(
203204
: {}),
204205
...projectCredentialSnapshotFields(account),
205206
...(readTrimmedString(record, "baseUrl")
206-
? { baseUrl: readTrimmedString(record, "baseUrl") }
207+
? { baseUrl: stripUrlUserInfo(readTrimmedString(record, "baseUrl")!) }
207208
: {}),
208209
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
209210
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }

src/config/redact-snapshot.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,36 @@ describe("redactConfigSnapshot", () => {
163163
expect(result.config).toEqual(snapshot.config);
164164
});
165165

166+
it("removes embedded credentials from URL-valued endpoint fields", () => {
167+
const raw = `{
168+
models: {
169+
providers: {
170+
openai: {
171+
baseUrl: "https://alice:[email protected]/v1",
172+
},
173+
},
174+
},
175+
}`;
176+
const snapshot = makeSnapshot(
177+
{
178+
models: {
179+
providers: {
180+
openai: {
181+
baseUrl: "https://alice:[email protected]/v1",
182+
},
183+
},
184+
},
185+
},
186+
raw,
187+
);
188+
189+
const result = redactConfigSnapshot(snapshot);
190+
const cfg = result.config as typeof snapshot.config;
191+
expect(cfg.models.providers.openai.baseUrl).toBe(REDACTED_SENTINEL);
192+
expect(result.raw).toContain(REDACTED_SENTINEL);
193+
expect(result.raw).not.toContain("alice:secret@");
194+
});
195+
166196
it("does not redact maxTokens-style fields", () => {
167197
const snapshot = makeSnapshot({
168198
maxTokens: 16384,
@@ -890,6 +920,25 @@ describe("redactConfigSnapshot", () => {
890920
});
891921

892922
describe("restoreRedactedValues", () => {
923+
it("restores redacted URL endpoint fields on round-trip", () => {
924+
const incoming = {
925+
models: {
926+
providers: {
927+
openai: { baseUrl: REDACTED_SENTINEL },
928+
},
929+
},
930+
};
931+
const original = {
932+
models: {
933+
providers: {
934+
openai: { baseUrl: "https://alice:[email protected]/v1" },
935+
},
936+
},
937+
};
938+
const result = restoreRedactedValues(incoming, original, mainSchemaHints);
939+
expect(result.models.providers.openai.baseUrl).toBe("https://alice:[email protected]/v1");
940+
});
941+
893942
it("restores sentinel values from original config", () => {
894943
const incoming = {
895944
gateway: { auth: { token: REDACTED_SENTINEL } },

src/config/redact-snapshot.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import JSON5 from "json5";
22
import { createSubsystemLogger } from "../logging/subsystem.js";
3+
import { stripUrlUserInfo } from "../shared/net/url-userinfo.js";
34
import {
45
replaceSensitiveValuesInRaw,
56
shouldFallbackToStructuredRawRedaction,
@@ -28,6 +29,10 @@ function isWholeObjectSensitivePath(path: string): boolean {
2829
return lowered.endsWith("serviceaccount") || lowered.endsWith("serviceaccountref");
2930
}
3031

32+
function isUserInfoUrlPath(path: string): boolean {
33+
return path.endsWith(".baseUrl") || path.endsWith(".httpUrl");
34+
}
35+
3136
function collectSensitiveStrings(value: unknown, values: string[]): void {
3237
if (typeof value === "string") {
3338
if (!isEnvVarPlaceholder(value)) {
@@ -212,6 +217,14 @@ function redactObjectWithLookup(
212217
) {
213218
// Keep primitives at explicitly-sensitive paths fully redacted.
214219
result[key] = REDACTED_SENTINEL;
220+
} else if (typeof value === "string" && isUserInfoUrlPath(path)) {
221+
const scrubbed = stripUrlUserInfo(value);
222+
if (scrubbed !== value) {
223+
values.push(value);
224+
result[key] = REDACTED_SENTINEL;
225+
} else {
226+
result[key] = value;
227+
}
215228
}
216229
break;
217230
}
@@ -229,6 +242,14 @@ function redactObjectWithLookup(
229242
) {
230243
result[key] = REDACTED_SENTINEL;
231244
values.push(value);
245+
} else if (typeof value === "string" && isUserInfoUrlPath(path)) {
246+
const scrubbed = stripUrlUserInfo(value);
247+
if (scrubbed !== value) {
248+
values.push(value);
249+
result[key] = REDACTED_SENTINEL;
250+
} else {
251+
result[key] = value;
252+
}
232253
} else if (typeof value === "object" && value !== null) {
233254
result[key] = redactObjectGuessing(value, path, values, hints);
234255
}
@@ -293,6 +314,14 @@ function redactObjectGuessing(
293314
) {
294315
collectSensitiveStrings(value, values);
295316
result[key] = REDACTED_SENTINEL;
317+
} else if (typeof value === "string" && isUserInfoUrlPath(dotPath)) {
318+
const scrubbed = stripUrlUserInfo(value);
319+
if (scrubbed !== value) {
320+
values.push(value);
321+
result[key] = REDACTED_SENTINEL;
322+
} else {
323+
result[key] = value;
324+
}
296325
} else if (typeof value === "object" && value !== null) {
297326
result[key] = redactObjectGuessing(value, dotPath, values, hints);
298327
} else {
@@ -624,7 +653,10 @@ function restoreRedactedValuesWithLookup(
624653
for (const candidate of [path, wildcardPath]) {
625654
if (lookup.has(candidate)) {
626655
matched = true;
627-
if (value === REDACTED_SENTINEL) {
656+
if (
657+
value === REDACTED_SENTINEL &&
658+
(hints[candidate]?.sensitive === true || isUserInfoUrlPath(path))
659+
) {
628660
result[key] = restoreOriginalValueOrThrow({ key, path: candidate, original: orig });
629661
} else if (typeof value === "object" && value !== null) {
630662
result[key] = restoreRedactedValuesWithLookup(value, orig[key], lookup, candidate, hints);
@@ -634,7 +666,11 @@ function restoreRedactedValuesWithLookup(
634666
}
635667
if (!matched) {
636668
const markedNonSensitive = isExplicitlyNonSensitivePath(hints, [path, wildcardPath]);
637-
if (!markedNonSensitive && isSensitivePath(path) && value === REDACTED_SENTINEL) {
669+
if (
670+
!markedNonSensitive &&
671+
value === REDACTED_SENTINEL &&
672+
(isSensitivePath(path) || isUserInfoUrlPath(path))
673+
) {
638674
result[key] = restoreOriginalValueOrThrow({ key, path, original: orig });
639675
} else if (typeof value === "object" && value !== null) {
640676
result[key] = restoreRedactedValuesGuessing(value, orig[key], path, hints);
@@ -674,8 +710,8 @@ function restoreRedactedValuesGuessing(
674710
const wildcardPath = prefix ? `${prefix}.*` : "*";
675711
if (
676712
!isExplicitlyNonSensitivePath(hints, [path, wildcardPath]) &&
677-
isSensitivePath(path) &&
678-
value === REDACTED_SENTINEL
713+
value === REDACTED_SENTINEL &&
714+
(isSensitivePath(path) || isUserInfoUrlPath(path))
679715
) {
680716
result[key] = restoreOriginalValueOrThrow({ key, path, original: orig });
681717
} else if (typeof value === "object" && value !== null) {

src/shared/net/url-userinfo.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function stripUrlUserInfo(value: string): string {
2+
try {
3+
const parsed = new URL(value);
4+
if (!parsed.username && !parsed.password) {
5+
return value;
6+
}
7+
parsed.username = "";
8+
parsed.password = "";
9+
return parsed.toString();
10+
} catch {
11+
return value;
12+
}
13+
}

0 commit comments

Comments
 (0)