From 6f936e0fdae30ebb1b0486ecc9c7abb4ce873cbd Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sat, 9 Aug 2025 15:22:29 +0800 Subject: [PATCH 01/10] chore: start 1.x branch --- .github/workflows/release.yml | 5 ++--- .gitignore | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c6cbb1..b8d6a72 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,7 @@ on: jobs: release: - name: Node.js - uses: node-modules/github-actions/.github/workflows/node-release.yml@master + name: NPM + uses: node-modules/github-actions/.github/workflows/npm-release.yml@master secrets: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} GIT_TOKEN: ${{ secrets.GIT_TOKEN }} diff --git a/.gitignore b/.gitignore index 5eaf2d1..12a7c04 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ test/fixtures/chinese-path-test.zip .DS_Store yarn.lock !test/fixtures/symlink/node_modules +pnpm-lock.yaml From 41a5eaec29b7110026ddd98f8ba6a3a6fa2f2655 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sat, 9 Aug 2025 15:23:42 +0800 Subject: [PATCH 02/10] chore: typo fix on branch --- .github/workflows/nodejs.yml | 6 +++--- .github/workflows/release.yml | 4 ++-- README.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 214b6c2..626a687 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -1,11 +1,11 @@ -name: CI +name: CI for 1.x on: push: - branches: [ master ] + branches: [ 1.x ] pull_request: - branches: [ master ] + branches: [ 1.x ] jobs: Job: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8d6a72..92b37bd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,8 @@ -name: Release +name: Release for 1.x on: push: - branches: [ master ] + branches: [ 1.x ] jobs: release: diff --git a/README.md b/README.md index e416e0c..b13e082 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# compressing +# compressing@1 [![NPM version][npm-image]][npm-url] [![Test coverage][codecov-image]][codecov-url] @@ -26,7 +26,7 @@ Currently supported: ## Install ```bash -npm install compressing +npm install compressing@1 ``` ## Usage From f2344e748205f2c9583155477473d8bd6f20d821 Mon Sep 17 00:00:00 2001 From: fengmk2 Date: Sat, 9 Aug 2025 15:27:13 +0800 Subject: [PATCH 03/10] chore: support auto merge queue [skip ci] --- .github/workflows/nodejs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 626a687..ad4b978 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -7,6 +7,8 @@ on: pull_request: branches: [ 1.x ] + merge_group: + jobs: Job: name: Node.js From 2368a039532addb7576406932ae6dd0499b035ee Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 21:54:04 +0800 Subject: [PATCH 04/10] chore: add warnning message --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b13e082..c1a55d3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,10 @@ [download-image]: https://img.shields.io/npm/dm/compressing.svg?style=flat-square [download-url]: https://npmjs.org/package/compressing +## ⚠️ Warning + +**Version 1.x is no longer maintained. Please upgrade to version 2.x as soon as possible.** + The missing compressing and uncompressing lib for node. Currently supported: From 01acc469c1b6f9e9c0ee0dbdcb1cf80ff3aa3731 Mon Sep 17 00:00:00 2001 From: MK Date: Tue, 27 Jan 2026 21:55:28 +0800 Subject: [PATCH 05/10] chore: add permissions to auto release --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92b37bd..ea554d7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,13 @@ on: push: branches: [ 1.x ] +permissions: + contents: write + deployments: write + issues: write + pull-requests: write + id-token: write + jobs: release: name: NPM From 8d16c196c7f1888fc1af957d9ff36117247cea6c Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Wed, 28 Jan 2026 10:23:38 +0800 Subject: [PATCH 06/10] fix: prevent arbitrary file write via symlink extraction (#133) Add path traversal and symlink escape protection to prevent malicious TAR/TGZ archives from writing files outside the extraction directory. - Add isPathWithinParent() validation function - Validate all entry paths stay within destination directory - Validate symlink targets don't escape extraction directory - Skip malicious entries with warning messages https://github.com/node-modules/compressing/security/advisories/GHSA-cc8f-xg8v-72m3 pick from https://github.com/node-modules/compressing/commit/ce1c0131c401c071c77d5a1425bf8c88cfc16361 --- lib/utils.js | 37 ++- test/tar/security-GHSA-cc8f-xg8v-72m3.test.js | 231 ++++++++++++++++++ 2 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 test/tar/security-GHSA-cc8f-xg8v-72m3.test.js diff --git a/lib/utils.js b/lib/utils.js index 3fe8df5..7901caa 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -5,6 +5,22 @@ const path = require('path'); const mkdirp = require('mkdirp'); const pump = require('pump'); +/** + * Check if childPath is within parentPath (prevents path traversal attacks) + * @param {string} childPath - The path to check + * @param {string} parentPath - The parent directory path + * @returns {boolean} - True if childPath is within parentPath + */ +function isPathWithinParent(childPath, parentPath) { + const normalizedChild = path.resolve(childPath); + const normalizedParent = path.resolve(parentPath); + const parentWithSep = normalizedParent.endsWith(path.sep) + ? normalizedParent + : normalizedParent + path.sep; + return normalizedChild === normalizedParent || + normalizedChild.startsWith(parentWithSep); +} + // file/fileBuffer/stream exports.sourceType = source => { if (!source) return undefined; @@ -93,6 +109,9 @@ exports.makeUncompressFn = StreamClass => { mkdirp(destDir, err => { if (err) return reject(err); + // Resolve destDir to absolute path for security validation + const resolvedDestDir = path.resolve(destDir); + let entryCount = 0; let successCount = 0; let isFinish = false; @@ -109,7 +128,15 @@ exports.makeUncompressFn = StreamClass => { .on('error', reject) .on('entry', (header, stream, next) => { stream.on('end', next); - const destFilePath = path.join(destDir, header.name); + const destFilePath = path.join(resolvedDestDir, header.name); + const resolvedDestPath = path.resolve(destFilePath); + + // Security: Validate that the entry path doesn't escape the destination directory + if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { + console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`); + stream.resume(); + return; + } if (header.type === 'file') { const dir = path.dirname(destFilePath); @@ -126,6 +153,14 @@ exports.makeUncompressFn = StreamClass => { } else if (header.type === 'symlink') { const dir = path.dirname(destFilePath); const target = path.resolve(dir, header.linkname); + + // Security: Validate that the symlink target doesn't escape the destination directory + if (!isPathWithinParent(target, resolvedDestDir)) { + console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`); + stream.resume(); + return; + } + entryCount++; mkdirp(dir, err => { diff --git a/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js new file mode 100644 index 0000000..eca7e14 --- /dev/null +++ b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js @@ -0,0 +1,231 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const uuid = require('uuid'); +const assert = require('assert'); +const tar = require('tar-stream'); +const compressing = require('../..'); + +describe('test/tar/security-GHSA-cc8f-xg8v-72m3.test.js', () => { + let tempDir; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), uuid.v4()); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + /** + * Helper function to create a TAR buffer with given entries + * @param {Array<{name: string, type?: string, linkname?: string, content?: string}>} entries + * @returns {Promise} + */ + function createTarBuffer(entries) { + return new Promise((resolve, reject) => { + const pack = tar.pack(); + const chunks = []; + + pack.on('data', chunk => chunks.push(chunk)); + pack.on('end', () => resolve(Buffer.concat(chunks))); + pack.on('error', reject); + + for (const entry of entries) { + if (entry.type === 'symlink') { + pack.entry({ name: entry.name, type: 'symlink', linkname: entry.linkname }); + } else if (entry.type === 'directory') { + pack.entry({ name: entry.name, type: 'directory' }); + } else { + pack.entry({ name: entry.name, type: 'file' }, entry.content || ''); + } + } + + pack.finalize(); + }); + } + + describe('symlink escape vulnerability (CVE-2021-32803 style)', () => { + it('should block symlink pointing outside extraction directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'escaped.txt'); + + // Create malicious TAR: + // 1. Symlink "escape" -> ".." (points to parent of dest) + // 2. File "escape/escaped.txt" (would write to tempDir/escaped.txt if symlink was followed) + const tarBuffer = await createTarBuffer([ + { name: 'escape', type: 'symlink', linkname: '..' }, + { name: 'escape/escaped.txt', type: 'file', content: 'malicious content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The escaped file should NOT exist in the parent directory + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written outside destination'); + + // The symlink should NOT exist as a symlink (it may exist as a directory now) + const escapePath = path.join(destDir, 'escape'); + if (fs.existsSync(escapePath)) { + const stat = fs.lstatSync(escapePath); + assert.strictEqual(stat.isSymbolicLink(), false, 'Path should not be a symlink'); + } + }); + + it('should block symlink pointing to absolute path outside extraction directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'poc.txt'); + + // Create malicious TAR with symlink pointing to absolute path + const tarBuffer = await createTarBuffer([ + { name: 'myTmp', type: 'symlink', linkname: tempDir }, + { name: 'myTmp/poc.txt', type: 'file', content: 'malicious content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The escaped file should NOT exist + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written via symlink escape'); + }); + + it('should block symlink with absolute path target like /etc/passwd', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create malicious TAR with symlink pointing to /etc/passwd + const tarBuffer = await createTarBuffer([ + { name: 'passwd', type: 'symlink', linkname: '/etc/passwd' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The symlink should NOT be created + assert.strictEqual(fs.existsSync(path.join(destDir, 'passwd')), false, 'Symlink to /etc/passwd should not be created'); + }); + }); + + describe('path traversal via file entries', () => { + it('should block file entry with ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'traversed.txt'); + + // Create malicious TAR with path traversal + const tarBuffer = await createTarBuffer([ + { name: '../traversed.txt', type: 'file', content: 'malicious' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist outside destination + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not be written via path traversal'); + }); + + it('should block file entry with nested ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedFile = path.join(tempDir, 'nested-escape.txt'); + + // Create malicious TAR with nested path traversal + const tarBuffer = await createTarBuffer([ + { name: 'foo/bar/../../nested-escape.txt', type: 'file', content: 'malicious' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist outside destination + // (Note: The file might be written as dest/nested-escape.txt after normalization, which is acceptable) + assert.strictEqual(fs.existsSync(escapedFile), false, 'File should not escape to parent via nested traversal'); + }); + + it('should block directory entry with ../ path traversal', async () => { + const destDir = path.join(tempDir, 'dest'); + const escapedDir = path.join(tempDir, 'escaped-dir'); + + // Create malicious TAR with directory path traversal + const tarBuffer = await createTarBuffer([ + { name: '../escaped-dir', type: 'directory' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The directory should NOT exist outside destination + assert.strictEqual(fs.existsSync(escapedDir), false, 'Directory should not be created via path traversal'); + }); + }); + + describe('backward compatibility - valid symlinks', () => { + it('should allow valid internal symlinks', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create TAR with valid internal symlink + const tarBuffer = await createTarBuffer([ + { name: 'real-file.txt', type: 'file', content: 'hello world' }, + { name: 'link-to-file.txt', type: 'symlink', linkname: 'real-file.txt' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // Both files should exist + assert.strictEqual(fs.existsSync(path.join(destDir, 'real-file.txt')), true, 'Real file should exist'); + assert.strictEqual(fs.existsSync(path.join(destDir, 'link-to-file.txt')), true, 'Symlink should exist'); + + // Symlink should point to the real file + const linkTarget = fs.readlinkSync(path.join(destDir, 'link-to-file.txt')); + assert.strictEqual(linkTarget, 'real-file.txt', 'Symlink should point to real-file.txt'); + }); + + it('should allow symlinks within subdirectories', async () => { + const destDir = path.join(tempDir, 'dest'); + + // Create TAR with valid symlink in subdirectory + const tarBuffer = await createTarBuffer([ + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file.txt', type: 'file', content: 'content' }, + { name: 'subdir/link.txt', type: 'symlink', linkname: 'file.txt' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // Both should exist + assert.strictEqual(fs.existsSync(path.join(destDir, 'subdir/file.txt')), true); + assert.strictEqual(fs.existsSync(path.join(destDir, 'subdir/link.txt')), true); + }); + + it('should extract symlink.tgz fixture correctly', async () => { + const sourceFile = path.join(__dirname, '..', 'fixtures', 'symlink.tgz'); + const destDir = path.join(tempDir, 'symlink-test'); + + // This should not throw + await compressing.tgz.uncompress(sourceFile, destDir); + + // Verify destination was created + assert.strictEqual(fs.existsSync(destDir), true, 'Destination directory should exist'); + }); + }); + + describe('edge cases', () => { + it('should handle empty TAR', async () => { + const destDir = path.join(tempDir, 'dest'); + const tarBuffer = await createTarBuffer([]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.existsSync(destDir), true, 'Destination should be created'); + }); + + it('should handle normal files correctly', async () => { + const destDir = path.join(tempDir, 'dest'); + + const tarBuffer = await createTarBuffer([ + { name: 'file1.txt', type: 'file', content: 'content1' }, + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file2.txt', type: 'file', content: 'content2' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2'); + }); + }); +}); From 1c1b72583a1fa83b28aa1b49b11bbcfef68b1449 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Wed, 28 Jan 2026 02:24:50 +0000 Subject: [PATCH 07/10] Release 1.10.4 [skip ci] ## 1.10.4 (2026-01-28) * fix: prevent arbitrary file write via symlink extraction (#133) ([8d16c19](https://github.com/node-modules/compressing/commit/8d16c19)), closes [#133](https://github.com/node-modules/compressing/issues/133) * chore: add permissions to auto release ([01acc46](https://github.com/node-modules/compressing/commit/01acc46)) * chore: add warnning message ([2368a03](https://github.com/node-modules/compressing/commit/2368a03)) * chore: start 1.x branch ([6f936e0](https://github.com/node-modules/compressing/commit/6f936e0)) * chore: support auto merge queue ([f2344e7](https://github.com/node-modules/compressing/commit/f2344e7)) * chore: typo fix on branch ([41a5eae](https://github.com/node-modules/compressing/commit/41a5eae)) --- CHANGELOG.md | 9 +++++++++ package.json | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8bb67..1dfa518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.10.4 (2026-01-28) + +* fix: prevent arbitrary file write via symlink extraction (#133) ([8d16c19](https://github.com/node-modules/compressing/commit/8d16c19)), closes [#133](https://github.com/node-modules/compressing/issues/133) +* chore: add permissions to auto release ([01acc46](https://github.com/node-modules/compressing/commit/01acc46)) +* chore: add warnning message ([2368a03](https://github.com/node-modules/compressing/commit/2368a03)) +* chore: start 1.x branch ([6f936e0](https://github.com/node-modules/compressing/commit/6f936e0)) +* chore: support auto merge queue ([f2344e7](https://github.com/node-modules/compressing/commit/f2344e7)) +* chore: typo fix on branch ([41a5eae](https://github.com/node-modules/compressing/commit/41a5eae)) + ## [1.10.3](https://github.com/node-modules/compressing/compare/v1.10.2...v1.10.3) (2025-05-24) diff --git a/package.json b/package.json index 8e5a657..57a319a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "compressing", - "version": "1.10.3", + "version": "1.10.4", "description": "Everything you need for compressing and uncompressing", "main": "index.js", "scripts": { From 18def2356bb9a061a5638014203caaca61e8ed2a Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 13 Apr 2026 21:43:59 +0800 Subject: [PATCH 08/10] fix: prevent symlink path traversal via pre-existing symlinks during tar extraction Add isRealPathSafe() that walks each path segment using lstat to detect when a pre-existing symlink on disk would cause a file write outside the extraction directory. This mitigates GHSA-4c3q-x735-j3r5 where a crafted tar with ordered entries (symlink then file through that symlink) could escape the destination. Also extracts createTarBuffer helper to shared test/util.js. Closes GHSA-4c3q-x735-j3r5 --- lib/utils.js | 206 +++++++++++------ test/tar/security-GHSA-4c3q-x735-j3r5.test.js | 209 ++++++++++++++++++ test/tar/security-GHSA-cc8f-xg8v-72m3.test.js | 30 +-- test/util.js | 30 +++ 4 files changed, 384 insertions(+), 91 deletions(-) create mode 100644 test/tar/security-GHSA-4c3q-x735-j3r5.test.js diff --git a/lib/utils.js b/lib/utils.js index 7901caa..72c84ab 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -21,6 +21,63 @@ function isPathWithinParent(childPath, parentPath) { normalizedChild.startsWith(parentWithSep); } +/** + * Check if the real filesystem path stays within parentDir, + * accounting for pre-existing symlinks on disk. + * Walks each path segment from parentDir to targetPath using lstat. + * If any segment is a symlink, resolves it and verifies it stays within parentDir. + * @param {string} targetPath - Absolute path to validate + * @param {string} parentDir - Absolute path of the extraction root + * @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS) + * @param {function} callback - callback(err, safe) + */ +function isRealPathSafe(targetPath, parentDir, realParentDir, callback) { + function isWithinParent(p) { + return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir); + } + + var relative = path.relative(parentDir, targetPath); + var segments = relative.split(path.sep); + var i = 0; + var current = parentDir; + + function checkNext() { + if (i >= segments.length) return callback(null, true); + var segment = segments[i++]; + if (!segment || segment === '.') return checkNext(); + + current = path.join(current, segment); + fs.lstat(current, function(err, stat) { + if (err) { + if (err.code === 'ENOENT') return callback(null, true); // doesn't exist yet, safe + // Fail closed: unexpected filesystem errors are unsafe + return callback(null, false); + } + if (!stat.isSymbolicLink()) return checkNext(); + + fs.realpath(current, function(err, resolved) { + if (err) { + if (err.code === 'ENOENT') { + // Dangling symlink - check textual target + return fs.readlink(current, function(err, linkTarget) { + if (err) return callback(null, false); + var absTarget = path.resolve(path.dirname(current), linkTarget); + callback(null, isWithinParent(absTarget)); + }); + } + // Fail closed: unexpected errors during symlink resolution are unsafe + return callback(null, false); + } + if (!isWithinParent(resolved)) return callback(null, false); + current = resolved; + checkNext(); + }); + }); + } + + checkNext(); +} + // file/fileBuffer/stream exports.sourceType = source => { if (!source) return undefined; @@ -112,74 +169,99 @@ exports.makeUncompressFn = StreamClass => { // Resolve destDir to absolute path for security validation const resolvedDestDir = path.resolve(destDir); - let entryCount = 0; - let successCount = 0; - let isFinish = false; - function done() { - // resolve when both stream finish and file write finish - if (isFinish && entryCount === successCount) resolve(); - } - - new StreamClass(opts) - .on('finish', () => { - isFinish = true; - done(); - }) - .on('error', reject) - .on('entry', (header, stream, next) => { - stream.on('end', next); - const destFilePath = path.join(resolvedDestDir, header.name); - const resolvedDestPath = path.resolve(destFilePath); - - // Security: Validate that the entry path doesn't escape the destination directory - if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { - console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`); - stream.resume(); - return; - } - - if (header.type === 'file') { - const dir = path.dirname(destFilePath); - mkdirp(dir, err => { - if (err) return reject(err); - - entryCount++; - pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { - if (err) return reject(err); - successCount++; - done(); - }); - }); - } else if (header.type === 'symlink') { - const dir = path.dirname(destFilePath); - const target = path.resolve(dir, header.linkname); - - // Security: Validate that the symlink target doesn't escape the destination directory - if (!isPathWithinParent(target, resolvedDestDir)) { - console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`); + // Resolve once for the entire extraction to handle OS-level symlinks + // (e.g. /var -> /private/var on macOS) + let realDestDir = resolvedDestDir; + fs.realpath(resolvedDestDir, (err, resolved) => { + if (!err) realDestDir = resolved; + + let entryCount = 0; + let successCount = 0; + let isFinish = false; + function done() { + // resolve when both stream finish and file write finish + if (isFinish && entryCount === successCount) resolve(); + } + + new StreamClass(opts) + .on('finish', () => { + isFinish = true; + done(); + }) + .on('error', reject) + .on('entry', (header, stream, next) => { + stream.on('end', next); + const destFilePath = path.join(resolvedDestDir, header.name); + const resolvedDestPath = path.resolve(destFilePath); + + // Security: Validate that the entry path doesn't escape the destination directory + if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) { + console.warn('[compressing] Skipping entry with path traversal: "' + header.name + '" -> "' + resolvedDestPath + '"'); stream.resume(); return; } - entryCount++; - - mkdirp(dir, err => { - if (err) return reject(err); - - const relativeTarget = path.relative(dir, target); - fs.symlink(relativeTarget, destFilePath, err => { - if (err) return reject(err); - successCount++; + // Security: Validate no pre-existing symlink in the path escapes the extraction directory + isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir, (err, safe) => { + if (err || !safe) { + console.warn('[compressing] Skipping entry "' + header.name + '": a symlink in its path resolves outside the extraction directory'); stream.resume(); - }); - }); - } else { // directory - mkdirp(destFilePath, err => { - if (err) return reject(err); - stream.resume(); + return; + } + + if (header.type === 'file') { + const dir = path.dirname(destFilePath); + mkdirp(dir, err => { + if (err) return reject(err); + + entryCount++; + pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => { + if (err) return reject(err); + successCount++; + done(); + }); + }); + } else if (header.type === 'symlink') { + const dir = path.dirname(destFilePath); + const target = path.resolve(dir, header.linkname); + + // Security: Validate that the symlink target doesn't escape the destination directory + if (!isPathWithinParent(target, resolvedDestDir)) { + console.warn('[compressing] Skipping symlink "' + header.name + '": target "' + target + '" escapes extraction directory'); + stream.resume(); + return; + } + + // Security: Validate no pre-existing symlink in the target path escapes the extraction directory + isRealPathSafe(target, resolvedDestDir, realDestDir, (err, targetSafe) => { + if (err || !targetSafe) { + console.warn('[compressing] Skipping symlink "' + header.name + '": target resolves outside extraction directory via existing symlink'); + stream.resume(); + return; + } + + entryCount++; + + mkdirp(dir, err => { + if (err) return reject(err); + + const relativeTarget = path.relative(dir, target); + fs.symlink(relativeTarget, destFilePath, err => { + if (err) return reject(err); + successCount++; + stream.resume(); + }); + }); + }); + } else { // directory + mkdirp(destFilePath, err => { + if (err) return reject(err); + stream.resume(); + }); + } }); - } - }); + }); + }); }); }); }; diff --git a/test/tar/security-GHSA-4c3q-x735-j3r5.test.js b/test/tar/security-GHSA-4c3q-x735-j3r5.test.js new file mode 100644 index 0000000..86b19f9 --- /dev/null +++ b/test/tar/security-GHSA-4c3q-x735-j3r5.test.js @@ -0,0 +1,209 @@ +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const zlib = require('zlib'); +const uuid = require('uuid'); +const assert = require('assert'); +const compressing = require('../..'); +const { createTarBuffer } = require('../util'); + +describe('test/tar/security-GHSA-4c3q-x735-j3r5.test.js', () => { + let tempDir; + + beforeEach(() => { + tempDir = path.join(os.tmpdir(), uuid.v4()); + fs.mkdirSync(tempDir, { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function gzipBuffer(buf) { + return new Promise((resolve, reject) => { + zlib.gzip(buf, (err, result) => { + if (err) return reject(err); + resolve(result); + }); + }); + } + + describe('pre-existing symlink file pointing outside destDir', () => { + it('should block file write through pre-existing symlink to external file', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + const sensitiveFile = path.join(outsideDir, 'target.txt'); + + // Setup: create the sensitive file and a pre-existing symlink in destDir + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); + + // Create a tar with a regular file entry matching the symlink name + const tarBuffer = await createTarBuffer([ + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The sensitive file should NOT have been overwritten + assert.strictEqual( + fs.readFileSync(sensitiveFile, 'utf8'), + 'ORIGINAL_SAFE_CONTENT', + 'Sensitive file should not be overwritten through pre-existing symlink' + ); + }); + }); + + describe('pre-existing symlink directory pointing outside destDir', () => { + it('should block file write through pre-existing symlink directory', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup: create outside dir and a symlink directory in destDir + fs.mkdirSync(outsideDir, { recursive: true }); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'subdir')); + + // Create a tar with a file inside the symlink directory + const tarBuffer = await createTarBuffer([ + { name: 'subdir/secret.txt', type: 'file', content: 'MALICIOUS_DATA' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'secret.txt')), + false, + 'File should not be written through pre-existing symlink directory' + ); + }); + }); + + describe('deeply nested pre-existing symlink', () => { + it('should block file write through nested symlink escape', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup: create real directories and a symlink deep in the tree + fs.mkdirSync(path.join(destDir, 'a', 'b'), { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'a', 'b', 'c')); + + // Create a tar with a file through the deep symlink + const tarBuffer = await createTarBuffer([ + { name: 'a/b/c/file.txt', type: 'file', content: 'ESCAPED_DATA' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'file.txt')), + false, + 'File should not be written through deeply nested symlink' + ); + }); + }); + + describe('pre-existing symlink pointing within destDir (should be allowed)', () => { + it('should allow file write through symlink that stays within destDir', async () => { + const destDir = path.join(tempDir, 'dest'); + const realDir = path.join(destDir, 'real'); + + // Setup: create real directory and internal symlink + fs.mkdirSync(realDir, { recursive: true }); + fs.symlinkSync(realDir, path.join(destDir, 'link')); + + // Create a tar with a file through the internal symlink + const tarBuffer = await createTarBuffer([ + { name: 'link/newfile.txt', type: 'file', content: 'safe content' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The file SHOULD exist since the symlink points within destDir + assert.strictEqual( + fs.readFileSync(path.join(realDir, 'newfile.txt'), 'utf8'), + 'safe content', + 'File should be written through internal symlink' + ); + }); + }); + + describe('directory entry through pre-existing external symlink', () => { + it('should block directory creation through pre-existing symlink', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + + // Setup + fs.mkdirSync(outsideDir, { recursive: true }); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(destDir, 'escape')); + + // Create a tar with a directory entry through the symlink + const tarBuffer = await createTarBuffer([ + { name: 'escape/newdir/', type: 'directory' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + // The directory should NOT exist in the outside directory + assert.strictEqual( + fs.existsSync(path.join(outsideDir, 'newdir')), + false, + 'Directory should not be created through pre-existing symlink' + ); + }); + }); + + describe('tgz format shares the same protection', () => { + it('should block file write through pre-existing symlink in tgz extraction', async () => { + const destDir = path.join(tempDir, 'dest'); + const outsideDir = path.join(tempDir, 'outside'); + const sensitiveFile = path.join(outsideDir, 'target.txt'); + + // Setup + fs.mkdirSync(outsideDir, { recursive: true }); + fs.writeFileSync(sensitiveFile, 'ORIGINAL_SAFE_CONTENT'); + fs.mkdirSync(destDir, { recursive: true }); + fs.symlinkSync(sensitiveFile, path.join(destDir, 'config_file')); + + // Create a tgz buffer + const tarBuffer = await createTarBuffer([ + { name: 'config_file', type: 'file', content: 'MALICIOUS_OVERWRITE' }, + ]); + const tgzBuffer = await gzipBuffer(tarBuffer); + + await compressing.tgz.uncompress(tgzBuffer, destDir); + + // The sensitive file should NOT have been overwritten + assert.strictEqual( + fs.readFileSync(sensitiveFile, 'utf8'), + 'ORIGINAL_SAFE_CONTENT', + 'TGZ: Sensitive file should not be overwritten through pre-existing symlink' + ); + }); + }); + + describe('normal extraction still works (regression)', () => { + it('should extract files normally when no pre-existing symlinks', async () => { + const destDir = path.join(tempDir, 'dest'); + + const tarBuffer = await createTarBuffer([ + { name: 'file1.txt', type: 'file', content: 'content1' }, + { name: 'subdir/', type: 'directory' }, + { name: 'subdir/file2.txt', type: 'file', content: 'content2' }, + ]); + + await compressing.tar.uncompress(tarBuffer, destDir); + + assert.strictEqual(fs.readFileSync(path.join(destDir, 'file1.txt'), 'utf8'), 'content1'); + assert.strictEqual(fs.readFileSync(path.join(destDir, 'subdir/file2.txt'), 'utf8'), 'content2'); + }); + }); +}); diff --git a/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js index eca7e14..8c34347 100644 --- a/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js +++ b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js @@ -5,8 +5,8 @@ const os = require('os'); const path = require('path'); const uuid = require('uuid'); const assert = require('assert'); -const tar = require('tar-stream'); const compressing = require('../..'); +const { createTarBuffer } = require('../util'); describe('test/tar/security-GHSA-cc8f-xg8v-72m3.test.js', () => { let tempDir; @@ -20,34 +20,6 @@ describe('test/tar/security-GHSA-cc8f-xg8v-72m3.test.js', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - /** - * Helper function to create a TAR buffer with given entries - * @param {Array<{name: string, type?: string, linkname?: string, content?: string}>} entries - * @returns {Promise} - */ - function createTarBuffer(entries) { - return new Promise((resolve, reject) => { - const pack = tar.pack(); - const chunks = []; - - pack.on('data', chunk => chunks.push(chunk)); - pack.on('end', () => resolve(Buffer.concat(chunks))); - pack.on('error', reject); - - for (const entry of entries) { - if (entry.type === 'symlink') { - pack.entry({ name: entry.name, type: 'symlink', linkname: entry.linkname }); - } else if (entry.type === 'directory') { - pack.entry({ name: entry.name, type: 'directory' }); - } else { - pack.entry({ name: entry.name, type: 'file' }, entry.content || ''); - } - } - - pack.finalize(); - }); - } - describe('symlink escape vulnerability (CVE-2021-32803 style)', () => { it('should block symlink pointing outside extraction directory', async () => { const destDir = path.join(tempDir, 'dest'); diff --git a/test/util.js b/test/util.js index cdb84c6..2c26f10 100644 --- a/test/util.js +++ b/test/util.js @@ -1,5 +1,6 @@ const stream = require('stream'); const pump = require('pump'); +const tar = require('tar-stream'); // impl promise pipeline on Node.js 14 const pipelinePromise = stream.promises?.pipeline ?? function pipeline(...args) { @@ -11,4 +12,33 @@ const pipelinePromise = stream.promises?.pipeline ?? function pipeline(...args) }); }; +/** + * Create a TAR buffer with given entries + * @param {Array<{name: string, type?: string, linkname?: string, content?: string}>} entries + * @returns {Promise} + */ +function createTarBuffer(entries) { + return new Promise((resolve, reject) => { + const pack = tar.pack(); + const chunks = []; + + pack.on('data', chunk => chunks.push(chunk)); + pack.on('end', () => resolve(Buffer.concat(chunks))); + pack.on('error', reject); + + for (const entry of entries) { + if (entry.type === 'symlink') { + pack.entry({ name: entry.name, type: 'symlink', linkname: entry.linkname }); + } else if (entry.type === 'directory') { + pack.entry({ name: entry.name, type: 'directory' }); + } else { + pack.entry({ name: entry.name, type: 'file' }, entry.content || ''); + } + } + + pack.finalize(); + }); +} + exports.pipelinePromise = pipelinePromise; +exports.createTarBuffer = createTarBuffer; From 40d5f1fb77b49927b3b07add69510539c7844be5 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Mon, 13 Apr 2026 13:56:27 +0000 Subject: [PATCH 09/10] Release 1.10.5 [skip ci] ## 1.10.5 (2026-04-13) * fix: prevent symlink path traversal via pre-existing symlinks during tar extraction ([18def23](https://github.com/node-modules/compressing/commit/18def23)) --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dfa518..0ff606f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 1.10.5 (2026-04-13) + +* fix: prevent symlink path traversal via pre-existing symlinks during tar extraction ([18def23](https://github.com/node-modules/compressing/commit/18def23)) + ## 1.10.4 (2026-01-28) * fix: prevent arbitrary file write via symlink extraction (#133) ([8d16c19](https://github.com/node-modules/compressing/commit/8d16c19)), closes [#133](https://github.com/node-modules/compressing/issues/133) diff --git a/package.json b/package.json index 57a319a..15996da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "compressing", - "version": "1.10.4", + "version": "1.10.5", "description": "Everything you need for compressing and uncompressing", "main": "index.js", "scripts": { From 60fa3af965d877079064cab1ca4aab0a2f475d46 Mon Sep 17 00:00:00 2001 From: "MK (fengmk2)" Date: Mon, 13 Apr 2026 22:17:50 +0800 Subject: [PATCH 10/10] chore: replace var with let/const in isRealPathSafe (#135) ## Summary - Replace `var` with `let`/`const` in `isRealPathSafe()` in `lib/utils.js` to fix 6 eslint `no-var` errors introduced by the GHSA-4c3q-x735-j3r5 fix. ## Test plan - [x] `npm run lint` passes with 0 errors --- lib/utils.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/utils.js b/lib/utils.js index 72c84ab..38974ae 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -36,14 +36,14 @@ function isRealPathSafe(targetPath, parentDir, realParentDir, callback) { return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir); } - var relative = path.relative(parentDir, targetPath); - var segments = relative.split(path.sep); - var i = 0; - var current = parentDir; + const relative = path.relative(parentDir, targetPath); + const segments = relative.split(path.sep); + let i = 0; + let current = parentDir; function checkNext() { if (i >= segments.length) return callback(null, true); - var segment = segments[i++]; + const segment = segments[i++]; if (!segment || segment === '.') return checkNext(); current = path.join(current, segment); @@ -61,7 +61,7 @@ function isRealPathSafe(targetPath, parentDir, realParentDir, callback) { // Dangling symlink - check textual target return fs.readlink(current, function(err, linkTarget) { if (err) return callback(null, false); - var absTarget = path.resolve(path.dirname(current), linkTarget); + const absTarget = path.resolve(path.dirname(current), linkTarget); callback(null, isWithinParent(absTarget)); }); }