From 2e21d53c84ec88433dac587bb97f2796e63b20ca Mon Sep 17 00:00:00 2001 From: cxbAsDev Date: Sat, 18 Jul 2026 18:22:30 +0800 Subject: [PATCH] fix(plugins): bound plugin manifest metadata file reads (#110036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(plugins): bound plugin manifest metadata file reads * fix(plugins): use correct Error type check in oversized manifest catch readRegularFileSync throws a plain Error (not RangeError) when the file exceeds maxBytes. Change the catch-block type check from RangeError to Error to properly detect oversized plugin manifests and emit the subsystem warning. * test(plugins): exercise valid oversized manifests Co-authored-by: 陈宪彪0668000387 --------- Co-authored-by: Peter Steinberger --- src/plugins/manifest-metadata-scan.test.ts | 51 ++++++++++++++++++++++ src/plugins/manifest-metadata-scan.ts | 21 ++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/plugins/manifest-metadata-scan.test.ts b/src/plugins/manifest-metadata-scan.test.ts index 8d2de7b8d39d..fe5f883e1f28 100644 --- a/src/plugins/manifest-metadata-scan.test.ts +++ b/src/plugins/manifest-metadata-scan.test.ts @@ -126,4 +126,55 @@ describe("listOpenClawPluginManifestMetadata", () => { origin: "global", }); }); + + it("skips oversized plugin manifests to prevent OOM during metadata scan", () => { + const root = createTempRoot(); + const home = path.join(root, "home"); + + const goodPluginDir = path.join(home, ".openclaw", "extensions", "good-plugin"); + writeJson(path.join(goodPluginDir, "openclaw.plugin.json"), { id: "good-plugin" }); + + const oversizedDir = path.join(home, ".openclaw", "extensions", "big-plugin"); + const oversizedPath = path.join(oversizedDir, "openclaw.plugin.json"); + fs.mkdirSync(oversizedDir, { recursive: true }); + fs.writeFileSync( + oversizedPath, + JSON.stringify({ id: "big-plugin", pad: "x".repeat(256 * 1024) }), + "utf8", + ); + expect(fs.statSync(oversizedPath).size).toBeGreaterThan(256 * 1024); + + const records = listOpenClawPluginManifestMetadata({ + OPENCLAW_HOME: home, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "empty-bundled"), + }); + + // "good-plugin" is present; "big-plugin" is skipped due to oversized manifest. + expect(records.find((record) => record.manifest.id === "good-plugin")).toBeTruthy(); + expect(records.find((record) => record.manifest.id === "big-plugin")).toBeUndefined(); + }); + + it("accepts plugin manifests at the exact byte limit", () => { + const root = createTempRoot(); + const home = path.join(root, "home"); + + const exactDir = path.join(home, ".openclaw", "extensions", "exact-plugin"); + fs.mkdirSync(exactDir, { recursive: true }); + + // Write a compact JSON manifest padded to exactly the byte limit. + const exactPath = path.join(exactDir, "openclaw.plugin.json"); + const exactManifest = { id: "exact-plugin", pad: "" }; + const compactJson = JSON.stringify(exactManifest); + const requiredPadding = 256 * 1024 - Buffer.byteLength(compactJson, "utf8"); + exactManifest.pad = "x".repeat(requiredPadding); + fs.writeFileSync(exactPath, JSON.stringify(exactManifest), "utf8"); + expect(Buffer.byteLength(fs.readFileSync(exactPath), "utf8")).toBe(256 * 1024); + + const records = listOpenClawPluginManifestMetadata({ + OPENCLAW_HOME: home, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(root, "empty-bundled"), + }); + + expect(records.find((record) => record.manifest.id === "exact-plugin")).toBeTruthy(); + }); }); diff --git a/src/plugins/manifest-metadata-scan.ts b/src/plugins/manifest-metadata-scan.ts index 5788e8498d17..d3cf430095cd 100644 --- a/src/plugins/manifest-metadata-scan.ts +++ b/src/plugins/manifest-metadata-scan.ts @@ -6,10 +6,18 @@ import { normalizeOptionalString as normalizeTrimmedString } from "@openclaw/nor import { resolveStateDir } from "../config/paths.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import { readRegularFileSync } from "../infra/regular-file.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; import { parseJsonWithJson5Fallback } from "../utils/parse-json-compat.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { readPersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js"; +// Plugin manifest files are small metadata descriptors. Bound reads to prevent +// a corrupted or hostile manifest from exhausting memory during metadata scan. +const PLUGIN_MANIFEST_METADATA_MAX_BYTES = 256 * 1024; + +const log = createSubsystemLogger("plugins/manifest-metadata-scan"); + type PluginManifestMetadataRecord = { pluginDir: string; manifest: Record; @@ -56,9 +64,18 @@ function listChildPluginDirs( function readJsonObject(filePath: string): Record | undefined { try { - const parsed = parseJsonWithJson5Fallback(fs.readFileSync(filePath, "utf8")); + const { buffer } = readRegularFileSync({ + filePath, + maxBytes: PLUGIN_MANIFEST_METADATA_MAX_BYTES, + }); + const parsed = parseJsonWithJson5Fallback(buffer.toString("utf-8")); return isRecord(parsed) ? parsed : undefined; - } catch { + } catch (err) { + if (err instanceof Error && err.message.includes("exceeds")) { + log.warn( + `Ignoring oversized plugin manifest at ${filePath}: file exceeds the ${PLUGIN_MANIFEST_METADATA_MAX_BYTES}-byte limit`, + ); + } return undefined; } }