Compressing Vulnerable to Arbitrary File Write via Symlink Extraction
Description
Compressing is a compressing and uncompressing lib for node. In version 2.0.0 and 1.10.3 and prior, Compressing extracts TAR archives while restoring symbolic links without validating their targets. By embedding symlinks that resolve outside the intended extraction directory, an attacker can cause subsequent file entries to be written to arbitrary locations on the host file system. Depending on the extractor’s handling of existing files, this behavior may allow overwriting sensitive files or creating new files in security-critical locations. This issue has been patched in versions 1.10.4 and 2.0.1.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
compressingnpm | >= 2.0.0, < 2.0.1 | 2.0.1 |
compressingnpm | < 1.10.4 | 1.10.4 |
Affected products
1- Range: = 2.0.0
Patches
28d16c196c7f1fix: prevent arbitrary file write via symlink extraction (#133)
2 files changed · +267 −1
lib/utils.js+36 −1 modified@@ -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 => {
test/tar/security-GHSA-cc8f-xg8v-72m3.test.js+231 −0 added@@ -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<Buffer>} + */ + 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'); + }); + }); +});
ce1c0131c401Merge commit from fork
2 files changed · +267 −1
lib/utils.js+36 −1 modified@@ -4,6 +4,22 @@ const fs = require('fs'); const path = require('path'); const { pipeline: pump } = require('stream'); +/** + * 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; @@ -92,6 +108,9 @@ exports.makeUncompressFn = StreamClass => { fs.mkdir(destDir, { recursive: true }, 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; @@ -108,7 +127,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); @@ -125,6 +152,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++; fs.mkdir(dir, { recursive: true }, err => {
test/tar/security-GHSA-cc8f-xg8v-72m3.test.js+231 −0 added@@ -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<Buffer>} + */ + 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'); + }); + }); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-cc8f-xg8v-72m3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-24884ghsaADVISORY
- github.com/node-modules/compressing/commit/8d16c196c7f1888fc1af957d9ff36117247cea6cghsax_refsource_MISCWEB
- github.com/node-modules/compressing/commit/ce1c0131c401c071c77d5a1425bf8c88cfc16361ghsax_refsource_MISCWEB
- github.com/node-modules/compressing/security/advisories/GHSA-cc8f-xg8v-72m3ghsax_refsource_CONFIRMWEB
News mentions
1- LMDeploy CVE-2026-33626 Flaw Exploited Within 13 Hours of DisclosureThe Hacker News · Apr 24, 2026