fix(plugins): bound marketplace manifest file reads (#101774)

* fix(plugins): bound marketplace manifest file reads

* fix(plugins): distinguish oversized marketplace manifest from other read failures

* fix(plugins): follow symlinked marketplace manifests while bounding reads

* test(plugins): cover oversized symlinked marketplace manifest target
This commit is contained in:
cxbAsDev
2026-07-18 17:34:00 +08:00
committed by GitHub
parent 75b1ac12df
commit 35754a63cc
2 changed files with 85 additions and 1 deletions
+61
View File
@@ -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");
+24 -1
View File
@@ -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<void>;
}): 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?.();