diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index b7db20bd94a3..6f7d6badac62 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -305,6 +305,67 @@ describe("marketplace plugins", () => { }); }); + it("rejects oversized local marketplace manifests", async () => { + await withTempDir("openclaw-marketplace-test-", async (rootDir) => { + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, Buffer.alloc(16 * 1024 * 1024 + 1, "x")); + + const result = await listMarketplacePlugins({ marketplace: rootDir }); + + expect(result).toEqual({ + ok: false, + error: "Marketplace manifest too large", + }); + }); + }); + + it("follows a symlinked marketplace manifest to a regular file", async () => { + if (process.platform === "win32") { + // Symlink support in unit tests is not guaranteed on Windows CI runners. + return; + } + await withTempDir("openclaw-marketplace-test-", async (rootDir) => { + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + const targetPath = path.join(rootDir, "real-manifest.json"); + await fs.writeFile( + targetPath, + JSON.stringify({ plugins: [{ name: "symlinked-plugin", source: "." }] }), + "utf-8", + ); + await fs.symlink(targetPath, manifestPath); + + const result = await listMarketplacePlugins({ marketplace: rootDir }); + + expect(result.ok).toBe(true); + expect( + (result as { ok: true; manifest: { plugins: unknown[] } }).manifest.plugins, + ).toHaveLength(1); + }); + }); + + it("rejects a symlinked marketplace manifest whose target exceeds the size limit", async () => { + if (process.platform === "win32") { + // Symlink support in unit tests is not guaranteed on Windows CI runners. + return; + } + await withTempDir("openclaw-marketplace-test-", async (rootDir) => { + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + const targetPath = path.join(rootDir, "real-manifest.json"); + await fs.writeFile(targetPath, Buffer.alloc(16 * 1024 * 1024 + 1, "x")); + await fs.symlink(targetPath, manifestPath); + + const result = await listMarketplacePlugins({ marketplace: rootDir }); + + expect(result).toEqual({ + ok: false, + error: "Marketplace manifest too large", + }); + }); + }); + it("resolves relative plugin paths against the marketplace root", async () => { await withTempDir("openclaw-marketplace-test-", async (rootDir) => { const pluginDir = path.join(rootDir, "plugins", "frontend-design"); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 6a425dd14cfd..c670aa2882b0 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -13,6 +13,7 @@ import { resolveOsHomeRelativePath } from "../infra/home-dir.js"; import { tryReadJson } from "../infra/json-files.js"; import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; import { isPathInside } from "../infra/path-guards.js"; +import { readRegularFile } from "../infra/regular-file.js"; import { runCommandWithTimeout } from "../process/exec.js"; import type { InstallPolicySource } from "../security/install-policy.js"; import { resolveUserPath } from "../utils.js"; @@ -23,6 +24,7 @@ import { installPluginFromPath, type InstallPluginResult } from "./install.js"; const DEFAULT_GIT_TIMEOUT_MS = 120_000; const DEFAULT_MARKETPLACE_DOWNLOAD_TIMEOUT_MS = 120_000; const MAX_MARKETPLACE_ARCHIVE_BYTES = 256 * 1024 * 1024; +const MAX_MARKETPLACE_MANIFEST_BYTES = 16 * 1024 * 1024; const MARKETPLACE_MANIFEST_CANDIDATES = [ path.join(".claude-plugin", "marketplace.json"), "marketplace.json", @@ -597,7 +599,28 @@ async function loadMarketplace(params: { remoteRef?: string; cleanup?: () => Promise; }): Promise<{ ok: true; marketplace: LoadedMarketplace } | { ok: false; error: string }> => { - const raw = await fs.readFile(paramsLocal.manifestPath, "utf-8"); + let raw: string; + try { + // Resolve symlinks so a marketplace.json that points to a regular file + // keeps working, while the bounded regular-file read still rejects + // directories, FIFOs, and oversized targets. + const resolvedManifestPath = await fs.realpath(paramsLocal.manifestPath); + const { buffer } = await readRegularFile({ + filePath: resolvedManifestPath, + maxBytes: MAX_MARKETPLACE_MANIFEST_BYTES, + }); + raw = buffer.toString("utf-8"); + } catch (err) { + await paramsLocal.cleanup?.(); + const message = err instanceof Error ? err.message : String(err); + // readRegularFile rejects symlinks/non-files and caps file size. Only the + // size cap should be reported as an oversize manifest; other read failures + // need their own diagnostic so users don't chase the wrong problem. + if (message.startsWith("File exceeds")) { + return { ok: false, error: "Marketplace manifest too large" }; + } + return { ok: false, error: `Marketplace manifest unreadable: ${message}` }; + } const parsed = parseMarketplaceManifest(raw, paramsLocal.manifestPath); if (!parsed.ok) { await paramsLocal.cleanup?.();