diff --git a/src/shared/archive-entry-validator.test.ts b/src/shared/archive-entry-validator.test.ts index 96c1bbbba..c8efc7f67 100644 --- a/src/shared/archive-entry-validator.test.ts +++ b/src/shared/archive-entry-validator.test.ts @@ -68,6 +68,21 @@ describe("validateArchiveEntries", () => { expect(rejectEscapeSymlink).toThrow(/symlink target/i) }) + it("rejects hard-link targets that escape the extraction directory", () => { + //#given + const destDir = "/tmp/archive-root" + + //#when + const rejectEscapeHardLink = () => + validateArchiveEntries( + [{ path: "bin/tool", type: "hardlink", linkPath: "../../etc/passwd" }], + destDir + ) + + //#then + expect(rejectEscapeHardLink).toThrow(/hard link target/i) + }) + it("accepts contained files, directories, and symlinks", () => { //#given const destDir = "/tmp/archive-root" @@ -120,6 +135,39 @@ describe("archive extraction preflight", () => { expect(errorMessage).toMatch(/path traversal/i) }) + it("rejects tar archives with hard-link traversal before extraction", async () => { + //#given + const rootDir = createTestDir() + const archivePath = join(rootDir, "malicious-hard-link.tar.gz") + const destDir = join(rootDir, "dest") + mkdirSync(destDir, { recursive: true }) + const scriptPath = writePythonScript( + rootDir, + "make-malicious-hard-link-tar.py", + [ + "import sys", + "import tarfile", + "with tarfile.open(sys.argv[1], 'w:gz') as archive:", + " info = tarfile.TarInfo('bin/tool')", + " info.type = tarfile.LNKTYPE", + " info.linkname = '../../etc/passwd'", + " archive.addfile(info)", + ].join("\n") + ) + runCommand(`python3 "${scriptPath}" "${archivePath}"`) + + //#when + let errorMessage = "" + try { + await extractTarGz(archivePath, destDir) + } catch (error) { + errorMessage = error instanceof Error ? error.message : String(error) + } + + //#then + expect(errorMessage).toMatch(/hard link target|path traversal/i) + }) + it("rejects zip archives with symlink escapes before extraction", async () => { //#given const rootDir = createTestDir() diff --git a/src/shared/zip-entry-listing/powershell-zip-entry-listing.test.ts b/src/shared/zip-entry-listing/powershell-zip-entry-listing.test.ts new file mode 100644 index 000000000..85caa10ae --- /dev/null +++ b/src/shared/zip-entry-listing/powershell-zip-entry-listing.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "bun:test" + +import { validateArchiveEntries } from "../archive-entry-validator" +import { parsePowerShellZipEntryLine } from "./powershell-zip-entry-listing" + +describe("parsePowerShellZipEntryLine", () => { + describe("#given a json entry line with tab characters in the file name", () => { + it("#when parsing and validating the entry #then preserves the full path for traversal checks", () => { + // given + const entryLine = JSON.stringify({ + type: "file", + name: `safe.txt\t../../escape.txt`, + target: "", + }) + + // when + const parsedEntry = parsePowerShellZipEntryLine(entryLine) + const validateParsedEntry = () => + validateArchiveEntries(parsedEntry ? [parsedEntry] : [], "/tmp/archive-root") + + // then + expect(parsedEntry).toEqual({ + path: `safe.txt\t../../escape.txt`, + type: "file", + }) + expect(validateParsedEntry).toThrow(/path traversal/i) + }) + }) +})