From 51df0866f63bbf61f36256663549a9d872073d45 Mon Sep 17 00:00:00 2001 From: gonzaloriestra <14979109+gonzaloriestra@users.noreply.github.com> Date: Thu, 21 May 2026 00:21:38 +0000 Subject: [PATCH] [Security] Harden archiver against Zip Slip traversal This change prevents files outside the input directory from being included in archives when using `zip` or `brotliCompress`. It validates that all files matched by glob patterns are subpaths of the intended input directory. - Filter paths using `isSubpath` in `zip` and `brotliCompress`. - Add a safety check in `archiveFile` helper. - Add regression test in `archiver.integration.test.ts`. --- .../public/node/archiver.integration.test.ts | 24 +++++++++++++++++++ packages/cli-kit/src/public/node/archiver.ts | 13 +++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/cli-kit/src/public/node/archiver.integration.test.ts b/packages/cli-kit/src/public/node/archiver.integration.test.ts index 892cb055e99..3d54d647bc0 100644 --- a/packages/cli-kit/src/public/node/archiver.integration.test.ts +++ b/packages/cli-kit/src/public/node/archiver.integration.test.ts @@ -58,6 +58,30 @@ describe('zip', () => { expect(expectedEntries.sort()).toEqual(archiveEntries.sort()) }) }) + + test('prevents zipping files outside the input directory', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const zipPath = joinPath(tmpDir, 'output.zip') + const inputDirectory = joinPath(tmpDir, 'input') + const outsideFile = joinPath(tmpDir, 'outside.txt') + await mkdir(inputDirectory) + await touchFile(outsideFile) + fs.writeFileSync(outsideFile, 'outside content') + + // When + await zip({ + inputDirectory, + outputZipPath: zipPath, + matchFilePattern: '../outside.txt', + }) + + // Then + const archiveEntries = await readArchiveFiles(zipPath) + expect(archiveEntries).not.toContain('../outside.txt') + expect(archiveEntries).not.toContain('outside.txt') + }) + }) }) describe('brotliCompress', () => { diff --git a/packages/cli-kit/src/public/node/archiver.ts b/packages/cli-kit/src/public/node/archiver.ts index 35d781a2f75..c9fa3a4295a 100644 --- a/packages/cli-kit/src/public/node/archiver.ts +++ b/packages/cli-kit/src/public/node/archiver.ts @@ -1,6 +1,7 @@ import {outputDebug, outputContent, outputToken} from './output.js' import {glob, removeFile} from './fs.js' -import {relativePath, joinPath, dirname} from './path.js' +import {relativePath, joinPath, dirname, isSubpath} from './path.js' +import {AbortError} from './error.js' import archiver from 'archiver' import {createWriteStream, readFileSync, writeFileSync} from 'fs' import {readFile} from 'fs/promises' @@ -35,13 +36,15 @@ interface ZipOptions { export async function zip(options: ZipOptions): Promise { const {inputDirectory, outputZipPath, matchFilePattern = '**/*'} = options outputDebug(outputContent`Zipping ${outputToken.path(inputDirectory)} into ${outputToken.path(outputZipPath)}`) - const pathsToZip = await glob(matchFilePattern, { + let pathsToZip = await glob(matchFilePattern, { cwd: inputDirectory, absolute: true, dot: true, followSymbolicLinks: false, }) + pathsToZip = pathsToZip.filter((filePath) => isSubpath(inputDirectory, filePath)) + return new Promise((resolve, reject) => { const archive = archiver('zip') @@ -96,6 +99,9 @@ function collectParentDirectories(fileRelativePath: string, accumulator: Set { + if (!isSubpath(inputDirectory, filePath)) { + throw new AbortError(outputContent`Skipped archiving of file ${outputToken.path(filePath)} outside the directory.`) + } const fileRelativePath = relativePath(inputDirectory, filePath) if (!filePath || !fileRelativePath) return @@ -169,8 +175,9 @@ export async function brotliCompress(options: BrotliOptions): Promise { followSymbolicLinks: false, }) .then(async (pathsToZip) => { + const filteredPaths = pathsToZip.filter((filePath) => isSubpath(options.inputDirectory, filePath)) // Read all files immediately to prevent ENOENT errors during race conditions - const addFilesPromises = pathsToZip.map(async (filePath) => { + const addFilesPromises = filteredPaths.map(async (filePath) => { await archiveFile(options.inputDirectory, filePath, archive) })