Files
openclaw/src/config/redact-snapshot.ts
Abdel Sy Fane 0c7fa2b0d5 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>
2026-02-05 16:34:48 -08:00

169 lines
5.5 KiB
TypeScript

import type { ConfigFileSnapshot } from "./types.openclaw.js";
/**
* Sentinel value used to replace sensitive config fields in gateway responses.
* Write-side handlers (config.set, config.apply, config.patch) detect this
* sentinel and restore the original value from the on-disk config, so a
* round-trip through the Web UI does not corrupt credentials.
*/
export const REDACTED_SENTINEL = "__OPENCLAW_REDACTED__";
/**
* Patterns that identify sensitive config field names.
* Aligned with the UI-hint logic in schema.ts.
*/
const SENSITIVE_KEY_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
function isSensitiveKey(key: string): boolean {
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
}
/**
* Deep-walk an object and replace values whose key matches a sensitive pattern
* with the redaction sentinel.
*/
function redactObject(obj: unknown): unknown {
if (obj === null || obj === undefined) {
return obj;
}
if (typeof obj !== "object") {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(redactObject);
}
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (isSensitiveKey(key) && value !== null && value !== undefined) {
result[key] = REDACTED_SENTINEL;
} else if (typeof value === "object" && value !== null) {
result[key] = redactObject(value);
} else {
result[key] = value;
}
}
return result;
}
export function redactConfigObject<T>(value: T): T {
return redactObject(value) as T;
}
/**
* Collect all sensitive string values from a config object.
* Used for text-based redaction of the raw JSON5 source.
*/
function collectSensitiveValues(obj: unknown): string[] {
const values: string[] = [];
if (obj === null || obj === undefined || typeof obj !== "object") {
return values;
}
if (Array.isArray(obj)) {
for (const item of obj) {
values.push(...collectSensitiveValues(item));
}
return values;
}
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
if (isSensitiveKey(key) && typeof value === "string" && value.length > 0) {
values.push(value);
} else if (typeof value === "object" && value !== null) {
values.push(...collectSensitiveValues(value));
}
}
return values;
}
/**
* Replace known sensitive values in a raw JSON5 string with the sentinel.
* Values are replaced longest-first to avoid partial matches.
*/
function redactRawText(raw: string, config: unknown): string {
const sensitiveValues = collectSensitiveValues(config);
sensitiveValues.sort((a, b) => b.length - a.length);
let result = raw;
for (const value of sensitiveValues) {
const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
result = result.replace(new RegExp(escaped, "g"), REDACTED_SENTINEL);
}
const keyValuePattern =
/(^|[{\s,])((["'])([^"']+)\3|([A-Za-z0-9_$.-]+))(\s*:\s*)(["'])([^"']*)\7/g;
result = result.replace(
keyValuePattern,
(match, prefix, keyExpr, _keyQuote, keyQuoted, keyBare, sep, valQuote, val) => {
const key = (keyQuoted ?? keyBare) as string | undefined;
if (!key || !isSensitiveKey(key)) {
return match;
}
if (val === REDACTED_SENTINEL) {
return match;
}
return `${prefix}${keyExpr}${sep}${valQuote}${REDACTED_SENTINEL}${valQuote}`;
},
);
return result;
}
/**
* Returns a copy of the config snapshot with all sensitive fields
* replaced by {@link REDACTED_SENTINEL}. The `hash` is preserved
* (it tracks config identity, not content).
*
* Both `config` (the parsed object) and `raw` (the JSON5 source) are scrubbed
* so no credential can leak through either path.
*/
export function redactConfigSnapshot(snapshot: ConfigFileSnapshot): ConfigFileSnapshot {
const redactedConfig = redactConfigObject(snapshot.config);
const redactedRaw = snapshot.raw ? redactRawText(snapshot.raw, snapshot.config) : null;
const redactedParsed = snapshot.parsed ? redactConfigObject(snapshot.parsed) : snapshot.parsed;
return {
...snapshot,
config: redactedConfig,
raw: redactedRaw,
parsed: redactedParsed,
};
}
/**
* Deep-walk `incoming` and replace any {@link REDACTED_SENTINEL} values
* (on sensitive keys) with the corresponding value from `original`.
*
* This is called by config.set / config.apply / config.patch before writing,
* so that credentials survive a Web UI round-trip unmodified.
*/
export function restoreRedactedValues(incoming: unknown, original: unknown): unknown {
if (incoming === null || incoming === undefined) {
return incoming;
}
if (typeof incoming !== "object") {
return incoming;
}
if (Array.isArray(incoming)) {
const origArr = Array.isArray(original) ? original : [];
return incoming.map((item, i) => restoreRedactedValues(item, origArr[i]));
}
const orig =
original && typeof original === "object" && !Array.isArray(original)
? (original as Record<string, unknown>)
: {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(incoming as Record<string, unknown>)) {
if (isSensitiveKey(key) && value === REDACTED_SENTINEL) {
if (!(key in orig)) {
throw new Error(
`config write rejected: "${key}" is redacted; set an explicit value instead of ${REDACTED_SENTINEL}`,
);
}
result[key] = orig[key];
} else if (typeof value === "object" && value !== null) {
result[key] = restoreRedactedValues(value, orig[key]);
} else {
result[key] = value;
}
}
return result;
}