Skip to content

Commit 27ee5c0

Browse files
Ziy1-Tanhxy91819
andauthored
fix(gateway): redact secrets in skills.update response (#69998)
Merged via squash. Prepared head SHA: 61fc06f Co-authored-by: Ziy1-Tan <[email protected]> Co-authored-by: hxy91819 <[email protected]> Reviewed-by: @hxy91819
1 parent 16eae4b commit 27ee5c0

3 files changed

Lines changed: 123 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,7 @@ Docs: https://docs.openclaw.ai
190190
- Plugins/memory-core: respect configured memory-search embedding concurrency during non-batch indexing so local Ollama embedding backends can serialize indexing instead of flooding the server. Fixes #66822. (#66931) Thanks @oliviareid-svg and @LyraInTheFlesh.
191191
- Docker/update smoke: keep the package-derived update-channel fixture on package-shipped files and make its UI build stub create the asset the updater verifies. Thanks @vincentkoc.
192192
- Gateway/models: repair legacy `models.providers.*.api = "openai"` config values to `openai-completions`, and skip providers with future stale API enum values during startup instead of bricking the gateway. Fixes #72477. (#72542) Thanks @JooyoungChoi14 and @obviyus.
193+
- Gateway/skills: redact `apiKey` and secret-named `env` values from the `skills.update` RPC response to prevent leaking credentials into WebSocket traffic, client logs, or session transcripts. Config is still written to disk in full; only the response payload is redacted. (#69998) Thanks @Ziy1-Tan.
193194

194195
## 2026.4.26
195196

src/gateway/server-methods/skills.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
1414
import { loadWorkspaceSkillEntries, type SkillEntry } from "../../agents/skills.js";
1515
import { listAgentWorkspaceDirs } from "../../agents/workspace-dirs.js";
1616
import { loadConfig, writeConfigFile } from "../../config/config.js";
17+
import { redactConfigObject, REDACTED_SENTINEL } from "../../config/redact-snapshot.js";
1718
import type { OpenClawConfig } from "../../config/types.openclaw.js";
1819
import { fetchClawHubSkillDetail } from "../../infra/clawhub.js";
1920
import { formatErrorMessage } from "../../infra/errors.js";
@@ -312,7 +313,9 @@ export const skillsHandlers: GatewayRequestHandlers = {
312313
}
313314
if (typeof p.apiKey === "string") {
314315
const trimmed = normalizeSecretInput(p.apiKey);
315-
if (trimmed) {
316+
if (trimmed === REDACTED_SENTINEL) {
317+
// Keep the stored secret when a client round-trips a redacted response value.
318+
} else if (trimmed) {
316319
current.apiKey = trimmed;
317320
} else {
318321
delete current.apiKey;
@@ -326,6 +329,9 @@ export const skillsHandlers: GatewayRequestHandlers = {
326329
continue;
327330
}
328331
const trimmedVal = value.trim();
332+
if (trimmedVal === REDACTED_SENTINEL) {
333+
continue;
334+
}
329335
if (!trimmedVal) {
330336
delete nextEnv[trimmedKey];
331337
} else {
@@ -341,6 +347,10 @@ export const skillsHandlers: GatewayRequestHandlers = {
341347
skills,
342348
};
343349
await writeConfigFile(nextConfig);
344-
respond(true, { ok: true, skillKey: p.skillKey, config: current }, undefined);
350+
respond(
351+
true,
352+
{ ok: true, skillKey: p.skillKey, config: redactConfigObject(current) },
353+
undefined,
354+
);
345355
},
346356
};

src/gateway/server-methods/skills.update.normalizes-api-key.test.ts

Lines changed: 110 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import { describe, expect, it, vi } from "vitest";
2+
import { REDACTED_SENTINEL } from "../../config/redact-snapshot.js";
23

34
let writtenConfig: unknown = null;
5+
let loadedConfig: unknown = {
6+
skills: {
7+
entries: {},
8+
},
9+
};
410

511
vi.mock("../../config/config.js", () => {
612
return {
7-
loadConfig: () => ({
8-
skills: {
9-
entries: {},
10-
},
11-
}),
13+
loadConfig: () => loadedConfig,
1214
writeConfigFile: async (cfg: unknown) => {
1315
writtenConfig = cfg;
1416
},
@@ -20,6 +22,11 @@ const { skillsHandlers } = await import("./skills.js");
2022
describe("skills.update", () => {
2123
it("strips embedded CR/LF from apiKey", async () => {
2224
writtenConfig = null;
25+
loadedConfig = {
26+
skills: {
27+
entries: {},
28+
},
29+
};
2330

2431
let ok: boolean | null = null;
2532
let error: unknown = null;
@@ -50,4 +57,102 @@ describe("skills.update", () => {
5057
},
5158
});
5259
});
60+
61+
it("redacts apiKey and secret env values from the response but writes full values to config", async () => {
62+
writtenConfig = null;
63+
loadedConfig = {
64+
skills: {
65+
entries: {},
66+
},
67+
};
68+
69+
let responseResult: unknown = null;
70+
await skillsHandlers["skills.update"]({
71+
params: {
72+
skillKey: "demo-skill",
73+
apiKey: "secret-api-key-123",
74+
env: {
75+
GEMINI_API_KEY: "secret-env-key-456",
76+
BRAVE_REGION: "us",
77+
},
78+
},
79+
req: {} as never,
80+
client: null as never,
81+
isWebchatConnect: () => false,
82+
context: {} as never,
83+
respond: (_success, result, _err) => {
84+
responseResult = result;
85+
},
86+
});
87+
88+
// Full values must be persisted to config
89+
expect(writtenConfig).toMatchObject({
90+
skills: {
91+
entries: {
92+
"demo-skill": {
93+
apiKey: "secret-api-key-123",
94+
env: {
95+
GEMINI_API_KEY: "secret-env-key-456",
96+
BRAVE_REGION: "us",
97+
},
98+
},
99+
},
100+
},
101+
});
102+
103+
// Response must not expose plaintext secrets
104+
const config = (responseResult as { config: Record<string, unknown> }).config;
105+
expect(config.apiKey).toBe(REDACTED_SENTINEL);
106+
const env = config.env as Record<string, string>;
107+
expect(env.GEMINI_API_KEY).toBe(REDACTED_SENTINEL);
108+
// Non-secret env values should still be present
109+
expect(env.BRAVE_REGION).toBe("us");
110+
});
111+
112+
it("keeps existing secrets when clients submit redacted sentinel values", async () => {
113+
writtenConfig = null;
114+
loadedConfig = {
115+
skills: {
116+
entries: {
117+
"demo-skill": {
118+
apiKey: "secret-api-key-123",
119+
env: {
120+
GEMINI_API_KEY: "secret-env-key-456",
121+
BRAVE_REGION: "us",
122+
},
123+
},
124+
},
125+
},
126+
};
127+
128+
await skillsHandlers["skills.update"]({
129+
params: {
130+
skillKey: "demo-skill",
131+
apiKey: REDACTED_SENTINEL,
132+
env: {
133+
GEMINI_API_KEY: REDACTED_SENTINEL,
134+
BRAVE_REGION: "eu",
135+
},
136+
},
137+
req: {} as never,
138+
client: null as never,
139+
isWebchatConnect: () => false,
140+
context: {} as never,
141+
respond: () => {},
142+
});
143+
144+
expect(writtenConfig).toMatchObject({
145+
skills: {
146+
entries: {
147+
"demo-skill": {
148+
apiKey: "secret-api-key-123",
149+
env: {
150+
GEMINI_API_KEY: "secret-env-key-456",
151+
BRAVE_REGION: "eu",
152+
},
153+
},
154+
},
155+
},
156+
});
157+
});
53158
});

0 commit comments

Comments
 (0)