Skip to content

Commit 2f1bca0

Browse files
committed
fix: prune dirCache properly for unicode, windows
This prunes the dirCache in a way that catches unicode filename matches. If a symbolic link is encountered on Windows, the entire dirCache is cleared, as 8.3 shortname collisions may result in a path escape vulnerability in the case of symbolic links. If such a collision occurs in the case of other types of entries, it is not such a big problem, because the unpack will fail.
1 parent 9bf70a8 commit 2f1bca0

File tree

2 files changed

+219
-22
lines changed

2 files changed

+219
-22
lines changed

lib/unpack.js

+76-22
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ const wc = require('./winchars.js')
1818
const stripAbsolutePath = require('./strip-absolute-path.js')
1919
const pathReservations = require('./path-reservations.js')
2020
const normPath = require('./normalize-windows-path.js')
21+
const stripSlash = require('./strip-trailing-slashes.js')
2122

2223
const ONENTRY = Symbol('onEntry')
2324
const CHECKFS = Symbol('checkFs')
2425
const CHECKFS2 = Symbol('checkFs2')
26+
const PRUNECACHE = Symbol('pruneCache')
2527
const ISREUSABLE = Symbol('isReusable')
2628
const MAKEFS = Symbol('makeFs')
2729
const FILE = Symbol('file')
@@ -45,6 +47,8 @@ const UID = Symbol('uid')
4547
const GID = Symbol('gid')
4648
const CHECKED_CWD = Symbol('checkedCwd')
4749
const crypto = require('crypto')
50+
const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform
51+
const isWindows = platform === 'win32'
4852

4953
// Unlinks on Windows are not atomic.
5054
//
@@ -63,7 +67,7 @@ const crypto = require('crypto')
6367
// See: https://github.com/npm/node-tar/issues/183
6468
/* istanbul ignore next */
6569
const unlinkFile = (path, cb) => {
66-
if (process.platform !== 'win32')
70+
if (!isWindows)
6771
return fs.unlink(path, cb)
6872

6973
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -76,7 +80,7 @@ const unlinkFile = (path, cb) => {
7680

7781
/* istanbul ignore next */
7882
const unlinkFileSync = path => {
79-
if (process.platform !== 'win32')
83+
if (!isWindows)
8084
return fs.unlinkSync(path)
8185

8286
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -90,17 +94,33 @@ const uint32 = (a, b, c) =>
9094
: b === b >>> 0 ? b
9195
: c
9296

97+
// clear the cache if it's a case-insensitive unicode-squashing match.
98+
// we can't know if the current file system is case-sensitive or supports
99+
// unicode fully, so we check for similarity on the maximally compatible
100+
// representation. Err on the side of pruning, since all it's doing is
101+
// preventing lstats, and it's not the end of the world if we get a false
102+
// positive.
103+
// Note that on windows, we always drop the entire cache whenever a
104+
// symbolic link is encountered, because 8.3 filenames are impossible
105+
// to reason about, and collisions are hazards rather than just failures.
106+
const cacheKeyNormalize = path => stripSlash(normPath(path))
107+
.normalize('NFKD')
108+
.toLowerCase()
109+
93110
const pruneCache = (cache, abs) => {
94-
// clear the cache if it's a case-insensitive match, since we can't
95-
// know if the current file system is case-sensitive or not.
96-
abs = normPath(abs).toLowerCase()
111+
abs = cacheKeyNormalize(abs)
97112
for (const path of cache.keys()) {
98-
const plower = path.toLowerCase()
99-
if (plower === abs || plower.toLowerCase().indexOf(abs + '/') === 0)
113+
const pnorm = cacheKeyNormalize(path)
114+
if (pnorm === abs || pnorm.indexOf(abs + '/') === 0)
100115
cache.delete(path)
101116
}
102117
}
103118

119+
const dropCache = cache => {
120+
for (const key of cache.keys())
121+
cache.delete(key)
122+
}
123+
104124
class Unpack extends Parser {
105125
constructor (opt) {
106126
if (!opt)
@@ -159,7 +179,7 @@ class Unpack extends Parser {
159179
this.forceChown = opt.forceChown === true
160180

161181
// turn ><?| in filenames into 0xf000-higher encoded forms
162-
this.win32 = !!opt.win32 || process.platform === 'win32'
182+
this.win32 = !!opt.win32 || isWindows
163183

164184
// do not unpack over files that are newer than what's in the archive
165185
this.newer = !!opt.newer
@@ -470,7 +490,7 @@ class Unpack extends Parser {
470490
!this.unlink &&
471491
st.isFile() &&
472492
st.nlink <= 1 &&
473-
process.platform !== 'win32'
493+
!isWindows
474494
}
475495

476496
// check if a thing is there, and if so, try to clobber it
@@ -481,13 +501,31 @@ class Unpack extends Parser {
481501
paths.push(entry.linkpath)
482502
this.reservations.reserve(paths, done => this[CHECKFS2](entry, done))
483503
}
484-
[CHECKFS2] (entry, done) {
504+
505+
[PRUNECACHE] (entry) {
485506
// if we are not creating a directory, and the path is in the dirCache,
486507
// then that means we are about to delete the directory we created
487508
// previously, and it is no longer going to be a directory, and neither
488509
// is any of its children.
489-
if (entry.type !== 'Directory')
510+
// If a symbolic link is encountered on Windows, all bets are off.
511+
// There is no reasonable way to sanitize the cache in such a way
512+
// we will be able to avoid having filesystem collisions. If this
513+
// happens with a non-symlink entry, it'll just fail to unpack,
514+
// but a symlink to a directory, using an 8.3 shortname, can evade
515+
// detection and lead to arbitrary writes to anywhere on the system.
516+
if (isWindows && entry.type === 'SymbolicLink')
517+
dropCache(this.dirCache)
518+
else if (entry.type !== 'Directory')
490519
pruneCache(this.dirCache, entry.absolute)
520+
}
521+
522+
[CHECKFS2] (entry, fullyDone) {
523+
this[PRUNECACHE](entry)
524+
525+
const done = er => {
526+
this[PRUNECACHE](entry)
527+
fullyDone(er)
528+
}
491529

492530
const checkCwd = () => {
493531
this[MKDIR](this.cwd, this.dmode, er => {
@@ -538,7 +576,13 @@ class Unpack extends Parser {
538576
return afterChmod()
539577
return fs.chmod(entry.absolute, entry.mode, afterChmod)
540578
}
541-
// not a dir entry, have to remove it.
579+
// Not a dir entry, have to remove it.
580+
// NB: the only way to end up with an entry that is the cwd
581+
// itself, in such a way that == does not detect, is a
582+
// tricky windows absolute path with UNC or 8.3 parts (and
583+
// preservePaths:true, or else it will have been stripped).
584+
// In that case, the user has opted out of path protections
585+
// explicitly, so if they blow away the cwd, c'est la vie.
542586
if (entry.absolute !== this.cwd) {
543587
return fs.rmdir(entry.absolute, er =>
544588
this[MAKEFS](er, entry, done))
@@ -608,8 +652,7 @@ class UnpackSync extends Unpack {
608652
}
609653

610654
[CHECKFS] (entry) {
611-
if (entry.type !== 'Directory')
612-
pruneCache(this.dirCache, entry.absolute)
655+
this[PRUNECACHE](entry)
613656

614657
if (!this[CHECKED_CWD]) {
615658
const er = this[MKDIR](this.cwd, this.dmode)
@@ -658,13 +701,19 @@ class UnpackSync extends Unpack {
658701
this[MAKEFS](er, entry)
659702
}
660703

661-
[FILE] (entry, _) {
704+
[FILE] (entry, done) {
662705
const mode = entry.mode & 0o7777 || this.fmode
663706

664707
const oner = er => {
665-
try { fs.closeSync(fd) } catch (_) {}
666-
if (er)
667-
this[ONERROR](er, entry)
708+
let closeError
709+
try {
710+
fs.closeSync(fd)
711+
} catch (e) {
712+
closeError = e
713+
}
714+
if (er || closeError)
715+
this[ONERROR](er || closeError, entry)
716+
done()
668717
}
669718

670719
let stream
@@ -725,11 +774,14 @@ class UnpackSync extends Unpack {
725774
})
726775
}
727776

728-
[DIRECTORY] (entry, _) {
777+
[DIRECTORY] (entry, done) {
729778
const mode = entry.mode & 0o7777 || this.dmode
730779
const er = this[MKDIR](entry.absolute, mode)
731-
if (er)
732-
return this[ONERROR](er, entry)
780+
if (er) {
781+
this[ONERROR](er, entry)
782+
done()
783+
return
784+
}
733785
if (entry.mtime && !this.noMtime) {
734786
try {
735787
fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime)
@@ -740,6 +792,7 @@ class UnpackSync extends Unpack {
740792
fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry))
741793
} catch (er) {}
742794
}
795+
done()
743796
entry.resume()
744797
}
745798

@@ -762,9 +815,10 @@ class UnpackSync extends Unpack {
762815
}
763816
}
764817

765-
[LINK] (entry, linkpath, link, _) {
818+
[LINK] (entry, linkpath, link, done) {
766819
try {
767820
fs[link + 'Sync'](linkpath, entry.absolute)
821+
done()
768822
entry.resume()
769823
} catch (er) {
770824
return this[ONERROR](er, entry)

test/unpack.js

+143
Original file line numberDiff line numberDiff line change
@@ -2693,3 +2693,146 @@ t.test('using strip option when top level file exists', t => {
26932693
check(t, path)
26942694
})
26952695
})
2696+
2697+
t.test('dirCache pruning unicode normalized collisions', {
2698+
skip: isWindows && 'symlinks not fully supported',
2699+
}, t => {
2700+
const data = makeTar([
2701+
{
2702+
type: 'Directory',
2703+
path: 'foo',
2704+
},
2705+
{
2706+
type: 'File',
2707+
path: 'foo/bar',
2708+
size: 1,
2709+
},
2710+
'x',
2711+
{
2712+
type: 'Directory',
2713+
// café
2714+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(),
2715+
},
2716+
{
2717+
type: 'SymbolicLink',
2718+
// cafe with a `
2719+
path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(),
2720+
linkpath: 'foo',
2721+
},
2722+
{
2723+
type: 'File',
2724+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString() + '/bar',
2725+
size: 1,
2726+
},
2727+
'y',
2728+
'',
2729+
'',
2730+
])
2731+
2732+
const check = (path, dirCache, t) => {
2733+
path = path.replace(/\\/g, '/')
2734+
t.strictSame([...dirCache.entries()], [
2735+
[path, true],
2736+
[`${path}/foo`, true],
2737+
])
2738+
t.equal(fs.readFileSync(path + '/foo/bar', 'utf8'), 'x')
2739+
t.end()
2740+
}
2741+
2742+
t.test('sync', t => {
2743+
const path = t.testdir()
2744+
const dirCache = new Map()
2745+
new UnpackSync({ cwd: path, dirCache }).end(data)
2746+
check(path, dirCache, t)
2747+
})
2748+
t.test('async', t => {
2749+
const path = t.testdir()
2750+
const dirCache = new Map()
2751+
new Unpack({ cwd: path, dirCache })
2752+
.on('close', () => check(path, dirCache, t))
2753+
.end(data)
2754+
})
2755+
2756+
t.end()
2757+
})
2758+
2759+
t.test('dircache prune all on windows when symlink encountered', t => {
2760+
if (process.platform !== 'win32') {
2761+
process.env.TESTING_TAR_FAKE_PLATFORM = 'win32'
2762+
t.teardown(() => {
2763+
delete process.env.TESTING_TAR_FAKE_PLATFORM
2764+
})
2765+
}
2766+
const symlinks = []
2767+
const Unpack = requireInject('../lib/unpack.js', {
2768+
fs: {
2769+
...fs,
2770+
symlink: (target, dest, cb) => {
2771+
symlinks.push(['async', target, dest])
2772+
process.nextTick(cb)
2773+
},
2774+
symlinkSync: (target, dest) => symlinks.push(['sync', target, dest]),
2775+
},
2776+
})
2777+
const UnpackSync = Unpack.Sync
2778+
2779+
const data = makeTar([
2780+
{
2781+
type: 'Directory',
2782+
path: 'foo',
2783+
},
2784+
{
2785+
type: 'File',
2786+
path: 'foo/bar',
2787+
size: 1,
2788+
},
2789+
'x',
2790+
{
2791+
type: 'Directory',
2792+
// café
2793+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(),
2794+
},
2795+
{
2796+
type: 'SymbolicLink',
2797+
// cafe with a `
2798+
path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(),
2799+
linkpath: 'safe/actually/but/cannot/be/too/careful',
2800+
},
2801+
{
2802+
type: 'File',
2803+
path: 'bar/baz',
2804+
size: 1,
2805+
},
2806+
'z',
2807+
'',
2808+
'',
2809+
])
2810+
2811+
const check = (path, dirCache, t) => {
2812+
// symlink blew away all dirCache entries before it
2813+
path = path.replace(/\\/g, '/')
2814+
t.strictSame([...dirCache.entries()], [
2815+
[`${path}/bar`, true],
2816+
])
2817+
t.equal(fs.readFileSync(`${path}/foo/bar`, 'utf8'), 'x')
2818+
t.equal(fs.readFileSync(`${path}/bar/baz`, 'utf8'), 'z')
2819+
t.end()
2820+
}
2821+
2822+
t.test('sync', t => {
2823+
const path = t.testdir()
2824+
const dirCache = new Map()
2825+
new UnpackSync({ cwd: path, dirCache }).end(data)
2826+
check(path, dirCache, t)
2827+
})
2828+
2829+
t.test('async', t => {
2830+
const path = t.testdir()
2831+
const dirCache = new Map()
2832+
new Unpack({ cwd: path, dirCache })
2833+
.on('close', () => check(path, dirCache, t))
2834+
.end(data)
2835+
})
2836+
2837+
t.end()
2838+
})

0 commit comments

Comments
 (0)