fix: guard writeConfigFile against persisting redaction sentinels (#18102)
`writeConfigFile` lacked a safety check for the `__OPENCLAW_REDACTED__` sentinel. If a redacted value reached the write path (e.g. via `doctor --fix` or any other caller), it would be serialized to disk, permanently destroying the original credential. Add a pre-write guard that scans the final serialized JSON for the sentinel string and throws before any I/O, keeping the on-disk config intact. This protects all callers of `writeConfigFile` universally. <!-- AI-assisted (protocol-zero + Claude) --> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
89e3969d64
commit
0e309cdc88
@ -1,10 +1,11 @@
|
||||
import JSON5 from "json5";
|
||||
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import JSON5 from "json5";
|
||||
import { ensureOwnerDisplaySecret } from "../agents/owner-display.js";
|
||||
import { loadDotEnv } from "../infra/dotenv.js";
|
||||
import { resolveRequiredHomeDir } from "../infra/home-dir.js";
|
||||
import {
|
||||
@ -47,8 +48,8 @@ import { normalizeExecSafeBinProfilesInConfig } from "./normalize-exec-safe-bin.
|
||||
import { normalizeConfigPaths } from "./normalize-paths.js";
|
||||
import { resolveConfigPath, resolveDefaultConfigCandidates, resolveStateDir } from "./paths.js";
|
||||
import { isBlockedObjectKey } from "./prototype-keys.js";
|
||||
import { REDACTED_SENTINEL } from "./redact-snapshot.js";
|
||||
import { applyConfigOverrides } from "./runtime-overrides.js";
|
||||
import type { OpenClawConfig, ConfigFileSnapshot, LegacyConfigIssue } from "./types.js";
|
||||
import {
|
||||
validateConfigObjectRawWithPlugins,
|
||||
validateConfigObjectWithPlugins,
|
||||
@ -1188,6 +1189,19 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
||||
// explicitly set values. Runtime defaults are applied when loading (issue #6070).
|
||||
const stampedOutputConfig = stampConfigVersion(outputConfig);
|
||||
const json = JSON.stringify(stampedOutputConfig, null, 2).trimEnd().concat("\n");
|
||||
|
||||
if (json.includes(REDACTED_SENTINEL)) {
|
||||
deps.logger.error(
|
||||
`Config write blocked: redaction sentinel "${REDACTED_SENTINEL}" found in output — ` +
|
||||
`writing would destroy credentials. Config file was NOT modified.`,
|
||||
);
|
||||
throw new Error(
|
||||
`Refusing to write config: found redaction sentinel "${REDACTED_SENTINEL}". ` +
|
||||
`This is a bug — credentials would be permanently lost. ` +
|
||||
`The config file on disk was not changed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const nextHash = hashConfigRaw(json);
|
||||
const previousHash = resolveConfigSnapshotHash(snapshot);
|
||||
const changedPathCount = changedPaths?.size;
|
||||
|
||||
@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createConfigIO } from "./io.js";
|
||||
import type { OpenClawConfig } from "./types.js";
|
||||
import { REDACTED_SENTINEL } from "./redact-snapshot.js";
|
||||
|
||||
describe("config io write", () => {
|
||||
let fixtureRoot = "";
|
||||
@ -548,4 +549,35 @@ describe("config io write", () => {
|
||||
expect(last.watchCommand).toBe("gateway --force");
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to write config containing redaction sentinel (issue #18102)", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const initialConfig = {
|
||||
gateway: { port: 18789 },
|
||||
channels: { telegram: { botToken: "real-bot-token-123" } },
|
||||
};
|
||||
const errorFn = vi.fn();
|
||||
const { configPath, io, snapshot } = await writeConfigAndCreateIo({
|
||||
home,
|
||||
initialConfig,
|
||||
logger: { warn: vi.fn(), error: errorFn },
|
||||
});
|
||||
|
||||
const poisoned = structuredClone(snapshot.config);
|
||||
(poisoned as Record<string, unknown>).channels = {
|
||||
...poisoned.channels,
|
||||
telegram: {
|
||||
...poisoned.channels?.telegram,
|
||||
botToken: REDACTED_SENTINEL,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(io.writeConfigFile(poisoned)).rejects.toThrow(/redaction sentinel/i);
|
||||
|
||||
const ondisk = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
|
||||
expect(JSON.stringify(ondisk)).not.toContain(REDACTED_SENTINEL);
|
||||
expect(ondisk).toEqual(initialConfig);
|
||||
expect(errorFn).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user