Skip to content

Commit 2295fae

Browse files
cxbAsDevsteipete
andauthored
fix: bound miscellaneous unbounded file reads across 5 modules (#110516)
* fix: bound misc unbounded fs.readFile calls; remove unused fs import * fix: decode buffer to string before passing to string consumers readRegularFile and readRegularFileSync return { buffer, stat }, not a string. All 4 new call sites passed the raw object to functions expecting a string (JSON.parse, RegExp.test, template literals, etc.), causing TS2345 type errors and runtime failures. Fix each call by extracting .buffer and calling .toString('utf8') before passing the result to string consumers. * style: fix oxfmt formatting in config-set-input.ts * fix: bound config and trajectory metadata reads Co-authored-by: 陈宪彪0668000387 <[email protected]> * refactor: isolate bounded read ownership Co-authored-by: 陈宪彪0668000387 <[email protected]> --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent a5ec26f commit 2295fae

10 files changed

Lines changed: 150 additions & 12 deletions

File tree

docs/cli/config.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ Use `--replace` only when the provided value should intentionally become the com
177177
openclaw config set --batch-file ./config-set.batch.json --dry-run
178178
```
179179

180+
Batch files are limited to 8 MiB.
181+
180182
</Tab>
181183
</Tabs>
182184

@@ -257,6 +259,8 @@ openclaw config patch --file ./openclaw.patch.json5 --dry-run
257259
openclaw config patch --file ./openclaw.patch.json5
258260
```
259261

262+
Patch files are limited to 8 MiB. Piped `--stdin` patches are limited to 1 MiB.
263+
260264
Pipe a patch over stdin for remote setup scripts:
261265

262266
```bash

src/cli/config-cli.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2424,6 +2424,24 @@ describe("config cli", () => {
24242424
expect(mockWriteConfigFile).not.toHaveBeenCalled();
24252425
});
24262426

2427+
it("rejects --file patches above the config mutation limit", async () => {
2428+
const pathname = path.join(
2429+
os.tmpdir(),
2430+
`openclaw-config-patch-oversized-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`,
2431+
);
2432+
fs.writeFileSync(pathname, " ".repeat(8 * 1024 * 1024 + 1), "utf8");
2433+
try {
2434+
await expect(runConfigCommand(["config", "patch", "--file", pathname])).rejects.toThrow(
2435+
ExitError,
2436+
);
2437+
} finally {
2438+
fs.rmSync(pathname, { force: true });
2439+
}
2440+
2441+
expectErrorIncludes("File exceeds 8388608 bytes");
2442+
expect(mockWriteConfigFile).not.toHaveBeenCalled();
2443+
});
2444+
24272445
it("dry-runs pluginIntegration provider patches against manifest integration metadata", async () => {
24282446
const pluginId = "secret-provider-proof";
24292447
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-plugin-provider-"));

src/cli/config-cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// Config CLI command implementation for get/set/unset/patch/validate and secret refs.
2-
import fs from "node:fs";
32
import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit";
43
import { expectDefined } from "@openclaw/normalization-core";
54
import { isRecord as isPlainRecord } from "@openclaw/normalization-core/record-coerce";
@@ -85,6 +84,7 @@ import {
8584
hasProviderBuilderOptions,
8685
hasRefBuilderOptions,
8786
parseBatchSource,
87+
readConfigMutationFileSync,
8888
type ConfigSetBatchEntry,
8989
type ConfigSetOptions,
9090
} from "./config-set-input.js";
@@ -1507,7 +1507,7 @@ async function readConfigPatchInput(opts: ConfigPatchOptions): Promise<unknown>
15071507
raw = await readStdinText();
15081508
} else {
15091509
try {
1510-
raw = fs.readFileSync(file as string, "utf8");
1510+
raw = readConfigMutationFileSync(file as string);
15111511
} catch (err) {
15121512
if (hasErrnoCode(err, "ENOENT")) {
15131513
throw new Error(`--file not found: ${file}`, { cause: err });

src/cli/config-set-input.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,16 @@ describe("config set input parsing", () => {
113113
).toThrow("--batch-file must be a JSON array.");
114114
});
115115
});
116+
117+
it("rejects --batch-file payloads above the config mutation limit", () => {
118+
withBatchFile(
119+
"openclaw-config-set-input-oversized-",
120+
" ".repeat(8 * 1024 * 1024 + 1),
121+
(batchPath) => {
122+
expect(() => parseBatchSource({ batchFile: batchPath })).toThrow(
123+
"File exceeds 8388608 bytes",
124+
);
125+
},
126+
);
127+
});
116128
});

src/cli/config-set-input.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "@openclaw/normalization-core/string-coerce";
77
import JSON5 from "json5";
88
import { hasErrnoCode } from "../infra/errors.js";
9+
import { readFileDescriptorBoundedSync } from "../infra/file-descriptor-read.js";
910

1011
export type ConfigSetOptions = {
1112
strictJson?: boolean;
@@ -45,6 +46,19 @@ export type ConfigSetBatchEntry = {
4546
provider?: unknown;
4647
};
4748

49+
const CONFIG_MUTATION_FILE_MAX_BYTES = 8 * 1024 * 1024;
50+
51+
export function readConfigMutationFileSync(filePath: string): string {
52+
// These explicit CLI file flags have historically followed user-provided
53+
// symlinks. Pin the opened descriptor, then bound the read without changing that contract.
54+
const fd = fs.openSync(filePath, "r");
55+
try {
56+
return readFileDescriptorBoundedSync(fd, CONFIG_MUTATION_FILE_MAX_BYTES).toString("utf8");
57+
} finally {
58+
fs.closeSync(fd);
59+
}
60+
}
61+
4862
export function hasBatchMode(opts: ConfigSetOptions): boolean {
4963
return Boolean(
5064
normalizeOptionalString(opts.batchJson) || normalizeOptionalString(opts.batchFile),
@@ -139,7 +153,7 @@ export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[]
139153
}
140154
let raw: string;
141155
try {
142-
raw = fs.readFileSync(pathname, "utf8");
156+
raw = readConfigMutationFileSync(pathname);
143157
} catch (err) {
144158
if (hasErrnoCode(err, "ENOENT")) {
145159
throw new Error(`--batch-file not found: ${pathname}`, { cause: err });

src/trajectory/cleanup.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,4 +170,34 @@ describe("trajectory cleanup", () => {
170170
expect((await fs.stat(unsafeExternalRuntime)).isFile()).toBe(true);
171171
});
172172
});
173+
174+
it("ignores oversized trajectory pointers while still removing the sidecar", async () => {
175+
await withTempDir({ prefix: "openclaw-trajectory-cleanup-" }, async (dir) => {
176+
const sessionId = "session-oversized-pointer";
177+
const sessionsDir = path.join(dir, "sessions");
178+
const storePath = path.join(sessionsDir, "sessions.json");
179+
const sessionFile = path.join(sessionsDir, `${sessionId}.jsonl`);
180+
const externalRuntime = path.join(dir, "external", `${sessionId}.jsonl`);
181+
const pointerPath = resolveTrajectoryPointerFilePath(sessionFile);
182+
await fs.mkdir(sessionsDir, { recursive: true });
183+
await fs.mkdir(path.dirname(externalRuntime), { recursive: true });
184+
await fs.writeFile(externalRuntime, runtimeEvent(sessionId), "utf8");
185+
await fs.writeFile(
186+
pointerPath,
187+
`${pointerFile(sessionId, externalRuntime)}${" ".repeat(64 * 1024)}`,
188+
"utf8",
189+
);
190+
191+
const removed = await removeSessionTrajectoryArtifacts({
192+
sessionId,
193+
sessionFile,
194+
storePath,
195+
restrictToStoreDir: true,
196+
});
197+
198+
expect(removed).toEqual([{ kind: "pointer", path: pointerPath }]);
199+
expect((await fs.stat(externalRuntime)).isFile()).toBe(true);
200+
await expectPathMissing(pointerPath);
201+
});
202+
});
173203
});

src/trajectory/cleanup.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { resolveSessionFilePath } from "../config/sessions/paths.js";
66
import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
77
import { readFileWindowFullySync } from "../infra/file-read.js";
88
import { isPathInside } from "../infra/path-guards.js";
9+
import { readRegularFileSync } from "../infra/regular-file.js";
910
import {
11+
TRAJECTORY_POINTER_FILE_MAX_BYTES,
1012
resolveTrajectoryFilePath,
1113
resolveTrajectoryPointerFilePath,
1214
safeTrajectorySessionFileName,
@@ -52,11 +54,12 @@ function readTrajectoryPointerFile(
5254
pointerPath: string,
5355
sessionId: string,
5456
): TrajectoryPointer | null {
55-
if (!isRegularNonSymlinkFile(pointerPath)) {
56-
return null;
57-
}
5857
try {
59-
const parsed: unknown = JSON.parse(fs.readFileSync(pointerPath, "utf8"));
58+
const { buffer } = readRegularFileSync({
59+
filePath: pointerPath,
60+
maxBytes: TRAJECTORY_POINTER_FILE_MAX_BYTES,
61+
});
62+
const parsed: unknown = JSON.parse(buffer.toString("utf8"));
6063
if (!isRecord(parsed)) {
6164
return null;
6265
}

src/trajectory/export.test.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.
1010
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
1111
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
1212
import { exportTrajectoryBundle, resolveDefaultTrajectoryExportDir } from "./export.js";
13-
import { TRAJECTORY_RUNTIME_FILE_MAX_BYTES, resolveTrajectoryPointerFilePath } from "./paths.js";
13+
import {
14+
TRAJECTORY_POINTER_FILE_MAX_BYTES,
15+
TRAJECTORY_RUNTIME_FILE_MAX_BYTES,
16+
resolveTrajectoryFilePath,
17+
resolveTrajectoryPointerFilePath,
18+
} from "./paths.js";
1419
import { appendSqliteTrajectoryRuntimeEvents } from "./runtime-store.sqlite.js";
1520
import type { TrajectoryEvent } from "./types.js";
1621

@@ -1307,6 +1312,53 @@ describe("exportTrajectoryBundle", () => {
13071312
expect(eventTypes(bundle.events)).not.toContain("outside-runtime");
13081313
});
13091314

1315+
it("ignores oversized runtime pointers and falls back to the default trajectory file", async () => {
1316+
const tmpDir = makeTempDir();
1317+
const sessionFile = path.join(tmpDir, "session.jsonl");
1318+
const defaultRuntimeFile = resolveTrajectoryFilePath({
1319+
env: {},
1320+
sessionFile,
1321+
sessionId: "session-1",
1322+
});
1323+
const outputDir = path.join(tmpDir, "bundle");
1324+
writeSimpleSessionFile(sessionFile);
1325+
fs.writeFileSync(
1326+
resolveTrajectoryPointerFilePath(sessionFile),
1327+
`${JSON.stringify({
1328+
traceSchema: "openclaw-trajectory-pointer",
1329+
schemaVersion: 1,
1330+
sessionId: "session-1",
1331+
runtimeFile: path.join(tmpDir, "recorded", "session-1.jsonl"),
1332+
})}\n${" ".repeat(TRAJECTORY_POINTER_FILE_MAX_BYTES)}`,
1333+
"utf8",
1334+
);
1335+
fs.writeFileSync(
1336+
defaultRuntimeFile,
1337+
`${JSON.stringify({
1338+
traceSchema: "openclaw-trajectory",
1339+
schemaVersion: 1,
1340+
traceId: "session-1",
1341+
source: "runtime",
1342+
type: "default-runtime",
1343+
ts: "2026-04-22T08:00:00.000Z",
1344+
seq: 1,
1345+
sourceSeq: 1,
1346+
sessionId: "session-1",
1347+
})}\n`,
1348+
"utf8",
1349+
);
1350+
1351+
const bundle = await exportTrajectoryBundle({
1352+
outputDir,
1353+
sessionFile,
1354+
sessionId: "session-1",
1355+
workspaceDir: tmpDir,
1356+
});
1357+
1358+
expect(bundle.runtimeFile).toBe(defaultRuntimeFile);
1359+
expect(eventTypes(bundle.events)).toContain("default-runtime");
1360+
});
1361+
13101362
it("does not fall back to runtime pointer targets that are not regular files", async () => {
13111363
const tmpDir = makeTempDir();
13121364
const sessionFile = path.join(tmpDir, "session.jsonl");

src/trajectory/paths.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { isPathInside } from "../infra/path-guards.js";
1010
export const TRAJECTORY_RUNTIME_CAPTURE_MAX_BYTES = 10 * 1024 * 1024;
1111
export const TRAJECTORY_RUNTIME_FILE_MAX_BYTES = 50 * 1024 * 1024;
1212
export const TRAJECTORY_RUNTIME_EVENT_MAX_BYTES = 256 * 1024;
13+
// Pointer JSON only records schema metadata and one runtime path.
14+
export const TRAJECTORY_POINTER_FILE_MAX_BYTES = 64 * 1024;
1315

1416
export function safeTrajectorySessionFileName(sessionId: string): string {
1517
const safe = sessionId.replaceAll(/[^A-Za-z0-9_-]/g, "_").slice(0, 120);

src/trajectory/runtime-file.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import fsp from "node:fs/promises";
33
import path from "node:path";
44
import { isRecord } from "@openclaw/normalization-core/record-coerce";
5+
import { readRegularFile } from "../infra/regular-file.js";
56
import {
7+
TRAJECTORY_POINTER_FILE_MAX_BYTES,
68
resolveTrajectoryFilePath,
79
resolveTrajectoryPointerFilePath,
810
safeTrajectorySessionFileName,
@@ -28,11 +30,12 @@ async function readRuntimePointerFile(
2830
sessionId: string,
2931
): Promise<string | undefined> {
3032
const pointerPath = resolveTrajectoryPointerFilePath(sessionFile);
31-
if (!(await isRegularNonSymlinkFile(pointerPath))) {
32-
return undefined;
33-
}
3433
try {
35-
const parsed = JSON.parse(await fsp.readFile(pointerPath, "utf8")) as unknown;
34+
const { buffer } = await readRegularFile({
35+
filePath: pointerPath,
36+
maxBytes: TRAJECTORY_POINTER_FILE_MAX_BYTES,
37+
});
38+
const parsed = JSON.parse(buffer.toString("utf8")) as unknown;
3639
if (!isRecord(parsed)) {
3740
return undefined;
3841
}

0 commit comments

Comments
 (0)