From 9b26e26caa4ffe4614d0e01ab07513839ff1063e Mon Sep 17 00:00:00 2001 From: Robby Date: Wed, 28 Jan 2026 10:49:25 +0000 Subject: [PATCH] security(archive): fix path traversal in zip/tar extraction Problem: The zip extractor used `outPath.startsWith(destDir)` for path validation. This is bypassable: if destDir is `/tmp/foo`, the path `/tmp/foobar/evil.txt` passes because the string `/tmp/foobar` starts with `/tmp/foo`. The tar extractor had ZERO path validation - entries with `../` could escape. Solution: - Add `isPathWithinBase()` using `path.relative()` instead of `startsWith()` - `path.relative("/tmp/foo", "/tmp/foobar")` returns `"../foobar"` which correctly indicates escape - Add filter to tar.x() to validate each entry path Fixes #3277 (partial) [AI-assisted: lightly tested, code understood] --- src/infra/archive.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 35ad4fa04..da3fdd1e0 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -61,6 +61,11 @@ export async function withTimeout( } } +function isPathWithinBase(basePath: string, targetPath: string): boolean { + const rel = path.relative(basePath, targetPath); + return !rel.startsWith("..") && !path.isAbsolute(rel); +} + async function extractZip(params: { archivePath: string; destDir: string }): Promise { const buffer = await fs.readFile(params.archivePath); const zip = await JSZip.loadAsync(buffer); @@ -70,7 +75,7 @@ async function extractZip(params: { archivePath: string; destDir: string }): Pro const entryPath = entry.name.replaceAll("\\", "/"); if (!entryPath || entryPath.endsWith("/")) { const dirPath = path.resolve(params.destDir, entryPath); - if (!dirPath.startsWith(params.destDir)) { + if (!isPathWithinBase(params.destDir, dirPath)) { throw new Error(`zip entry escapes destination: ${entry.name}`); } await fs.mkdir(dirPath, { recursive: true }); @@ -78,7 +83,7 @@ async function extractZip(params: { archivePath: string; destDir: string }): Pro } const outPath = path.resolve(params.destDir, entryPath); - if (!outPath.startsWith(params.destDir)) { + if (!isPathWithinBase(params.destDir, outPath)) { throw new Error(`zip entry escapes destination: ${entry.name}`); } await fs.mkdir(path.dirname(outPath), { recursive: true }); @@ -101,7 +106,14 @@ export async function extractArchive(params: { const label = kind === "zip" ? "extract zip" : "extract tar"; if (kind === "tar") { await withTimeout( - tar.x({ file: params.archivePath, cwd: params.destDir }), + tar.x({ + file: params.archivePath, + cwd: params.destDir, + filter: (entryPath) => { + const absPath = path.resolve(params.destDir, entryPath); + return isPathWithinBase(params.destDir, absPath); + }, + }), params.timeoutMs, label, );