mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(postinstall): reject unsafe dist symlinks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user