Arbitrary File Creation/Overwrite via insufficient symlink protection due to directory cache poisoning using symbolic links
Description
The npm package "tar" (aka node-tar) before versions 4.4.18, 5.0.10, and 6.1.9 has an arbitrary file creation/overwrite and arbitrary code execution vulnerability. node-tar aims to guarantee that any file whose location would be modified by a symbolic link is not extracted. This is, in part, achieved by ensuring that extracted directories are not symlinks. Additionally, in order to prevent unnecessary stat calls to determine whether a given path is a directory, paths are cached when directories are created. This logic was insufficient when extracting tar files that contained both a directory and a symlink with names containing unicode values that normalized to the same value. Additionally, on Windows systems, long path portions would resolve to the same file system entities as their 8.3 "short path" counterparts. A specially crafted tar archive could thus include a directory with one form of the path, followed by a symbolic link with a different string that resolves to the same file system entity, followed by a file using the first form. By first creating a directory, and then replacing that directory with a symlink that had a different apparent name that resolved to the same entry in the filesystem, it was thus possible to bypass node-tar symlink checks on directories, essentially allowing an untrusted tar file to symlink into an arbitrary location and subsequently extracting arbitrary files into that location, thus allowing arbitrary file creation and overwrite. These issues were addressed in releases 4.4.18, 5.0.10 and 6.1.9. The v3 branch of node-tar has been deprecated and did not receive patches for these issues. If you are still using a v3 release we recommend you update to a more recent version of node-tar. If this is not possible, a workaround is available in the referenced GHSA-qq89-hq3f-393p.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
The npm package 'tar' (node-tar) has an arbitrary file creation/overwrite vulnerability due to insufficient symlink checks when extracting archives with unicode-normalized paths.
Vulnerability
The npm package tar (node-tar) versions prior to 4.4.18, 5.0.10, and 6.1.9 contain an arbitrary file creation/overwrite vulnerability that can lead to arbitrary code execution. The vulnerability stems from insufficient symlink-checking logic when extracting tar archives containing both a directory and a symlink whose names contain Unicode values that normalize to the same string. Additionally, on Windows systems, long path portions can resolve to the same file system entities as their 8.3 "short path" counterparts. A specially crafted tar file can include a directory with one form of the path, then a symbolic link with a different string that resolves to the same file system entry, followed by a file using the first form. By first creating a directory and then replacing it with a symlink having a different apparent name that resolves to the same filesystem entry, the symlink checks on directories are bypassed [1] [2].
Exploitation
An attacker needs to supply a maliciously crafted tar archive to a system using a vulnerable version of node-tar (v3, v4.x <4.4.18, v5.x <5.0.10, or v6.x <6.1.9). The attacker can craft a tar file that includes a directory entry with a path using a particular Unicode form, followed by a symlink entry with a path that normalizes to the same filesystem entity (e.g., different Unicode normalization or a short path alias on Windows). Finally, a file entry with the original path form is included. During extraction, the directory is created first, then replaced by the symlink. Because the path cache does not account for these equivalences, subsequent extraction of the file entry proceeds without detecting the symlink replacement, allowing the file to be written through the symlink [1] [2] [3] [4].
Impact
Successful exploitation allows an attacker to create or overwrite arbitrary files on the filesystem, potentially leading to code execution. The attacker can write files outside the extraction target directory, bypassing node-tar's path traversal protections. On Windows, the short-path aliasing further expands the attack surface. The vulnerability can be used to overwrite configuration files, libraries, or executables, escalating to remote code execution in contexts such as package installation or archive extraction [1] [2].
Mitigation
Fixed versions are 4.4.18, 5.0.10, and 6.1.9 of node-tar, released August 2021. Users should upgrade to these versions or later. The v3 branch is deprecated and did not receive a patch; users still on v3 must upgrade to a supported branch. If upgrading is not possible, a workaround is available in the referenced security advisory GHSA-qq89-hq3f-393p [1] [2].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
tarnpm | >= 3.0.0, < 4.4.18 | 4.4.18 |
tarnpm | >= 5.0.0, < 5.0.10 | 5.0.10 |
tarnpm | >= 6.0.0, < 6.1.9 | 6.1.9 |
Affected products
15- ghsa-coords14 versionspkg:npm/tarpkg:rpm/almalinux/nodejspkg:rpm/almalinux/nodejs-develpkg:rpm/almalinux/nodejs-docspkg:rpm/almalinux/nodejs-full-i18npkg:rpm/almalinux/nodejs-nodemonpkg:rpm/almalinux/nodejs-packagingpkg:rpm/almalinux/npmpkg:rpm/suse/nodejs12&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2012pkg:rpm/suse/nodejs12&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP2pkg:rpm/suse/nodejs12&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP3pkg:rpm/suse/nodejs14&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2012pkg:rpm/suse/nodejs14&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP2pkg:rpm/suse/nodejs14&distro=SUSE%20Linux%20Enterprise%20Module%20for%20Web%20and%20Scripting%2015%20SP3
>= 3.0.0, < 4.4.18+ 13 more
- (no CPE)range: >= 3.0.0, < 4.4.18
- (no CPE)range: < 1:14.18.2-2.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 1:14.18.2-2.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 1:14.18.2-2.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 1:14.18.2-2.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 2.0.15-1.module_el8.6.0+2904+f21ad6f4
- (no CPE)range: < 23-3.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 1:6.14.15-1.14.18.2.2.module_el8.5.0+2618+8d46dafd
- (no CPE)range: < 12.22.9-1.38.1
- (no CPE)range: < 12.22.7-4.22.1
- (no CPE)range: < 12.22.7-4.22.1
- (no CPE)range: < 14.18.1-6.18.2
- (no CPE)range: < 14.18.1-15.21.2
- (no CPE)range: < 14.18.1-15.21.2
- npm/node-tarv5Range: < 4.4.18
Patches
6b6162c7fafe7fix: reserve paths properly for unicode, windows
2 files changed · +118 −5
lib/path-reservations.js+26 −5 modified@@ -8,8 +8,12 @@ const assert = require('assert') const normPath = require('./normalize-windows-path.js') +const stripSlashes = require('./strip-trailing-slashes.js') const { join } = require('path') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' + module.exports = () => { // path => [function or Set] // A Set object means a directory reservation @@ -20,10 +24,16 @@ module.exports = () => { const reservations = new Map() // return a set of parent dirs for a given path - const getDirs = path => - path.split('/').slice(0, -1).reduce((set, path) => - set.length ? set.concat(normPath(join(set[set.length - 1], path))) - : [path], []) + // '/a/b/c/d' -> ['/', '/a', '/a/b', '/a/b/c', '/a/b/c/d'] + const getDirs = path => { + const dirs = path.split('/').slice(0, -1).reduce((set, path) => { + if (set.length) + path = normPath(join(set[set.length - 1], path)) + set.push(path || '/') + return set + }, []) + return dirs + } // functions currently running const running = new Set() @@ -99,7 +109,18 @@ module.exports = () => { } const reserve = (paths, fn) => { - paths = paths.map(p => normPath(join(p)).toLowerCase()) + // collide on matches across case and unicode normalization + // On windows, thanks to the magic of 8.3 shortnames, it is fundamentally + // impossible to determine whether two paths refer to the same thing on + // disk, without asking the kernel for a shortname. + // So, we just pretend that every path matches every other path here, + // effectively removing all parallelization on windows. + paths = isWindows ? ['win32 parallelization disabled'] : paths.map(p => { + return stripSlashes(normPath(join(p))) + .normalize('NFKD') + .toLowerCase() + }) + const dirs = new Set( paths.map(path => getDirs(path)).reduce((a, b) => a.concat(b)) )
test/path-reservations.js+92 −0 modified@@ -1,5 +1,13 @@ const t = require('tap') + +// load up the posix and windows versions of the reserver +if (process.platform === 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'posix' const { reserve } = require('../lib/path-reservations.js')() +delete process.env.TESTING_TAR_FAKE_PLATFORM +if (process.platform !== 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' +const { reserve: winReserve } = t.mock('../lib/path-reservations.js')() t.test('basic race', t => { // simulate the race conditions we care about @@ -54,3 +62,87 @@ t.test('basic race', t => { t.notOk(reserve(['a/b'], dir2), 'dir2 waits') t.notOk(reserve(['a/b/x'], dir3), 'dir3 waits') }) + +t.test('unicode shenanigans', t => { + const e1 = Buffer.from([0xc3, 0xa9]) + const e2 = Buffer.from([0x65, 0xcc, 0x81]) + let didCafe1 = false + const cafe1 = done => { + t.equal(didCafe1, false, 'did cafe1 only once') + t.equal(didCafe2, false, 'did cafe1 before cafe2') + didCafe1 = true + setTimeout(done) + } + let didCafe2 = false + const cafe2 = done => { + t.equal(didCafe1, true, 'did cafe1 before cafe2') + t.equal(didCafe2, false, 'did cafe2 only once') + didCafe2 = true + done() + t.end() + } + const cafePath1 = `c/a/f/${e1}` + const cafePath2 = `c/a/f/${e2}` + t.ok(reserve([cafePath1], cafe1)) + t.notOk(reserve([cafePath2], cafe2)) +}) + +t.test('absolute paths and trailing slash', t => { + let calledA1 = false + let calledA2 = false + const a1 = done => { + t.equal(calledA1, false, 'called a1 only once') + t.equal(calledA2, false, 'called a1 before 2') + calledA1 = true + setTimeout(done) + } + const a2 = done => { + t.equal(calledA1, true, 'called a1 before 2') + t.equal(calledA2, false, 'called a2 only once') + calledA2 = true + done() + if (calledR2) + t.end() + } + let calledR1 = false + let calledR2 = false + const r1 = done => { + t.equal(calledR1, false, 'called r1 only once') + t.equal(calledR2, false, 'called r1 before 2') + calledR1 = true + setTimeout(done) + } + const r2 = done => { + t.equal(calledR1, true, 'called r1 before 2') + t.equal(calledR2, false, 'called r1 only once') + calledR2 = true + done() + if (calledA2) + t.end() + } + t.ok(reserve(['/p/a/t/h'], a1)) + t.notOk(reserve(['/p/a/t/h/'], a2)) + t.ok(reserve(['p/a/t/h'], r1)) + t.notOk(reserve(['p/a/t/h/'], r2)) +}) + +t.test('on windows, everything collides with everything', t => { + const reserve = winReserve + let called1 = false + let called2 = false + const f1 = done => { + t.equal(called1, false, 'only call 1 once') + t.equal(called2, false, 'call 1 before 2') + called1 = true + setTimeout(done) + } + const f2 = done => { + t.equal(called1, true, 'call 1 before 2') + t.equal(called2, false, 'only call 2 once') + called2 = true + done() + t.end() + } + t.equal(reserve(['some/path'], f1), true) + t.equal(reserve(['other/path'], f2), false) +})
1739408d3122fix: reserve paths properly for unicode, windows
2 files changed · +119 −5
lib/path-reservations.js+26 −5 modified@@ -8,8 +8,12 @@ const assert = require('assert') const normPath = require('./normalize-windows-path.js') +const stripSlashes = require('./strip-trailing-slashes.js') const { join } = require('path') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' + module.exports = () => { // path => [function or Set] // A Set object means a directory reservation @@ -20,10 +24,16 @@ module.exports = () => { const reservations = new Map() // return a set of parent dirs for a given path - const getDirs = path => - path.split('/').slice(0, -1).reduce((set, path) => - set.length ? set.concat(normPath(join(set[set.length - 1], path))) - : [path], []) + // '/a/b/c/d' -> ['/', '/a', '/a/b', '/a/b/c', '/a/b/c/d'] + const getDirs = path => { + const dirs = path.split('/').slice(0, -1).reduce((set, path) => { + if (set.length) + path = normPath(join(set[set.length - 1], path)) + set.push(path || '/') + return set + }, []) + return dirs + } // functions currently running const running = new Set() @@ -99,7 +109,18 @@ module.exports = () => { } const reserve = (paths, fn) => { - paths = paths.map(p => normPath(join(p)).toLowerCase()) + // collide on matches across case and unicode normalization + // On windows, thanks to the magic of 8.3 shortnames, it is fundamentally + // impossible to determine whether two paths refer to the same thing on + // disk, without asking the kernel for a shortname. + // So, we just pretend that every path matches every other path here, + // effectively removing all parallelization on windows. + paths = isWindows ? ['win32 parallelization disabled'] : paths.map(p => { + return stripSlashes(normPath(join(p))) + .normalize('NFKD') + .toLowerCase() + }) + const dirs = new Set( paths.map(path => getDirs(path)).reduce((a, b) => a.concat(b)) )
test/path-reservations.js+93 −0 modified@@ -1,5 +1,14 @@ const t = require('tap') +const requireInject = require('require-inject') + +// load up the posix and windows versions of the reserver +if (process.platform === 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'posix' const { reserve } = require('../lib/path-reservations.js')() +delete process.env.TESTING_TAR_FAKE_PLATFORM +if (process.platform !== 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' +const { reserve: winReserve } = requireInject('../lib/path-reservations.js')() t.test('basic race', t => { // simulate the race conditions we care about @@ -54,3 +63,87 @@ t.test('basic race', t => { t.notOk(reserve(['a/b'], dir2), 'dir2 waits') t.notOk(reserve(['a/b/x'], dir3), 'dir3 waits') }) + +t.test('unicode shenanigans', t => { + const e1 = Buffer.from([0xc3, 0xa9]) + const e2 = Buffer.from([0x65, 0xcc, 0x81]) + let didCafe1 = false + const cafe1 = done => { + t.equal(didCafe1, false, 'did cafe1 only once') + t.equal(didCafe2, false, 'did cafe1 before cafe2') + didCafe1 = true + setTimeout(done) + } + let didCafe2 = false + const cafe2 = done => { + t.equal(didCafe1, true, 'did cafe1 before cafe2') + t.equal(didCafe2, false, 'did cafe2 only once') + didCafe2 = true + done() + t.end() + } + const cafePath1 = `c/a/f/${e1}` + const cafePath2 = `c/a/f/${e2}` + t.ok(reserve([cafePath1], cafe1)) + t.notOk(reserve([cafePath2], cafe2)) +}) + +t.test('absolute paths and trailing slash', t => { + let calledA1 = false + let calledA2 = false + const a1 = done => { + t.equal(calledA1, false, 'called a1 only once') + t.equal(calledA2, false, 'called a1 before 2') + calledA1 = true + setTimeout(done) + } + const a2 = done => { + t.equal(calledA1, true, 'called a1 before 2') + t.equal(calledA2, false, 'called a2 only once') + calledA2 = true + done() + if (calledR2) + t.end() + } + let calledR1 = false + let calledR2 = false + const r1 = done => { + t.equal(calledR1, false, 'called r1 only once') + t.equal(calledR2, false, 'called r1 before 2') + calledR1 = true + setTimeout(done) + } + const r2 = done => { + t.equal(calledR1, true, 'called r1 before 2') + t.equal(calledR2, false, 'called r1 only once') + calledR2 = true + done() + if (calledA2) + t.end() + } + t.ok(reserve(['/p/a/t/h'], a1)) + t.notOk(reserve(['/p/a/t/h/'], a2)) + t.ok(reserve(['p/a/t/h'], r1)) + t.notOk(reserve(['p/a/t/h/'], r2)) +}) + +t.test('on windows, everything collides with everything', t => { + const reserve = winReserve + let called1 = false + let called2 = false + const f1 = done => { + t.equal(called1, false, 'only call 1 once') + t.equal(called2, false, 'call 1 before 2') + called1 = true + setTimeout(done) + } + const f2 = done => { + t.equal(called1, true, 'call 1 before 2') + t.equal(called2, false, 'only call 2 once') + called2 = true + done() + t.end() + } + t.equal(reserve(['some/path'], f1), true) + t.equal(reserve(['other/path'], f2), false) +})
bb93ba243746fix: reserve paths properly for unicode, windows
2 files changed · +119 −5
lib/path-reservations.js+26 −5 modified@@ -8,8 +8,12 @@ const assert = require('assert') const normPath = require('./normalize-windows-path.js') +const stripSlashes = require('./strip-trailing-slashes.js') const { join } = require('path') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' + module.exports = () => { // path => [function or Set] // A Set object means a directory reservation @@ -20,10 +24,16 @@ module.exports = () => { const reservations = new Map() // return a set of parent dirs for a given path - const getDirs = path => - path.split('/').slice(0, -1).reduce((set, path) => - set.length ? set.concat(normPath(join(set[set.length - 1], path))) - : [path], []) + // '/a/b/c/d' -> ['/', '/a', '/a/b', '/a/b/c', '/a/b/c/d'] + const getDirs = path => { + const dirs = path.split('/').slice(0, -1).reduce((set, path) => { + if (set.length) + path = normPath(join(set[set.length - 1], path)) + set.push(path || '/') + return set + }, []) + return dirs + } // functions currently running const running = new Set() @@ -99,7 +109,18 @@ module.exports = () => { } const reserve = (paths, fn) => { - paths = paths.map(p => normPath(join(p)).toLowerCase()) + // collide on matches across case and unicode normalization + // On windows, thanks to the magic of 8.3 shortnames, it is fundamentally + // impossible to determine whether two paths refer to the same thing on + // disk, without asking the kernel for a shortname. + // So, we just pretend that every path matches every other path here, + // effectively removing all parallelization on windows. + paths = isWindows ? ['win32 parallelization disabled'] : paths.map(p => { + return stripSlashes(normPath(join(p))) + .normalize('NFKD') + .toLowerCase() + }) + const dirs = new Set( paths.map(path => getDirs(path)).reduce((a, b) => a.concat(b)) )
test/path-reservations.js+93 −0 modified@@ -1,5 +1,14 @@ const t = require('tap') +const requireInject = require('require-inject') + +// load up the posix and windows versions of the reserver +if (process.platform === 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'posix' const { reserve } = require('../lib/path-reservations.js')() +delete process.env.TESTING_TAR_FAKE_PLATFORM +if (process.platform !== 'win32') + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' +const { reserve: winReserve } = requireInject('../lib/path-reservations.js')() t.test('basic race', t => { // simulate the race conditions we care about @@ -54,3 +63,87 @@ t.test('basic race', t => { t.notOk(reserve(['a/b'], dir2), 'dir2 waits') t.notOk(reserve(['a/b/x'], dir3), 'dir3 waits') }) + +t.test('unicode shenanigans', t => { + const e1 = Buffer.from([0xc3, 0xa9]) + const e2 = Buffer.from([0x65, 0xcc, 0x81]) + let didCafe1 = false + const cafe1 = done => { + t.equal(didCafe1, false, 'did cafe1 only once') + t.equal(didCafe2, false, 'did cafe1 before cafe2') + didCafe1 = true + setTimeout(done) + } + let didCafe2 = false + const cafe2 = done => { + t.equal(didCafe1, true, 'did cafe1 before cafe2') + t.equal(didCafe2, false, 'did cafe2 only once') + didCafe2 = true + done() + t.end() + } + const cafePath1 = `c/a/f/${e1}` + const cafePath2 = `c/a/f/${e2}` + t.ok(reserve([cafePath1], cafe1)) + t.notOk(reserve([cafePath2], cafe2)) +}) + +t.test('absolute paths and trailing slash', t => { + let calledA1 = false + let calledA2 = false + const a1 = done => { + t.equal(calledA1, false, 'called a1 only once') + t.equal(calledA2, false, 'called a1 before 2') + calledA1 = true + setTimeout(done) + } + const a2 = done => { + t.equal(calledA1, true, 'called a1 before 2') + t.equal(calledA2, false, 'called a2 only once') + calledA2 = true + done() + if (calledR2) + t.end() + } + let calledR1 = false + let calledR2 = false + const r1 = done => { + t.equal(calledR1, false, 'called r1 only once') + t.equal(calledR2, false, 'called r1 before 2') + calledR1 = true + setTimeout(done) + } + const r2 = done => { + t.equal(calledR1, true, 'called r1 before 2') + t.equal(calledR2, false, 'called r1 only once') + calledR2 = true + done() + if (calledA2) + t.end() + } + t.ok(reserve(['/p/a/t/h'], a1)) + t.notOk(reserve(['/p/a/t/h/'], a2)) + t.ok(reserve(['p/a/t/h'], r1)) + t.notOk(reserve(['p/a/t/h/'], r2)) +}) + +t.test('on windows, everything collides with everything', t => { + const reserve = winReserve + let called1 = false + let called2 = false + const f1 = done => { + t.equal(called1, false, 'only call 1 once') + t.equal(called2, false, 'call 1 before 2') + called1 = true + setTimeout(done) + } + const f2 = done => { + t.equal(called1, true, 'call 1 before 2') + t.equal(called2, false, 'only call 2 once') + called2 = true + done() + t.end() + } + t.equal(reserve(['some/path'], f1), true) + t.equal(reserve(['other/path'], f2), false) +})
3aaf19b2501bfix: prune dirCache properly for unicode, windows
2 files changed · +210 −19
lib/unpack.js+67 −19 modified@@ -16,10 +16,12 @@ const wc = require('./winchars.js') const pathReservations = require('./path-reservations.js') const stripAbsolutePath = require('./strip-absolute-path.js') const normPath = require('./normalize-windows-path.js') +const stripSlash = require('./strip-trailing-slashes.js') const ONENTRY = Symbol('onEntry') const CHECKFS = Symbol('checkFs') const CHECKFS2 = Symbol('checkFs2') +const PRUNECACHE = Symbol('pruneCache') const ISREUSABLE = Symbol('isReusable') const MAKEFS = Symbol('makeFs') const FILE = Symbol('file') @@ -43,6 +45,8 @@ const GID = Symbol('gid') const CHECKED_CWD = Symbol('checkedCwd') const crypto = require('crypto') const getFlag = require('./get-write-flag.js') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' // Unlinks on Windows are not atomic. // @@ -61,7 +65,7 @@ const getFlag = require('./get-write-flag.js') // See: https://github.com/npm/node-tar/issues/183 /* istanbul ignore next */ const unlinkFile = (path, cb) => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlink(path, cb) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -74,7 +78,7 @@ const unlinkFile = (path, cb) => { /* istanbul ignore next */ const unlinkFileSync = path => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlinkSync(path) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -88,17 +92,33 @@ const uint32 = (a, b, c) => : b === b >>> 0 ? b : c +// clear the cache if it's a case-insensitive unicode-squashing match. +// we can't know if the current file system is case-sensitive or supports +// unicode fully, so we check for similarity on the maximally compatible +// representation. Err on the side of pruning, since all it's doing is +// preventing lstats, and it's not the end of the world if we get a false +// positive. +// Note that on windows, we always drop the entire cache whenever a +// symbolic link is encountered, because 8.3 filenames are impossible +// to reason about, and collisions are hazards rather than just failures. +const cacheKeyNormalize = path => stripSlash(normPath(path)) + .normalize('NFKD') + .toLowerCase() + const pruneCache = (cache, abs) => { - // clear the cache if it's a case-insensitive match, since we can't - // know if the current file system is case-sensitive or not. - abs = normPath(abs).toLowerCase() + abs = cacheKeyNormalize(abs) for (const path of cache.keys()) { - const plower = path.toLowerCase() - if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0) + const pnorm = cacheKeyNormalize(path) + if (pnorm === abs || pnorm.indexOf(abs + '/') === 0) cache.delete(path) } } +const dropCache = cache => { + for (const key of cache.keys()) + cache.delete(key) +} + class Unpack extends Parser { constructor (opt) { if (!opt) @@ -158,7 +178,7 @@ class Unpack extends Parser { this.forceChown = opt.forceChown === true // turn ><?| in filenames into 0xf000-higher encoded forms - this.win32 = !!opt.win32 || process.platform === 'win32' + this.win32 = !!opt.win32 || isWindows // do not unpack over files that are newer than what's in the archive this.newer = !!opt.newer @@ -497,7 +517,7 @@ class Unpack extends Parser { !this.unlink && st.isFile() && st.nlink <= 1 && - process.platform !== 'win32' + !isWindows } // check if a thing is there, and if so, try to clobber it @@ -509,13 +529,30 @@ class Unpack extends Parser { this.reservations.reserve(paths, done => this[CHECKFS2](entry, done)) } - [CHECKFS2] (entry, done) { + [PRUNECACHE] (entry) { // if we are not creating a directory, and the path is in the dirCache, // then that means we are about to delete the directory we created // previously, and it is no longer going to be a directory, and neither // is any of its children. - if (entry.type !== 'Directory') + // If a symbolic link is encountered on Windows, all bets are off. + // There is no reasonable way to sanitize the cache in such a way + // we will be able to avoid having filesystem collisions. If this + // happens with a non-symlink entry, it'll just fail to unpack, + // but a symlink to a directory, using an 8.3 shortname, can evade + // detection and lead to arbitrary writes to anywhere on the system. + if (isWindows && entry.type === 'SymbolicLink') + dropCache(this.dirCache) + else if (entry.type !== 'Directory') pruneCache(this.dirCache, entry.absolute) + } + + [CHECKFS2] (entry, fullyDone) { + this[PRUNECACHE](entry) + + const done = er => { + this[PRUNECACHE](entry) + fullyDone(er) + } const checkCwd = () => { this[MKDIR](this.cwd, this.dmode, er => { @@ -566,7 +603,13 @@ class Unpack extends Parser { return afterChmod() return fs.chmod(entry.absolute, entry.mode, afterChmod) } - // not a dir entry, have to remove it. + // Not a dir entry, have to remove it. + // NB: the only way to end up with an entry that is the cwd + // itself, in such a way that == does not detect, is a + // tricky windows absolute path with UNC or 8.3 parts (and + // preservePaths:true, or else it will have been stripped). + // In that case, the user has opted out of path protections + // explicitly, so if they blow away the cwd, c'est la vie. if (entry.absolute !== this.cwd) { return fs.rmdir(entry.absolute, er => this[MAKEFS](er, entry, done)) @@ -641,8 +684,7 @@ class UnpackSync extends Unpack { } [CHECKFS] (entry) { - if (entry.type !== 'Directory') - pruneCache(this.dirCache, entry.absolute) + this[PRUNECACHE](entry) if (!this[CHECKED_CWD]) { const er = this[MKDIR](this.cwd, this.dmode) @@ -691,7 +733,7 @@ class UnpackSync extends Unpack { this[MAKEFS](er, entry) } - [FILE] (entry, _) { + [FILE] (entry, done) { const mode = entry.mode & 0o7777 || this.fmode const oner = er => { @@ -703,6 +745,7 @@ class UnpackSync extends Unpack { } if (er || closeError) this[ONERROR](er || closeError, entry) + done() } let fd @@ -762,11 +805,14 @@ class UnpackSync extends Unpack { }) } - [DIRECTORY] (entry, _) { + [DIRECTORY] (entry, done) { const mode = entry.mode & 0o7777 || this.dmode const er = this[MKDIR](entry.absolute, mode) - if (er) - return this[ONERROR](er, entry) + if (er) { + this[ONERROR](er, entry) + done() + return + } if (entry.mtime && !this.noMtime) { try { fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime) @@ -777,6 +823,7 @@ class UnpackSync extends Unpack { fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry)) } catch (er) {} } + done() entry.resume() } @@ -799,9 +846,10 @@ class UnpackSync extends Unpack { } } - [LINK] (entry, linkpath, link, _) { + [LINK] (entry, linkpath, link, done) { try { fs[link + 'Sync'](linkpath, entry.absolute) + done() entry.resume() } catch (er) { return this[ONERROR](er, entry)
test/unpack.js+143 −0 modified@@ -3001,3 +3001,146 @@ t.test('do not hang on large files that fail to open()', t => { }) }) }) + +t.test('dirCache pruning unicode normalized collisions', { + skip: isWindows && 'symlinks not fully supported', +}, t => { + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'foo', + }, + { + type: 'File', + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString() + '/bar', + size: 1, + }, + 'y', + '', + '', + ]) + + const check = (path, dirCache, t) => { + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [path, true], + [`${path}/foo`, true], + ]) + t.equal(fs.readFileSync(path + '/foo/bar', 'utf8'), 'x') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +}) + +t.test('dircache prune all on windows when symlink encountered', t => { + if (process.platform !== 'win32') { + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' + t.teardown(() => { + delete process.env.TESTING_TAR_FAKE_PLATFORM + }) + } + const symlinks = [] + const Unpack = t.mock('../lib/unpack.js', { + fs: { + ...fs, + symlink: (target, dest, cb) => { + symlinks.push(['async', target, dest]) + process.nextTick(cb) + }, + symlinkSync: (target, dest) => symlinks.push(['sync', target, dest]), + }, + }) + const UnpackSync = Unpack.Sync + + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'safe/actually/but/cannot/be/too/careful', + }, + { + type: 'File', + path: 'bar/baz', + size: 1, + }, + 'z', + '', + '', + ]) + + const check = (path, dirCache, t) => { + // symlink blew away all dirCache entries before it + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [`${path}/bar`, true], + ]) + t.equal(fs.readFileSync(`${path}/foo/bar`, 'utf8'), 'x') + t.equal(fs.readFileSync(`${path}/bar/baz`, 'utf8'), 'z') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +})
d56f790bda9ffix: prune dirCache properly for unicode, windows
2 files changed · +211 −19
lib/unpack.js+68 −19 modified@@ -18,10 +18,12 @@ const wc = require('./winchars.js') const pathReservations = require('./path-reservations.js') const stripAbsolutePath = require('./strip-absolute-path.js') const normPath = require('./normalize-windows-path.js') +const stripSlash = require('./strip-trailing-slashes.js') const ONENTRY = Symbol('onEntry') const CHECKFS = Symbol('checkFs') const CHECKFS2 = Symbol('checkFs2') +const PRUNECACHE = Symbol('pruneCache') const ISREUSABLE = Symbol('isReusable') const MAKEFS = Symbol('makeFs') const FILE = Symbol('file') @@ -46,6 +48,8 @@ const GID = Symbol('gid') const CHECKED_CWD = Symbol('checkedCwd') const crypto = require('crypto') const getFlag = require('./get-write-flag.js') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' // Unlinks on Windows are not atomic. // @@ -64,7 +68,7 @@ const getFlag = require('./get-write-flag.js') // See: https://github.com/npm/node-tar/issues/183 /* istanbul ignore next */ const unlinkFile = (path, cb) => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlink(path, cb) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -77,7 +81,7 @@ const unlinkFile = (path, cb) => { /* istanbul ignore next */ const unlinkFileSync = path => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlinkSync(path) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -91,17 +95,33 @@ const uint32 = (a, b, c) => : b === b >>> 0 ? b : c +// clear the cache if it's a case-insensitive unicode-squashing match. +// we can't know if the current file system is case-sensitive or supports +// unicode fully, so we check for similarity on the maximally compatible +// representation. Err on the side of pruning, since all it's doing is +// preventing lstats, and it's not the end of the world if we get a false +// positive. +// Note that on windows, we always drop the entire cache whenever a +// symbolic link is encountered, because 8.3 filenames are impossible +// to reason about, and collisions are hazards rather than just failures. +const cacheKeyNormalize = path => stripSlash(normPath(path)) + .normalize('NFKD') + .toLowerCase() + const pruneCache = (cache, abs) => { - // clear the cache if it's a case-insensitive match, since we can't - // know if the current file system is case-sensitive or not. - abs = normPath(abs).toLowerCase() + abs = cacheKeyNormalize(abs) for (const path of cache.keys()) { - const plower = path.toLowerCase() - if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0) + const pnorm = cacheKeyNormalize(path) + if (pnorm === abs || pnorm.indexOf(abs + '/') === 0) cache.delete(path) } } +const dropCache = cache => { + for (const key of cache.keys()) + cache.delete(key) +} + class Unpack extends Parser { constructor (opt) { if (!opt) @@ -160,7 +180,7 @@ class Unpack extends Parser { this.forceChown = opt.forceChown === true // turn ><?| in filenames into 0xf000-higher encoded forms - this.win32 = !!opt.win32 || process.platform === 'win32' + this.win32 = !!opt.win32 || isWindows // do not unpack over files that are newer than what's in the archive this.newer = !!opt.newer @@ -494,7 +514,7 @@ class Unpack extends Parser { !this.unlink && st.isFile() && st.nlink <= 1 && - process.platform !== 'win32' + !isWindows } // check if a thing is there, and if so, try to clobber it @@ -505,13 +525,31 @@ class Unpack extends Parser { paths.push(entry.linkpath) this.reservations.reserve(paths, done => this[CHECKFS2](entry, done)) } - [CHECKFS2] (entry, done) { + + [PRUNECACHE] (entry) { // if we are not creating a directory, and the path is in the dirCache, // then that means we are about to delete the directory we created // previously, and it is no longer going to be a directory, and neither // is any of its children. - if (entry.type !== 'Directory') + // If a symbolic link is encountered on Windows, all bets are off. + // There is no reasonable way to sanitize the cache in such a way + // we will be able to avoid having filesystem collisions. If this + // happens with a non-symlink entry, it'll just fail to unpack, + // but a symlink to a directory, using an 8.3 shortname, can evade + // detection and lead to arbitrary writes to anywhere on the system. + if (isWindows && entry.type === 'SymbolicLink') + dropCache(this.dirCache) + else if (entry.type !== 'Directory') pruneCache(this.dirCache, entry.absolute) + } + + [CHECKFS2] (entry, fullyDone) { + this[PRUNECACHE](entry) + + const done = er => { + this[PRUNECACHE](entry) + fullyDone(er) + } const checkCwd = () => { this[MKDIR](this.cwd, this.dmode, er => { @@ -562,7 +600,13 @@ class Unpack extends Parser { return afterChmod() return fs.chmod(entry.absolute, entry.mode, afterChmod) } - // not a dir entry, have to remove it. + // Not a dir entry, have to remove it. + // NB: the only way to end up with an entry that is the cwd + // itself, in such a way that == does not detect, is a + // tricky windows absolute path with UNC or 8.3 parts (and + // preservePaths:true, or else it will have been stripped). + // In that case, the user has opted out of path protections + // explicitly, so if they blow away the cwd, c'est la vie. if (entry.absolute !== this.cwd) { return fs.rmdir(entry.absolute, er => this[MAKEFS](er, entry, done)) @@ -637,8 +681,7 @@ class UnpackSync extends Unpack { } [CHECKFS] (entry) { - if (entry.type !== 'Directory') - pruneCache(this.dirCache, entry.absolute) + this[PRUNECACHE](entry) if (!this[CHECKED_CWD]) { const er = this[MKDIR](this.cwd, this.dmode) @@ -687,7 +730,7 @@ class UnpackSync extends Unpack { this[MAKEFS](er, entry) } - [FILE] (entry, _) { + [FILE] (entry, done) { const mode = entry.mode & 0o7777 || this.fmode const oner = er => { @@ -699,6 +742,7 @@ class UnpackSync extends Unpack { } if (er || closeError) this[ONERROR](er || closeError, entry) + done() } let stream @@ -759,11 +803,14 @@ class UnpackSync extends Unpack { }) } - [DIRECTORY] (entry, _) { + [DIRECTORY] (entry, done) { const mode = entry.mode & 0o7777 || this.dmode const er = this[MKDIR](entry.absolute, mode) - if (er) - return this[ONERROR](er, entry) + if (er) { + this[ONERROR](er, entry) + done() + return + } if (entry.mtime && !this.noMtime) { try { fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime) @@ -774,6 +821,7 @@ class UnpackSync extends Unpack { fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry)) } catch (er) {} } + done() entry.resume() } @@ -796,9 +844,10 @@ class UnpackSync extends Unpack { } } - [LINK] (entry, linkpath, link, _) { + [LINK] (entry, linkpath, link, done) { try { fs[link + 'Sync'](linkpath, entry.absolute) + done() entry.resume() } catch (er) { return this[ONERROR](er, entry)
test/unpack.js+143 −0 modified@@ -2982,3 +2982,146 @@ t.test('do not hang on large files that fail to open()', t => { }) }) }) + +t.test('dirCache pruning unicode normalized collisions', { + skip: isWindows && 'symlinks not fully supported', +}, t => { + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'foo', + }, + { + type: 'File', + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString() + '/bar', + size: 1, + }, + 'y', + '', + '', + ]) + + const check = (path, dirCache, t) => { + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [path, true], + [`${path}/foo`, true], + ]) + t.equal(fs.readFileSync(path + '/foo/bar', 'utf8'), 'x') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +}) + +t.test('dircache prune all on windows when symlink encountered', t => { + if (process.platform !== 'win32') { + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' + t.teardown(() => { + delete process.env.TESTING_TAR_FAKE_PLATFORM + }) + } + const symlinks = [] + const Unpack = requireInject('../lib/unpack.js', { + fs: { + ...fs, + symlink: (target, dest, cb) => { + symlinks.push(['async', target, dest]) + process.nextTick(cb) + }, + symlinkSync: (target, dest) => symlinks.push(['sync', target, dest]), + }, + }) + const UnpackSync = Unpack.Sync + + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'safe/actually/but/cannot/be/too/careful', + }, + { + type: 'File', + path: 'bar/baz', + size: 1, + }, + 'z', + '', + '', + ]) + + const check = (path, dirCache, t) => { + // symlink blew away all dirCache entries before it + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [`${path}/bar`, true], + ]) + t.equal(fs.readFileSync(`${path}/foo/bar`, 'utf8'), 'x') + t.equal(fs.readFileSync(`${path}/bar/baz`, 'utf8'), 'z') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +})
2f1bca027286fix: prune dirCache properly for unicode, windows
2 files changed · +219 −22
lib/unpack.js+76 −22 modified@@ -18,10 +18,12 @@ const wc = require('./winchars.js') const stripAbsolutePath = require('./strip-absolute-path.js') const pathReservations = require('./path-reservations.js') const normPath = require('./normalize-windows-path.js') +const stripSlash = require('./strip-trailing-slashes.js') const ONENTRY = Symbol('onEntry') const CHECKFS = Symbol('checkFs') const CHECKFS2 = Symbol('checkFs2') +const PRUNECACHE = Symbol('pruneCache') const ISREUSABLE = Symbol('isReusable') const MAKEFS = Symbol('makeFs') const FILE = Symbol('file') @@ -45,6 +47,8 @@ const UID = Symbol('uid') const GID = Symbol('gid') const CHECKED_CWD = Symbol('checkedCwd') const crypto = require('crypto') +const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform +const isWindows = platform === 'win32' // Unlinks on Windows are not atomic. // @@ -63,7 +67,7 @@ const crypto = require('crypto') // See: https://github.com/npm/node-tar/issues/183 /* istanbul ignore next */ const unlinkFile = (path, cb) => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlink(path, cb) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -76,7 +80,7 @@ const unlinkFile = (path, cb) => { /* istanbul ignore next */ const unlinkFileSync = path => { - if (process.platform !== 'win32') + if (!isWindows) return fs.unlinkSync(path) const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex') @@ -90,17 +94,33 @@ const uint32 = (a, b, c) => : b === b >>> 0 ? b : c +// clear the cache if it's a case-insensitive unicode-squashing match. +// we can't know if the current file system is case-sensitive or supports +// unicode fully, so we check for similarity on the maximally compatible +// representation. Err on the side of pruning, since all it's doing is +// preventing lstats, and it's not the end of the world if we get a false +// positive. +// Note that on windows, we always drop the entire cache whenever a +// symbolic link is encountered, because 8.3 filenames are impossible +// to reason about, and collisions are hazards rather than just failures. +const cacheKeyNormalize = path => stripSlash(normPath(path)) + .normalize('NFKD') + .toLowerCase() + const pruneCache = (cache, abs) => { - // clear the cache if it's a case-insensitive match, since we can't - // know if the current file system is case-sensitive or not. - abs = normPath(abs).toLowerCase() + abs = cacheKeyNormalize(abs) for (const path of cache.keys()) { - const plower = path.toLowerCase() - if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0) + const pnorm = cacheKeyNormalize(path) + if (pnorm === abs || pnorm.indexOf(abs + '/') === 0) cache.delete(path) } } +const dropCache = cache => { + for (const key of cache.keys()) + cache.delete(key) +} + class Unpack extends Parser { constructor (opt) { if (!opt) @@ -159,7 +179,7 @@ class Unpack extends Parser { this.forceChown = opt.forceChown === true // turn ><?| in filenames into 0xf000-higher encoded forms - this.win32 = !!opt.win32 || process.platform === 'win32' + this.win32 = !!opt.win32 || isWindows // do not unpack over files that are newer than what's in the archive this.newer = !!opt.newer @@ -470,7 +490,7 @@ class Unpack extends Parser { !this.unlink && st.isFile() && st.nlink <= 1 && - process.platform !== 'win32' + !isWindows } // check if a thing is there, and if so, try to clobber it @@ -481,13 +501,31 @@ class Unpack extends Parser { paths.push(entry.linkpath) this.reservations.reserve(paths, done => this[CHECKFS2](entry, done)) } - [CHECKFS2] (entry, done) { + + [PRUNECACHE] (entry) { // if we are not creating a directory, and the path is in the dirCache, // then that means we are about to delete the directory we created // previously, and it is no longer going to be a directory, and neither // is any of its children. - if (entry.type !== 'Directory') + // If a symbolic link is encountered on Windows, all bets are off. + // There is no reasonable way to sanitize the cache in such a way + // we will be able to avoid having filesystem collisions. If this + // happens with a non-symlink entry, it'll just fail to unpack, + // but a symlink to a directory, using an 8.3 shortname, can evade + // detection and lead to arbitrary writes to anywhere on the system. + if (isWindows && entry.type === 'SymbolicLink') + dropCache(this.dirCache) + else if (entry.type !== 'Directory') pruneCache(this.dirCache, entry.absolute) + } + + [CHECKFS2] (entry, fullyDone) { + this[PRUNECACHE](entry) + + const done = er => { + this[PRUNECACHE](entry) + fullyDone(er) + } const checkCwd = () => { this[MKDIR](this.cwd, this.dmode, er => { @@ -538,7 +576,13 @@ class Unpack extends Parser { return afterChmod() return fs.chmod(entry.absolute, entry.mode, afterChmod) } - // not a dir entry, have to remove it. + // Not a dir entry, have to remove it. + // NB: the only way to end up with an entry that is the cwd + // itself, in such a way that == does not detect, is a + // tricky windows absolute path with UNC or 8.3 parts (and + // preservePaths:true, or else it will have been stripped). + // In that case, the user has opted out of path protections + // explicitly, so if they blow away the cwd, c'est la vie. if (entry.absolute !== this.cwd) { return fs.rmdir(entry.absolute, er => this[MAKEFS](er, entry, done)) @@ -608,8 +652,7 @@ class UnpackSync extends Unpack { } [CHECKFS] (entry) { - if (entry.type !== 'Directory') - pruneCache(this.dirCache, entry.absolute) + this[PRUNECACHE](entry) if (!this[CHECKED_CWD]) { const er = this[MKDIR](this.cwd, this.dmode) @@ -658,13 +701,19 @@ class UnpackSync extends Unpack { this[MAKEFS](er, entry) } - [FILE] (entry, _) { + [FILE] (entry, done) { const mode = entry.mode & 0o7777 || this.fmode const oner = er => { - try { fs.closeSync(fd) } catch (_) {} - if (er) - this[ONERROR](er, entry) + let closeError + try { + fs.closeSync(fd) + } catch (e) { + closeError = e + } + if (er || closeError) + this[ONERROR](er || closeError, entry) + done() } let stream @@ -725,11 +774,14 @@ class UnpackSync extends Unpack { }) } - [DIRECTORY] (entry, _) { + [DIRECTORY] (entry, done) { const mode = entry.mode & 0o7777 || this.dmode const er = this[MKDIR](entry.absolute, mode) - if (er) - return this[ONERROR](er, entry) + if (er) { + this[ONERROR](er, entry) + done() + return + } if (entry.mtime && !this.noMtime) { try { fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime) @@ -740,6 +792,7 @@ class UnpackSync extends Unpack { fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry)) } catch (er) {} } + done() entry.resume() } @@ -762,9 +815,10 @@ class UnpackSync extends Unpack { } } - [LINK] (entry, linkpath, link, _) { + [LINK] (entry, linkpath, link, done) { try { fs[link + 'Sync'](linkpath, entry.absolute) + done() entry.resume() } catch (er) { return this[ONERROR](er, entry)
test/unpack.js+143 −0 modified@@ -2693,3 +2693,146 @@ t.test('using strip option when top level file exists', t => { check(t, path) }) }) + +t.test('dirCache pruning unicode normalized collisions', { + skip: isWindows && 'symlinks not fully supported', +}, t => { + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'foo', + }, + { + type: 'File', + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString() + '/bar', + size: 1, + }, + 'y', + '', + '', + ]) + + const check = (path, dirCache, t) => { + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [path, true], + [`${path}/foo`, true], + ]) + t.equal(fs.readFileSync(path + '/foo/bar', 'utf8'), 'x') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +}) + +t.test('dircache prune all on windows when symlink encountered', t => { + if (process.platform !== 'win32') { + process.env.TESTING_TAR_FAKE_PLATFORM = 'win32' + t.teardown(() => { + delete process.env.TESTING_TAR_FAKE_PLATFORM + }) + } + const symlinks = [] + const Unpack = requireInject('../lib/unpack.js', { + fs: { + ...fs, + symlink: (target, dest, cb) => { + symlinks.push(['async', target, dest]) + process.nextTick(cb) + }, + symlinkSync: (target, dest) => symlinks.push(['sync', target, dest]), + }, + }) + const UnpackSync = Unpack.Sync + + const data = makeTar([ + { + type: 'Directory', + path: 'foo', + }, + { + type: 'File', + path: 'foo/bar', + size: 1, + }, + 'x', + { + type: 'Directory', + // café + path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(), + }, + { + type: 'SymbolicLink', + // cafe with a ` + path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(), + linkpath: 'safe/actually/but/cannot/be/too/careful', + }, + { + type: 'File', + path: 'bar/baz', + size: 1, + }, + 'z', + '', + '', + ]) + + const check = (path, dirCache, t) => { + // symlink blew away all dirCache entries before it + path = path.replace(/\\/g, '/') + t.strictSame([...dirCache.entries()], [ + [`${path}/bar`, true], + ]) + t.equal(fs.readFileSync(`${path}/foo/bar`, 'utf8'), 'x') + t.equal(fs.readFileSync(`${path}/bar/baz`, 'utf8'), 'z') + t.end() + } + + t.test('sync', t => { + const path = t.testdir() + const dirCache = new Map() + new UnpackSync({ cwd: path, dirCache }).end(data) + check(path, dirCache, t) + }) + + t.test('async', t => { + const path = t.testdir() + const dirCache = new Map() + new Unpack({ cwd: path, dirCache }) + .on('close', () => check(path, dirCache, t)) + .end(data) + }) + + t.end() +})
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
14- github.com/advisories/GHSA-qq89-hq3f-393pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-37712ghsaADVISORY
- www.debian.org/security/2021/dsa-5008ghsavendor-advisoryWEB
- cert-portal.siemens.com/productcert/pdf/ssa-389290.pdfghsaWEB
- github.com/isaacs/node-tar/commit/1739408d3122af897caefd09662bce2ea477533bghsaWEB
- github.com/isaacs/node-tar/commit/2f1bca027286c23e110b8dfc7efc10756fa3db5aghsaWEB
- github.com/isaacs/node-tar/commit/3aaf19b2501bbddb145d92b3322c80dcaed3c35fghsaWEB
- github.com/isaacs/node-tar/commit/b6162c7fafe797f856564ef37f4b82747f051455ghsaWEB
- github.com/isaacs/node-tar/commit/bb93ba243746f705092905da1955ac3b0509ba1eghsaWEB
- github.com/isaacs/node-tar/commit/d56f790bda9fea807dd80c5083f24771dbdd6eb1ghsaWEB
- github.com/npm/node-tar/security/advisories/GHSA-qq89-hq3f-393pghsaWEB
- lists.debian.org/debian-lts-announce/2022/12/msg00023.htmlghsamailing-listWEB
- www.npmjs.com/package/targhsaWEB
- www.oracle.com/security-alerts/cpuoct2021.htmlghsaWEB
News mentions
0No linked articles in our index yet.