Skip to content

Commit ce078f3

Browse files
authored
fix(security): bound skill-file audit read via safe reader (restore removed fs dependency) (#110589)
1 parent 6b90591 commit ce078f3

2 files changed

Lines changed: 45 additions & 3 deletions

File tree

src/security/audit-extra.async.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,25 @@ Read the requested file and summarize it.
295295
).toBe(false);
296296
});
297297

298+
it("surfaces manifest_parse_error finding when plugin package.json exceeds the size limit", async () => {
299+
const tmpDir = await makeTmpDir("audit-manifest-oversized");
300+
const pluginDir = path.join(tmpDir, "extensions", "oversized-plugin");
301+
await fs.mkdir(pluginDir, { recursive: true });
302+
// Oversized manifest — simulates a plugin trying to exhaust the audit reader
303+
// by declaring a huge package.json, hiding its declared extension entrypoints.
304+
await fs.writeFile(path.join(pluginDir, "package.json"), "x".repeat(1024 * 1024 + 1), "utf-8");
305+
306+
const findings = await collectPluginsCodeSafetyFindings({ stateDir: tmpDir });
307+
const finding = requireFinding(
308+
findings,
309+
(f) => f.checkId === "plugins.code_safety.manifest_parse_error",
310+
"oversized manifest parse error",
311+
);
312+
expect(finding.severity).toBe("warn");
313+
expect(finding.detail).toContain("oversized-plugin");
314+
expect(finding.detail).toContain("too large");
315+
});
316+
298317
it("reports scan_failed when plugin code scanner throws during deep audit", async () => {
299318
const scanSpy = vi
300319
.spyOn(skillScanner, "scanDirectoryWithSummary")

src/security/audit-extra.async.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
*
44
* These functions perform I/O (filesystem, config reads) to detect security issues.
55
*/
6-
import fs from "node:fs/promises";
76
import path from "node:path";
87
import { clearTimeout as clearNodeTimeout, setTimeout as setNodeTimeout } from "node:timers";
98
import {
@@ -21,6 +20,7 @@ import { MANIFEST_KEY } from "../compat/legacy-names.js";
2120
import type { OpenClawConfig, ConfigFileSnapshot } from "../config/config.js";
2221
import { collectIncludePathsRecursive } from "../config/includes-scan.js";
2322
import { resolveOAuthDir } from "../config/paths.js";
23+
import { readRegularFile, statRegularFile } from "../infra/fs-safe.js";
2424
import { normalizeAgentId } from "../routing/session-key.js";
2525
import { getOrCreatePromise } from "../shared/lazy-promise.js";
2626
import { createLazyRuntimeModule, createLazyRuntimeNamedExport } from "../shared/lazy-runtime.js";
@@ -97,9 +97,29 @@ function expandTilde(p: string, env: NodeJS.ProcessEnv): string | null {
9797
return null;
9898
}
9999

100+
const MAX_PLUGIN_MANIFEST_BYTES = 1024 * 1024;
101+
// Skill file audit reads are bounded like other audit reads; matches the
102+
// workspace loader's DEFAULT_MAX_SKILL_FILE_BYTES so oversized SKILL.md files
103+
// cannot force an unbounded read during the code-safety scan.
104+
const MAX_SKILL_AUDIT_FILE_BYTES = 256_000;
105+
100106
async function readPluginManifestExtensions(pluginPath: string): Promise<string[]> {
101107
const manifestPath = path.join(pluginPath, "package.json");
102-
const raw = await fs.readFile(manifestPath, "utf-8").catch(() => "");
108+
const statResult = await statRegularFile(manifestPath);
109+
if (statResult.missing) {
110+
return [];
111+
}
112+
if (statResult.stat.size > MAX_PLUGIN_MANIFEST_BYTES) {
113+
throw new Error(
114+
`Plugin manifest at ${manifestPath} is too large (${statResult.stat.size} bytes, max ${MAX_PLUGIN_MANIFEST_BYTES})`,
115+
);
116+
}
117+
118+
const { buffer } = await readRegularFile({
119+
filePath: manifestPath,
120+
maxBytes: MAX_PLUGIN_MANIFEST_BYTES,
121+
});
122+
const raw = buffer.toString("utf-8");
103123
if (!raw.trim()) {
104124
return [];
105125
}
@@ -177,7 +197,10 @@ async function getSkillCodeSafetySummary(params: {
177197
dirPath: params.dirPath,
178198
summaryCache: params.summaryCache,
179199
}),
180-
fs.readFile(params.skillFilePath, "utf-8"),
200+
readRegularFile({
201+
filePath: params.skillFilePath,
202+
maxBytes: MAX_SKILL_AUDIT_FILE_BYTES,
203+
}).then(({ buffer }) => buffer.toString("utf-8")),
181204
loadSkillScannerModule(),
182205
]);
183206
const skillFindings = [

0 commit comments

Comments
 (0)