mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:50:43 +00:00
fix(postinstall): reject dist symlink escapes
This commit is contained in:
@@ -17,7 +17,9 @@ import {
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmdirSync,
|
||||
rmSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
||||
@@ -197,7 +199,7 @@ function listInstalledDistFiles(params = {}) {
|
||||
|
||||
function pruneEmptyDistDirectories(params = {}) {
|
||||
const readDir = params.readdirSync ?? readdirSync;
|
||||
const removePath = params.rmSync ?? rmSync;
|
||||
const removeDirectory = params.rmdirSync ?? rmdirSync;
|
||||
const distRoot = resolveInstalledDistRoot(params);
|
||||
if (distRoot === null) {
|
||||
return;
|
||||
@@ -227,13 +229,12 @@ function pruneEmptyDistDirectories(params = {}) {
|
||||
);
|
||||
}
|
||||
if (readDir(currentDir).length === 0) {
|
||||
removePath(
|
||||
removeDirectory(
|
||||
assertSafeInstalledDistPath(normalizeRelativePath(relative(packageRoot, currentDir)), {
|
||||
packageRoot,
|
||||
distDirReal: distRoot.distDirReal,
|
||||
realpathSync: params.realpathSync,
|
||||
}),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -243,7 +244,7 @@ function pruneEmptyDistDirectories(params = {}) {
|
||||
|
||||
export function pruneInstalledPackageDist(params = {}) {
|
||||
const packageRoot = params.packageRoot ?? DEFAULT_PACKAGE_ROOT;
|
||||
const removePath = params.rmSync ?? rmSync;
|
||||
const removeFile = params.unlinkSync ?? unlinkSync;
|
||||
const log = params.log ?? console;
|
||||
const distRoot = resolveInstalledDistRoot(params);
|
||||
if (distRoot === null) {
|
||||
@@ -257,13 +258,12 @@ export function pruneInstalledPackageDist(params = {}) {
|
||||
if (expectedFiles.has(relativePath)) {
|
||||
continue;
|
||||
}
|
||||
removePath(
|
||||
removeFile(
|
||||
assertSafeInstalledDistPath(relativePath, {
|
||||
packageRoot,
|
||||
distDirReal: distRoot.distDirReal,
|
||||
realpathSync: params.realpathSync,
|
||||
}),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
removed.push(relativePath);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
import {
|
||||
collectPackageDistInventoryErrors,
|
||||
PACKAGE_DIST_INVENTORY_RELATIVE_PATH,
|
||||
collectPackageDistInventory,
|
||||
writePackageDistInventory,
|
||||
} from "./package-dist-inventory.js";
|
||||
|
||||
@@ -63,4 +64,17 @@ describe("package dist inventory", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects symlinked dist entries", async () => {
|
||||
await withTempDir({ prefix: "openclaw-dist-inventory-symlink-" }, async (packageRoot) => {
|
||||
const distDir = path.join(packageRoot, "dist");
|
||||
await fs.mkdir(distDir, { recursive: true });
|
||||
await fs.writeFile(path.join(packageRoot, "escape.js"), "export {};\n", "utf8");
|
||||
await fs.symlink(path.join(packageRoot, "escape.js"), path.join(distDir, "entry.js"));
|
||||
|
||||
await expect(collectPackageDistInventory(packageRoot)).rejects.toThrow(
|
||||
"Unsafe package dist path: dist/entry.js",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,15 +34,24 @@ function isPackagedDistPath(relativePath: string): boolean {
|
||||
}
|
||||
async function collectRelativeFiles(rootDir: string, baseDir: string): Promise<string[]> {
|
||||
try {
|
||||
const rootStats = await fs.lstat(rootDir);
|
||||
if (!rootStats.isDirectory() || rootStats.isSymbolicLink()) {
|
||||
throw new Error(
|
||||
`Unsafe package dist path: ${normalizeRelativePath(path.relative(baseDir, rootDir))}`,
|
||||
);
|
||||
}
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(rootDir, entry.name);
|
||||
const relativePath = normalizeRelativePath(path.relative(baseDir, entryPath));
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`Unsafe package dist path: ${relativePath}`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
return await collectRelativeFiles(entryPath, baseDir);
|
||||
}
|
||||
if (entry.isFile() || entry.isSymbolicLink()) {
|
||||
const relativePath = normalizeRelativePath(path.relative(baseDir, entryPath));
|
||||
if (entry.isFile()) {
|
||||
return isPackagedDistPath(relativePath) ? [relativePath] : [];
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -262,6 +262,40 @@ describe("bundled plugin postinstall", () => {
|
||||
).toThrow("unsafe dist entry: dist/escape");
|
||||
});
|
||||
|
||||
it("unlinks stale files instead of recursive pruning them", () => {
|
||||
const unlinkSync = vi.fn();
|
||||
|
||||
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, options) => {
|
||||
if (filePath === "/pkg/dist" && options?.withFileTypes) {
|
||||
return [
|
||||
{
|
||||
name: "stale.js",
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
isSymbolicLink: () => false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
unlinkSync,
|
||||
log: { log: vi.fn(), warn: vi.fn() },
|
||||
}),
|
||||
).toEqual(["dist/stale.js"]);
|
||||
|
||||
expect(unlinkSync).toHaveBeenCalledWith("/pkg/dist/stale.js");
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user