mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 13:21:25 +00:00
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 <gpickett00@gmail.com>
This commit is contained in:
@@ -12,6 +12,11 @@ import {
|
||||
} from "../../config/config.js";
|
||||
import { applyLegacyMigrations } from "../../config/legacy.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import {
|
||||
redactConfigObject,
|
||||
redactConfigSnapshot,
|
||||
restoreRedactedValues,
|
||||
} from "../../config/redact-snapshot.js";
|
||||
import { buildConfigSchema } from "../../config/schema.js";
|
||||
import {
|
||||
formatDoctorNonInteractiveHint,
|
||||
@@ -100,7 +105,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
respond(true, snapshot, undefined);
|
||||
respond(true, redactConfigSnapshot(snapshot), undefined);
|
||||
},
|
||||
"config.schema": ({ params, respond }) => {
|
||||
if (!validateConfigSchemaParams(params)) {
|
||||
@@ -185,13 +190,27 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
let restored: typeof validated.config;
|
||||
try {
|
||||
restored = restoreRedactedValues(
|
||||
validated.config,
|
||||
snapshot.config,
|
||||
) as typeof validated.config;
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(restored);
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(restored),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -250,8 +269,19 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
let restoredMerge: unknown;
|
||||
try {
|
||||
restoredMerge = restoreRedactedValues(merged, snapshot.config);
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const migrated = applyLegacyMigrations(restoredMerge);
|
||||
const resolved = migrated.next ?? restoredMerge;
|
||||
const validated = validateConfigObjectWithPlugins(resolved);
|
||||
if (!validated.ok) {
|
||||
respond(
|
||||
@@ -306,7 +336,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(validated.config),
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
@@ -360,7 +390,21 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
let restoredApply: typeof validated.config;
|
||||
try {
|
||||
restoredApply = restoreRedactedValues(
|
||||
validated.config,
|
||||
snapshot.config,
|
||||
) as typeof validated.config;
|
||||
} catch (err) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.INVALID_REQUEST, String(err instanceof Error ? err.message : err)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await writeConfigFile(restoredApply);
|
||||
|
||||
const sessionKey =
|
||||
typeof (params as { sessionKey?: unknown }).sessionKey === "string"
|
||||
@@ -403,7 +447,7 @@ export const configHandlers: GatewayRequestHandlers = {
|
||||
{
|
||||
ok: true,
|
||||
path: CONFIG_PATH,
|
||||
config: validated.config,
|
||||
config: redactConfigObject(restoredApply),
|
||||
restart,
|
||||
sentinel: {
|
||||
path: sentinelPath,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { resolveConfigSnapshotHash } from "../config/config.js";
|
||||
import { CONFIG_PATH, resolveConfigSnapshotHash } from "../config/config.js";
|
||||
import {
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
@@ -115,7 +115,82 @@ describe("gateway config.patch", () => {
|
||||
}>(ws, (o) => o.type === "res" && o.id === get2Id);
|
||||
expect(get2Res.ok).toBe(true);
|
||||
expect(get2Res.payload?.config?.gateway?.mode).toBe("local");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("token-1");
|
||||
expect(get2Res.payload?.config?.channels?.telegram?.botToken).toBe("__OPENCLAW_REDACTED__");
|
||||
|
||||
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
|
||||
const stored = JSON.parse(storedRaw) as {
|
||||
channels?: { telegram?: { botToken?: string } };
|
||||
};
|
||||
expect(stored.channels?.telegram?.botToken).toBe("token-1");
|
||||
});
|
||||
|
||||
it("preserves credentials on config.set when raw contains redacted sentinels", async () => {
|
||||
const setId = "req-set-sentinel-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: setId,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: JSON.stringify({
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { botToken: "token-1" } },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const setRes = await onceMessage<{ ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === setId,
|
||||
);
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const getId = "req-get-sentinel-1";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: getId,
|
||||
method: "config.get",
|
||||
params: {},
|
||||
}),
|
||||
);
|
||||
const getRes = await onceMessage<{ ok: boolean; payload?: { hash?: string; raw?: string } }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === getId,
|
||||
);
|
||||
expect(getRes.ok).toBe(true);
|
||||
const baseHash = resolveConfigSnapshotHash({
|
||||
hash: getRes.payload?.hash,
|
||||
raw: getRes.payload?.raw,
|
||||
});
|
||||
expect(typeof baseHash).toBe("string");
|
||||
const rawRedacted = getRes.payload?.raw;
|
||||
expect(typeof rawRedacted).toBe("string");
|
||||
expect(rawRedacted).toContain("__OPENCLAW_REDACTED__");
|
||||
|
||||
const set2Id = "req-set-sentinel-2";
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "req",
|
||||
id: set2Id,
|
||||
method: "config.set",
|
||||
params: {
|
||||
raw: rawRedacted,
|
||||
baseHash,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const set2Res = await onceMessage<{ ok: boolean }>(
|
||||
ws,
|
||||
(o) => o.type === "res" && o.id === set2Id,
|
||||
);
|
||||
expect(set2Res.ok).toBe(true);
|
||||
|
||||
const storedRaw = await fs.readFile(CONFIG_PATH, "utf-8");
|
||||
const stored = JSON.parse(storedRaw) as {
|
||||
channels?: { telegram?: { botToken?: string } };
|
||||
};
|
||||
expect(stored.channels?.telegram?.botToken).toBe("token-1");
|
||||
});
|
||||
|
||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||
|
||||
@@ -590,6 +590,15 @@ vi.mock("../cli/deps.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/loader.js", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../plugins/loader.js")>("../plugins/loader.js");
|
||||
return {
|
||||
...actual,
|
||||
loadOpenClawPlugins: () => pluginRegistryState.registry,
|
||||
};
|
||||
});
|
||||
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
process.env.OPENCLAW_SKIP_CRON = "1";
|
||||
process.env.OPENCLAW_SKIP_CHANNELS = "1";
|
||||
|
||||
@@ -285,7 +285,9 @@ export function onceMessage<T = unknown>(
|
||||
|
||||
export async function startGatewayServer(port: number, opts?: GatewayServerOptions) {
|
||||
const mod = await serverModulePromise;
|
||||
return await mod.startGatewayServer(port, opts);
|
||||
const resolvedOpts =
|
||||
opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts;
|
||||
return await mod.startGatewayServer(port, resolvedOpts);
|
||||
}
|
||||
|
||||
export async function startServerWithClient(token?: string, opts?: GatewayServerOptions) {
|
||||
@@ -323,7 +325,30 @@ export async function startServerWithClient(token?: string, opts?: GatewayServer
|
||||
}
|
||||
|
||||
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("timeout waiting for ws open")), 10_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
ws.off("open", onOpen);
|
||||
ws.off("error", onError);
|
||||
ws.off("close", onClose);
|
||||
};
|
||||
const onOpen = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onError = (err: unknown) => {
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
};
|
||||
const onClose = (code: number, reason: Buffer) => {
|
||||
cleanup();
|
||||
reject(new Error(`closed ${code}: ${reason.toString()}`));
|
||||
};
|
||||
ws.once("open", onOpen);
|
||||
ws.once("error", onError);
|
||||
ws.once("close", onClose);
|
||||
});
|
||||
return { server, ws, port, prevToken: prev };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user