diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml
index 214b6c2..ad4b978 100644
--- a/.github/workflows/nodejs.yml
+++ b/.github/workflows/nodejs.yml
@@ -1,11 +1,13 @@
-name: CI
+name: CI for 1.x
on:
push:
- branches: [ master ]
+ branches: [ 1.x ]
pull_request:
- branches: [ master ]
+ branches: [ 1.x ]
+
+ merge_group:
jobs:
Job:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 1c6cbb1..ea554d7 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,13 +1,19 @@
-name: Release
+name: Release for 1.x
on:
push:
- branches: [ master ]
+ branches: [ 1.x ]
+
+permissions:
+ contents: write
+ deployments: write
+ issues: write
+ pull-requests: write
+ id-token: write
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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b8bb67..0ff606f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# 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)
+* 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/README.md b/README.md
index e416e0c..c1a55d3 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]
@@ -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:
@@ -26,7 +30,7 @@ Currently supported:
## Install
```bash
-npm install compressing
+npm install compressing@1
```
## Usage
diff --git a/lib/utils.js b/lib/utils.js
index 3fe8df5..38974ae 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -5,6 +5,79 @@ 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);
+}
+
+/**
+ * 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);
+ }
+
+ 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);
+ const 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);
+ const 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;
@@ -93,58 +166,102 @@ exports.makeUncompressFn = StreamClass => {
mkdirp(destDir, err => {
if (err) return reject(err);
- 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();
- }
+ // Resolve destDir to absolute path for security validation
+ const resolvedDestDir = path.resolve(destDir);
+
+ // 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;
+ }
- new StreamClass(opts)
- .on('finish', () => {
- isFinish = true;
- done();
- })
- .on('error', reject)
- .on('entry', (header, stream, next) => {
- stream.on('end', next);
- const destFilePath = path.join(destDir, header.name);
-
- 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);
- 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/package.json b/package.json
index 8e5a657..15996da 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "compressing",
- "version": "1.10.3",
+ "version": "1.10.5",
"description": "Everything you need for compressing and uncompressing",
"main": "index.js",
"scripts": {
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
new file mode 100644
index 0000000..8c34347
--- /dev/null
+++ b/test/tar/security-GHSA-cc8f-xg8v-72m3.test.js
@@ -0,0 +1,203 @@
+'use strict';
+
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const uuid = require('uuid');
+const assert = require('assert');
+const compressing = require('../..');
+const { createTarBuffer } = require('../util');
+
+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 });
+ });
+
+ 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');
+ });
+ });
+});
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;