This commit is contained in:
Max Kong 2026-01-30 02:45:35 +00:00 committed by GitHub
commit 623a394e88
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 42 additions and 4 deletions

View File

@ -49,6 +49,23 @@ describe("archive utils", () => {
expect(content).toBe("hi"); 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 () => { it("extracts tar archives", async () => {
const workDir = await makeTempDir(); const workDir = await makeTempDir();
const archivePath = path.join(workDir, "bundle.tar"); const archivePath = path.join(workDir, "bundle.tar");

View File

@ -61,7 +61,28 @@ 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> { 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 buffer = await fs.readFile(params.archivePath);
const zip = await JSZip.loadAsync(buffer); const zip = await JSZip.loadAsync(buffer);
const entries = Object.values(zip.files); const entries = Object.values(zip.files);
@ -69,16 +90,16 @@ async function extractZip(params: { archivePath: string; destDir: string }): Pro
for (const entry of entries) { for (const entry of entries) {
const entryPath = entry.name.replaceAll("\\", "/"); const entryPath = entry.name.replaceAll("\\", "/");
if (!entryPath || entryPath.endsWith("/")) { if (!entryPath || entryPath.endsWith("/")) {
const dirPath = path.resolve(params.destDir, entryPath); const dirPath = path.resolve(destRoot, entryPath);
if (!dirPath.startsWith(params.destDir)) { if (!startsWithDest(dirPath)) {
throw new Error(`zip entry escapes destination: ${entry.name}`); throw new Error(`zip entry escapes destination: ${entry.name}`);
} }
await fs.mkdir(dirPath, { recursive: true }); await fs.mkdir(dirPath, { recursive: true });
continue; continue;
} }
const outPath = path.resolve(params.destDir, entryPath); const outPath = path.resolve(destRoot, entryPath);
if (!outPath.startsWith(params.destDir)) { if (!startsWithDest(outPath)) {
throw new Error(`zip entry escapes destination: ${entry.name}`); throw new Error(`zip entry escapes destination: ${entry.name}`);
} }
await fs.mkdir(path.dirname(outPath), { recursive: true }); await fs.mkdir(path.dirname(outPath), { recursive: true });