From 9e1df984758331e3839aedac390e8a351c5283a4 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Wed, 15 Apr 2026 10:20:13 +0530 Subject: [PATCH] fix(postinstall): reject unsafe dist symlinks --- scripts/postinstall-bundled-plugins.mjs | 90 ++++++++++++++++--- .../postinstall-bundled-plugins.test.ts | 48 ++++++++++ 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 20d423c18f8..443b350b753 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -124,15 +124,47 @@ function readInstalledDistInventory(params = {}) { return new Set(parsed.map(normalizeRelativePath)); } -function listInstalledDistFiles(params = {}) { +function resolveInstalledDistRoot(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const pathExists = params.existsSync ?? existsSync; - const readDir = params.readdirSync ?? readdirSync; + const pathLstat = params.lstatSync ?? lstatSync; + const resolveRealPath = params.realpathSync ?? realpathSync; const distDir = join(packageRoot, "dist"); if (!pathExists(distDir)) { + return null; + } + const distStats = pathLstat(distDir); + if (!distStats.isDirectory() || distStats.isSymbolicLink()) { + throw new Error("unsafe dist root: dist must be a real directory"); + } + const packageRootReal = resolveRealPath(packageRoot); + const distDirReal = resolveRealPath(distDir); + const relativeDistPath = relative(packageRootReal, distDirReal); + if (relativeDistPath !== "dist") { + throw new Error("unsafe dist root: dist escaped package root"); + } + return { distDir, distDirReal, packageRootReal }; +} + +function assertSafeInstalledDistPath(relativePath, params) { + const resolveRealPath = params.realpathSync ?? realpathSync; + const candidatePath = join(params.packageRoot, relativePath); + const candidateRealPath = resolveRealPath(candidatePath); + const relativeCandidatePath = relative(params.distDirReal, candidateRealPath); + if (relativeCandidatePath.startsWith("..") || isAbsolute(relativeCandidatePath)) { + throw new Error(`unsafe dist path: ${relativePath}`); + } + return candidatePath; +} + +function listInstalledDistFiles(params = {}) { + const readDir = params.readdirSync ?? readdirSync; + const distRoot = resolveInstalledDistRoot(params); + if (distRoot === null) { return []; } - const pending = [distDir]; + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const pending = [distRoot.distDir]; const files = []; while (pending.length > 0) { const currentDir = pending.pop(); @@ -141,11 +173,16 @@ function listInstalledDistFiles(params = {}) { } for (const entry of readDir(currentDir, { withFileTypes: true })) { const entryPath = join(currentDir, entry.name); + if (entry.isSymbolicLink()) { + throw new Error( + `unsafe dist entry: ${normalizeRelativePath(relative(packageRoot, entryPath))}`, + ); + } if (entry.isDirectory()) { pending.push(entryPath); continue; } - if (!entry.isFile() && !entry.isSymbolicLink()) { + if (!entry.isFile()) { continue; } const relativePath = normalizeRelativePath(relative(packageRoot, entryPath)); @@ -159,37 +196,59 @@ function listInstalledDistFiles(params = {}) { } function pruneEmptyDistDirectories(params = {}) { - const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; - const pathExists = params.existsSync ?? existsSync; const readDir = params.readdirSync ?? readdirSync; const removePath = params.rmSync ?? rmSync; - const distDir = join(packageRoot, "dist"); - if (!pathExists(distDir)) { + const distRoot = resolveInstalledDistRoot(params); + if (distRoot === null) { return; } + const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; + const pathLstat = params.lstatSync ?? lstatSync; function prune(currentDir) { for (const entry of readDir(currentDir, { withFileTypes: true })) { + if (entry.isSymbolicLink()) { + throw new Error( + `unsafe dist entry: ${normalizeRelativePath(relative(packageRoot, join(currentDir, entry.name)))}`, + ); + } if (!entry.isDirectory()) { continue; } prune(join(currentDir, entry.name)); } - if (currentDir === distDir) { + if (currentDir === distRoot.distDir) { return; } + const currentStats = pathLstat(currentDir); + if (!currentStats.isDirectory() || currentStats.isSymbolicLink()) { + throw new Error( + `unsafe dist directory: ${normalizeRelativePath(relative(packageRoot, currentDir))}`, + ); + } if (readDir(currentDir).length === 0) { - removePath(currentDir, { recursive: true, force: true }); + removePath( + assertSafeInstalledDistPath(normalizeRelativePath(relative(packageRoot, currentDir)), { + packageRoot, + distDirReal: distRoot.distDirReal, + realpathSync: params.realpathSync, + }), + { recursive: true, force: true }, + ); } } - prune(distDir); + prune(distRoot.distDir); } export function pruneInstalledPackageDist(params = {}) { const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT; const removePath = params.rmSync ?? rmSync; const log = params.log ?? console; + const distRoot = resolveInstalledDistRoot(params); + if (distRoot === null) { + return []; + } const expectedFiles = params.expectedFiles ?? readInstalledDistInventory(params); const installedFiles = listInstalledDistFiles(params); const removed = []; @@ -198,7 +257,14 @@ export function pruneInstalledPackageDist(params = {}) { if (expectedFiles.has(relativePath)) { continue; } - removePath(join(packageRoot, relativePath), { recursive: true, force: true }); + removePath( + assertSafeInstalledDistPath(relativePath, { + packageRoot, + distDirReal: distRoot.distDirReal, + realpathSync: params.realpathSync, + }), + { recursive: true, force: true }, + ); removed.push(relativePath); } diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index a3278b99968..65fb525ac40 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -214,6 +214,54 @@ describe("bundled plugin postinstall", () => { await expect(fs.stat(staleFile)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("rejects symlinked dist roots in packaged installs", () => { + expect(() => + pruneInstalledPackageDist({ + packageRoot: "/pkg", + expectedFiles: new Set(), + existsSync: vi.fn(() => true), + lstatSync: vi.fn((filePath) => ({ + isDirectory: () => filePath === "/pkg/dist", + isSymbolicLink: () => filePath === "/pkg/dist", + })), + realpathSync: vi.fn((filePath) => filePath), + readdirSync: vi.fn(), + rmSync: vi.fn(), + log: { log: vi.fn(), warn: vi.fn() }, + }), + ).toThrow("unsafe dist root: dist must be a real directory"); + }); + + it("rejects symlink entries in packaged dist trees", () => { + expect(() => + pruneInstalledPackageDist({ + packageRoot: "/pkg", + expectedFiles: new Set(), + existsSync: vi.fn(() => true), + lstatSync: vi.fn(() => ({ + isDirectory: () => true, + isSymbolicLink: () => false, + })), + realpathSync: vi.fn((filePath) => filePath), + readdirSync: vi.fn((filePath) => { + if (filePath === "/pkg/dist") { + return [ + { + name: "escape", + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + }, + ]; + } + return []; + }), + rmSync: vi.fn(), + log: { log: vi.fn(), warn: vi.fn() }, + }), + ).toThrow("unsafe dist entry: dist/escape"); + }); + it("runs nested local installs with sanitized env when the sentinel package is missing", async () => { const extensionsDir = await createExtensionsDir(); const packageRoot = path.dirname(path.dirname(extensionsDir));