Skip to content

Commit 0c7fa2b

Browse files
abdelsfanegrp06
andauthored
security: redact credentials from config.get gateway responses (#9858)
* security: add skill/plugin code safety scanner module * security: integrate skill scanner into security audit * security: add pre-install code safety scan for plugins * style: fix curly brace lint errors in skill-scanner.ts * docs: add changelog entry for skill code safety scanner * security: redact credentials from config.get gateway responses The config.get gateway method returned the full config snapshot including channel credentials (Discord tokens, Slack botToken/appToken, Telegram botToken, Feishu appSecret, etc.), model provider API keys, and gateway auth tokens in plaintext. Any WebSocket client—including the unauthenticated Control UI when dangerouslyDisableDeviceAuth is set—could read every secret. This adds redactConfigSnapshot() which: - Deep-walks the config object and masks any field whose key matches token, password, secret, or apiKey patterns - Uses the existing redactSensitiveText() to scrub the raw JSON5 source - Preserves the hash for change detection - Includes 15 test cases covering all channel types * security: make gateway config writes return redacted values * test: disable control UI by default in gateway server tests * fix: redact credentials in gateway config APIs (#9858) (thanks @abdelsfane) --------- Co-authored-by: George Pickett <[email protected]>
1 parent 5f6e1c1 commit 0c7fa2b

File tree

7 files changed

+669
-12
lines changed

7 files changed

+669
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai
5050
- Web UI: apply button styling to the new-messages indicator.
5151
- Onboarding: infer auth choice from non-interactive API key flags. (#8484) Thanks @f-trycua.
5252
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
53+
- Security: redact channel credentials (tokens, passwords, API keys, secrets) from gateway config APIs and preserve secrets during Control UI round-trips. (#9858) Thanks @abdelsfane.
5354
- Discord: treat allowlisted senders as owner for system-prompt identity hints while keeping channel topics untrusted.
5455
- Slack: strip `<@...>` mention tokens before command matching so `/new` and `/reset` work when prefixed with a mention. (#9971) Thanks @ironbyte-rgb.
5556
- Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.

src/config/redact-snapshot.test.ts

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { ConfigFileSnapshot } from "./types.openclaw.js";
3+
import {
4+
REDACTED_SENTINEL,
5+
redactConfigSnapshot,
6+
restoreRedactedValues,
7+
} from "./redact-snapshot.js";
8+
9+
function makeSnapshot(config: Record<string, unknown>, raw?: string): ConfigFileSnapshot {
10+
return {
11+
path: "/home/user/.openclaw/config.json5",
12+
exists: true,
13+
raw: raw ?? JSON.stringify(config),
14+
parsed: config,
15+
valid: true,
16+
config: config as ConfigFileSnapshot["config"],
17+
hash: "abc123",
18+
issues: [],
19+
warnings: [],
20+
legacyIssues: [],
21+
};
22+
}
23+
24+
describe("redactConfigSnapshot", () => {
25+
it("redacts top-level token fields", () => {
26+
const snapshot = makeSnapshot({
27+
gateway: { auth: { token: "my-super-secret-gateway-token-value" } },
28+
});
29+
const result = redactConfigSnapshot(snapshot);
30+
expect(result.config).toEqual({
31+
gateway: { auth: { token: REDACTED_SENTINEL } },
32+
});
33+
});
34+
35+
it("redacts botToken in channel configs", () => {
36+
const snapshot = makeSnapshot({
37+
channels: {
38+
telegram: { botToken: "123456:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef" },
39+
slack: { botToken: "fake-slack-bot-token-placeholder-value" },
40+
},
41+
});
42+
const result = redactConfigSnapshot(snapshot);
43+
const channels = result.config.channels as Record<string, Record<string, string>>;
44+
expect(channels.telegram.botToken).toBe(REDACTED_SENTINEL);
45+
expect(channels.slack.botToken).toBe(REDACTED_SENTINEL);
46+
});
47+
48+
it("redacts apiKey in model providers", () => {
49+
const snapshot = makeSnapshot({
50+
models: {
51+
providers: {
52+
openai: { apiKey: "sk-proj-abcdef1234567890ghij", baseUrl: "https://api.openai.com" },
53+
},
54+
},
55+
});
56+
const result = redactConfigSnapshot(snapshot);
57+
const models = result.config.models as Record<string, Record<string, Record<string, string>>>;
58+
expect(models.providers.openai.apiKey).toBe(REDACTED_SENTINEL);
59+
expect(models.providers.openai.baseUrl).toBe("https://api.openai.com");
60+
});
61+
62+
it("redacts password fields", () => {
63+
const snapshot = makeSnapshot({
64+
gateway: { auth: { password: "super-secret-password-value-here" } },
65+
});
66+
const result = redactConfigSnapshot(snapshot);
67+
const gw = result.config.gateway as Record<string, Record<string, string>>;
68+
expect(gw.auth.password).toBe(REDACTED_SENTINEL);
69+
});
70+
71+
it("redacts appSecret fields", () => {
72+
const snapshot = makeSnapshot({
73+
channels: {
74+
feishu: { appSecret: "feishu-app-secret-value-here-1234" },
75+
},
76+
});
77+
const result = redactConfigSnapshot(snapshot);
78+
const channels = result.config.channels as Record<string, Record<string, string>>;
79+
expect(channels.feishu.appSecret).toBe(REDACTED_SENTINEL);
80+
});
81+
82+
it("redacts signingSecret fields", () => {
83+
const snapshot = makeSnapshot({
84+
channels: {
85+
slack: { signingSecret: "slack-signing-secret-value-1234" },
86+
},
87+
});
88+
const result = redactConfigSnapshot(snapshot);
89+
const channels = result.config.channels as Record<string, Record<string, string>>;
90+
expect(channels.slack.signingSecret).toBe(REDACTED_SENTINEL);
91+
});
92+
93+
it("redacts short secrets with same sentinel", () => {
94+
const snapshot = makeSnapshot({
95+
gateway: { auth: { token: "short" } },
96+
});
97+
const result = redactConfigSnapshot(snapshot);
98+
const gw = result.config.gateway as Record<string, Record<string, string>>;
99+
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
100+
});
101+
102+
it("preserves non-sensitive fields", () => {
103+
const snapshot = makeSnapshot({
104+
ui: { seamColor: "#0088cc" },
105+
gateway: { port: 18789 },
106+
models: { providers: { openai: { baseUrl: "https://api.openai.com" } } },
107+
});
108+
const result = redactConfigSnapshot(snapshot);
109+
expect(result.config).toEqual(snapshot.config);
110+
});
111+
112+
it("preserves hash unchanged", () => {
113+
const snapshot = makeSnapshot({ gateway: { auth: { token: "secret-token-value-here" } } });
114+
const result = redactConfigSnapshot(snapshot);
115+
expect(result.hash).toBe("abc123");
116+
});
117+
118+
it("redacts secrets in raw field via text-based redaction", () => {
119+
const config = { token: "abcdef1234567890ghij" };
120+
const raw = '{ "token": "abcdef1234567890ghij" }';
121+
const snapshot = makeSnapshot(config, raw);
122+
const result = redactConfigSnapshot(snapshot);
123+
expect(result.raw).not.toContain("abcdef1234567890ghij");
124+
expect(result.raw).toContain(REDACTED_SENTINEL);
125+
});
126+
127+
it("redacts parsed object as well", () => {
128+
const config = {
129+
channels: { discord: { token: "MTIzNDU2Nzg5MDEyMzQ1Njc4.GaBcDe.FgH" } },
130+
};
131+
const snapshot = makeSnapshot(config);
132+
const result = redactConfigSnapshot(snapshot);
133+
const parsed = result.parsed as Record<string, Record<string, Record<string, string>>>;
134+
expect(parsed.channels.discord.token).toBe(REDACTED_SENTINEL);
135+
});
136+
137+
it("handles null raw gracefully", () => {
138+
const snapshot: ConfigFileSnapshot = {
139+
path: "/test",
140+
exists: false,
141+
raw: null,
142+
parsed: null,
143+
valid: false,
144+
config: {} as ConfigFileSnapshot["config"],
145+
issues: [],
146+
warnings: [],
147+
legacyIssues: [],
148+
};
149+
const result = redactConfigSnapshot(snapshot);
150+
expect(result.raw).toBeNull();
151+
expect(result.parsed).toBeNull();
152+
});
153+
154+
it("handles deeply nested tokens in accounts", () => {
155+
const snapshot = makeSnapshot({
156+
channels: {
157+
slack: {
158+
accounts: {
159+
workspace1: { botToken: "fake-workspace1-token-abcdefghij" },
160+
workspace2: { appToken: "fake-workspace2-token-abcdefghij" },
161+
},
162+
},
163+
},
164+
});
165+
const result = redactConfigSnapshot(snapshot);
166+
const channels = result.config.channels as Record<
167+
string,
168+
Record<string, Record<string, Record<string, string>>>
169+
>;
170+
expect(channels.slack.accounts.workspace1.botToken).toBe(REDACTED_SENTINEL);
171+
expect(channels.slack.accounts.workspace2.appToken).toBe(REDACTED_SENTINEL);
172+
});
173+
174+
it("handles webhookSecret field", () => {
175+
const snapshot = makeSnapshot({
176+
channels: {
177+
telegram: { webhookSecret: "telegram-webhook-secret-value-1234" },
178+
},
179+
});
180+
const result = redactConfigSnapshot(snapshot);
181+
const channels = result.config.channels as Record<string, Record<string, string>>;
182+
expect(channels.telegram.webhookSecret).toBe(REDACTED_SENTINEL);
183+
});
184+
185+
it("redacts env vars that look like secrets", () => {
186+
const snapshot = makeSnapshot({
187+
env: {
188+
vars: {
189+
OPENAI_API_KEY: "sk-proj-1234567890abcdefghij",
190+
NODE_ENV: "production",
191+
},
192+
},
193+
});
194+
const result = redactConfigSnapshot(snapshot);
195+
const env = result.config.env as Record<string, Record<string, string>>;
196+
expect(env.vars.OPENAI_API_KEY).toBe(REDACTED_SENTINEL);
197+
// NODE_ENV is not sensitive, should be preserved
198+
expect(env.vars.NODE_ENV).toBe("production");
199+
});
200+
201+
it("redacts raw by key pattern even when parsed config is empty", () => {
202+
const snapshot: ConfigFileSnapshot = {
203+
path: "/test",
204+
exists: true,
205+
raw: '{ token: "raw-secret-1234567890" }',
206+
parsed: {},
207+
valid: false,
208+
config: {} as ConfigFileSnapshot["config"],
209+
issues: [],
210+
warnings: [],
211+
legacyIssues: [],
212+
};
213+
const result = redactConfigSnapshot(snapshot);
214+
expect(result.raw).not.toContain("raw-secret-1234567890");
215+
expect(result.raw).toContain(REDACTED_SENTINEL);
216+
});
217+
218+
it("redacts sensitive fields even when the value is not a string", () => {
219+
const snapshot = makeSnapshot({
220+
gateway: { auth: { token: 1234 } },
221+
});
222+
const result = redactConfigSnapshot(snapshot);
223+
const gw = result.config.gateway as Record<string, Record<string, string>>;
224+
expect(gw.auth.token).toBe(REDACTED_SENTINEL);
225+
});
226+
});
227+
228+
describe("restoreRedactedValues", () => {
229+
it("restores sentinel values from original config", () => {
230+
const incoming = {
231+
gateway: { auth: { token: REDACTED_SENTINEL } },
232+
};
233+
const original = {
234+
gateway: { auth: { token: "real-secret-token-value" } },
235+
};
236+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
237+
expect(result.gateway.auth.token).toBe("real-secret-token-value");
238+
});
239+
240+
it("preserves explicitly changed sensitive values", () => {
241+
const incoming = {
242+
gateway: { auth: { token: "new-token-value-from-user" } },
243+
};
244+
const original = {
245+
gateway: { auth: { token: "old-token-value" } },
246+
};
247+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
248+
expect(result.gateway.auth.token).toBe("new-token-value-from-user");
249+
});
250+
251+
it("preserves non-sensitive fields unchanged", () => {
252+
const incoming = {
253+
ui: { seamColor: "#ff0000" },
254+
gateway: { port: 9999, auth: { token: REDACTED_SENTINEL } },
255+
};
256+
const original = {
257+
ui: { seamColor: "#0088cc" },
258+
gateway: { port: 18789, auth: { token: "real-secret" } },
259+
};
260+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
261+
expect(result.ui.seamColor).toBe("#ff0000");
262+
expect(result.gateway.port).toBe(9999);
263+
expect(result.gateway.auth.token).toBe("real-secret");
264+
});
265+
266+
it("handles deeply nested sentinel restoration", () => {
267+
const incoming = {
268+
channels: {
269+
slack: {
270+
accounts: {
271+
ws1: { botToken: REDACTED_SENTINEL },
272+
ws2: { botToken: "user-typed-new-token-value" },
273+
},
274+
},
275+
},
276+
};
277+
const original = {
278+
channels: {
279+
slack: {
280+
accounts: {
281+
ws1: { botToken: "original-ws1-token-value" },
282+
ws2: { botToken: "original-ws2-token-value" },
283+
},
284+
},
285+
},
286+
};
287+
const result = restoreRedactedValues(incoming, original) as typeof incoming;
288+
expect(result.channels.slack.accounts.ws1.botToken).toBe("original-ws1-token-value");
289+
expect(result.channels.slack.accounts.ws2.botToken).toBe("user-typed-new-token-value");
290+
});
291+
292+
it("handles missing original gracefully", () => {
293+
const incoming = {
294+
channels: { newChannel: { token: REDACTED_SENTINEL } },
295+
};
296+
const original = {};
297+
expect(() => restoreRedactedValues(incoming, original)).toThrow(/redacted/i);
298+
});
299+
300+
it("handles null and undefined inputs", () => {
301+
expect(restoreRedactedValues(null, { token: "x" })).toBeNull();
302+
expect(restoreRedactedValues(undefined, { token: "x" })).toBeUndefined();
303+
});
304+
305+
it("round-trips config through redact → restore", () => {
306+
const originalConfig = {
307+
gateway: { auth: { token: "gateway-auth-secret-token-value" }, port: 18789 },
308+
channels: {
309+
slack: { botToken: "fake-slack-token-placeholder-value" },
310+
telegram: {
311+
botToken: "fake-telegram-token-placeholder-value",
312+
webhookSecret: "fake-tg-secret-placeholder-value",
313+
},
314+
},
315+
models: {
316+
providers: {
317+
openai: {
318+
apiKey: "sk-proj-fake-openai-api-key-value",
319+
baseUrl: "https://api.openai.com",
320+
},
321+
},
322+
},
323+
ui: { seamColor: "#0088cc" },
324+
};
325+
const snapshot = makeSnapshot(originalConfig);
326+
327+
// Redact (simulates config.get response)
328+
const redacted = redactConfigSnapshot(snapshot);
329+
330+
// Restore (simulates config.set before write)
331+
const restored = restoreRedactedValues(redacted.config, snapshot.config);
332+
333+
expect(restored).toEqual(originalConfig);
334+
});
335+
});

0 commit comments

Comments
 (0)