diff --git a/src/infra/archive.test.ts b/src/infra/archive.test.ts index 82ae804f50c..14c546e7674 100644 --- a/src/infra/archive.test.ts +++ b/src/infra/archive.test.ts @@ -10,6 +10,7 @@ import { extractArchive, resolveArchiveKind, resolvePackedRootDir } from "./arch let fixtureRoot = ""; let fixtureCount = 0; +const directorySymlinkType = process.platform === "win32" ? "junction" : undefined; async function makeTempDir(prefix = "case") { const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`); @@ -48,6 +49,14 @@ async function writePackageArchive(params: { await tar.c({ cwd: params.workDir, file: params.archivePath }, ["package"]); } +async function createDirectorySymlink(targetDir: string, linkPath: string) { + await fs.symlink(targetDir, linkPath, directorySymlinkType); +} + +async function expectPathMissing(filePath: string) { + await expect(fs.stat(filePath)).rejects.toMatchObject({ code: "ENOENT" }); +} + async function expectExtractedSizeBudgetExceeded(params: { archivePath: string; destDir: string; @@ -119,11 +128,7 @@ describe("archive utils", () => { content: "hi", }); await fs.rm(extractDir, { recursive: true, force: true }); - await fs.symlink( - realExtractDir, - extractDir, - process.platform === "win32" ? "junction" : undefined, - ); + await createDirectorySymlink(realExtractDir, extractDir); await expect( extractArchive({ archivePath, destDir: extractDir, timeoutMs: 5_000 }), @@ -131,9 +136,7 @@ describe("archive utils", () => { code: "destination-symlink", } satisfies Partial); - await expect( - fs.stat(path.join(realExtractDir, "package", "hello.txt")), - ).rejects.toMatchObject({ code: "ENOENT" }); + await expectPathMissing(path.join(realExtractDir, "package", "hello.txt")); }); }, ); @@ -154,13 +157,7 @@ describe("archive utils", () => { await withArchiveCase("zip", async ({ workDir, archivePath, extractDir }) => { const outsideDir = path.join(workDir, "outside"); await fs.mkdir(outsideDir, { recursive: true }); - // Use 'junction' on Windows — junctions target directories without - // requiring SeCreateSymbolicLinkPrivilege. - await fs.symlink( - outsideDir, - path.join(extractDir, "escape"), - process.platform === "win32" ? "junction" : undefined, - ); + await createDirectorySymlink(outsideDir, path.join(extractDir, "escape")); const zip = new JSZip(); zip.file("escape/pwn.txt", "owned"); @@ -273,11 +270,7 @@ describe("archive utils", () => { await fs.mkdir(outsideDir, { recursive: true }); await fs.mkdir(path.join(archiveRoot, "escape"), { recursive: true }); await fs.writeFile(path.join(archiveRoot, "escape", "pwn.txt"), "owned"); - await fs.symlink( - outsideDir, - path.join(extractDir, "escape"), - process.platform === "win32" ? "junction" : undefined, - ); + await createDirectorySymlink(outsideDir, path.join(extractDir, "escape")); await tar.c({ cwd: archiveRoot, file: archivePath }, ["escape"]); await expect( @@ -286,9 +279,7 @@ describe("archive utils", () => { code: "destination-symlink-traversal", } satisfies Partial); - await expect(fs.stat(path.join(outsideDir, "pwn.txt"))).rejects.toMatchObject({ - code: "ENOENT", - }); + await expectPathMissing(path.join(outsideDir, "pwn.txt")); }); }); diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 613b02579a5..97460eff4f3 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -575,6 +575,10 @@ export async function extractArchive(params: { stripComponents: params.stripComponents, limits, }); + // A canonical cwd is not enough here: tar can still follow + // pre-existing child symlinks in the live destination tree. + // Extract into a private staging dir first, then merge through + // the same safe-open boundary checks used by direct file writes. await tar.x({ file: params.archivePath, cwd: stagingDir,