fix(security): prevent zip extraction destination escape

This commit is contained in:
MaxMiksa 2026-01-28 20:58:12 -05:00
parent f4004054ab
commit 34e9448d08
2 changed files with 38 additions and 4 deletions

View File

@ -49,6 +49,23 @@ describe("archive utils", () => {
expect(content).toBe("hi");
});
it("blocks zip entries that escape the destination directory", async () => {
const workDir = await makeTempDir();
const archivePath = path.join(workDir, "evil.zip");
const extractDir = path.join(workDir, "extract");
const siblingDir = path.join(workDir, "extractX");
const zip = new JSZip();
zip.file("../extractX/pwned.txt", "pwned");
await fs.writeFile(archivePath, await zip.generateAsync({ type: "nodebuffer" }));
await fs.mkdir(extractDir, { recursive: true });
await expect(extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 })).rejects.toThrow(
/escapes destination/i,
);
await expect(fs.stat(path.join(siblingDir, "pwned.txt"))).rejects.toThrow();
});
it("extracts tar archives", async () => {
const workDir = await makeTempDir();
const archivePath = path.join(workDir, "bundle.tar");

View File

@ -61,7 +61,24 @@ export async function withTimeout<T>(
}
}
function ensureTrailingSep(filePath: string): string {
return filePath.endsWith(path.sep) ? filePath : `${filePath}${path.sep}`;
}
async function normalizeDestRoot(destDir: string): Promise<{ destRoot: string; destRootLower?: string }> {
await fs.mkdir(destDir, { recursive: true });
const destReal = await fs.realpath(destDir);
const destRoot = ensureTrailingSep(destReal);
return process.platform === "win32"
? { destRoot, destRootLower: destRoot.toLowerCase() }
: { destRoot };
}
async function extractZip(params: { archivePath: string; destDir: string }): Promise<void> {
const { destRoot, destRootLower } = await normalizeDestRoot(params.destDir);
const startsWithDest = (targetPath: string): boolean =>
destRootLower ? targetPath.toLowerCase().startsWith(destRootLower) : targetPath.startsWith(destRoot);
const buffer = await fs.readFile(params.archivePath);
const zip = await JSZip.loadAsync(buffer);
const entries = Object.values(zip.files);
@ -69,16 +86,16 @@ async function extractZip(params: { archivePath: string; destDir: string }): Pro
for (const entry of entries) {
const entryPath = entry.name.replaceAll("\\", "/");
if (!entryPath || entryPath.endsWith("/")) {
const dirPath = path.resolve(params.destDir, entryPath);
if (!dirPath.startsWith(params.destDir)) {
const dirPath = path.resolve(destRoot, entryPath);
if (!startsWithDest(dirPath)) {
throw new Error(`zip entry escapes destination: ${entry.name}`);
}
await fs.mkdir(dirPath, { recursive: true });
continue;
}
const outPath = path.resolve(params.destDir, entryPath);
if (!outPath.startsWith(params.destDir)) {
const outPath = path.resolve(destRoot, entryPath);
if (!startsWithDest(outPath)) {
throw new Error(`zip entry escapes destination: ${entry.name}`);
}
await fs.mkdir(path.dirname(outPath), { recursive: true });