Skip to content

Commit d56f790

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 173800d commit d56f790

File tree

2 files changed

+211
-19
lines changed

2 files changed

+211
-19
lines changed

lib/unpack.js

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ const wc = require('./winchars.js')
1818
const pathReservations = require('./path-reservations.js')
1919
const stripAbsolutePath = require('./strip-absolute-path.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')
@@ -46,6 +48,8 @@ const GID = Symbol('gid')
4648
const CHECKED_CWD = Symbol('checkedCwd')
4749
const crypto = require('crypto')
4850
const getFlag = require('./get-write-flag.js')
51+
const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform
52+
const isWindows = platform === 'win32'
4953

5054
// Unlinks on Windows are not atomic.
5155
//
@@ -64,7 +68,7 @@ const getFlag = require('./get-write-flag.js')
6468
// See: https://github.com/npm/node-tar/issues/183
6569
/* istanbul ignore next */
6670
const unlinkFile = (path, cb) => {
67-
if (process.platform !== 'win32')
71+
if (!isWindows)
6872
return fs.unlink(path, cb)
6973

7074
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -77,7 +81,7 @@ const unlinkFile = (path, cb) => {
7781

7882
/* istanbul ignore next */
7983
const unlinkFileSync = path => {
80-
if (process.platform !== 'win32')
84+
if (!isWindows)
8185
return fs.unlinkSync(path)
8286

8387
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -91,17 +95,33 @@ const uint32 = (a, b, c) =>
9195
: b === b >>> 0 ? b
9296
: c
9397

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

120+
const dropCache = cache => {
121+
for (const key of cache.keys())
122+
cache.delete(key)
123+
}
124+
105125
class Unpack extends Parser {
106126
constructor (opt) {
107127
if (!opt)
@@ -160,7 +180,7 @@ class Unpack extends Parser {
160180
this.forceChown = opt.forceChown === true
161181

162182
// turn ><?| in filenames into 0xf000-higher encoded forms
163-
this.win32 = !!opt.win32 || process.platform === 'win32'
183+
this.win32 = !!opt.win32 || isWindows
164184

165185
// do not unpack over files that are newer than what's in the archive
166186
this.newer = !!opt.newer
@@ -494,7 +514,7 @@ class Unpack extends Parser {
494514
!this.unlink &&
495515
st.isFile() &&
496516
st.nlink <= 1 &&
497-
process.platform !== 'win32'
517+
!isWindows
498518
}
499519

500520
// check if a thing is there, and if so, try to clobber it
@@ -505,13 +525,31 @@ class Unpack extends Parser {
505525
paths.push(entry.linkpath)
506526
this.reservations.reserve(paths, done => this[CHECKFS2](entry, done))
507527
}
508-
[CHECKFS2] (entry, done) {
528+
529+
[PRUNECACHE] (entry) {
509530
// if we are not creating a directory, and the path is in the dirCache,
510531
// then that means we are about to delete the directory we created
511532
// previously, and it is no longer going to be a directory, and neither
512533
// is any of its children.
513-
if (entry.type !== 'Directory')
534+
// If a symbolic link is encountered on Windows, all bets are off.
535+
// There is no reasonable way to sanitize the cache in such a way
536+
// we will be able to avoid having filesystem collisions. If this
537+
// happens with a non-symlink entry, it'll just fail to unpack,
538+
// but a symlink to a directory, using an 8.3 shortname, can evade
539+
// detection and lead to arbitrary writes to anywhere on the system.
540+
if (isWindows && entry.type === 'SymbolicLink')
541+
dropCache(this.dirCache)
542+
else if (entry.type !== 'Directory')
514543
pruneCache(this.dirCache, entry.absolute)
544+
}
545+
546+
[CHECKFS2] (entry, fullyDone) {
547+
this[PRUNECACHE](entry)
548+
549+
const done = er => {
550+
this[PRUNECACHE](entry)
551+
fullyDone(er)
552+
}
515553

516554
const checkCwd = () => {
517555
this[MKDIR](this.cwd, this.dmode, er => {
@@ -562,7 +600,13 @@ class Unpack extends Parser {
562600
return afterChmod()
563601
return fs.chmod(entry.absolute, entry.mode, afterChmod)
564602
}
565-
// not a dir entry, have to remove it.
603+
// Not a dir entry, have to remove it.
604+
// NB: the only way to end up with an entry that is the cwd
605+
// itself, in such a way that == does not detect, is a
606+
// tricky windows absolute path with UNC or 8.3 parts (and
607+
// preservePaths:true, or else it will have been stripped).
608+
// In that case, the user has opted out of path protections
609+
// explicitly, so if they blow away the cwd, c'est la vie.
566610
if (entry.absolute !== this.cwd) {
567611
return fs.rmdir(entry.absolute, er =>
568612
this[MAKEFS](er, entry, done))
@@ -637,8 +681,7 @@ class UnpackSync extends Unpack {
637681
}
638682

639683
[CHECKFS] (entry) {
640-
if (entry.type !== 'Directory')
641-
pruneCache(this.dirCache, entry.absolute)
684+
this[PRUNECACHE](entry)
642685

643686
if (!this[CHECKED_CWD]) {
644687
const er = this[MKDIR](this.cwd, this.dmode)
@@ -687,7 +730,7 @@ class UnpackSync extends Unpack {
687730
this[MAKEFS](er, entry)
688731
}
689732

690-
[FILE] (entry, _) {
733+
[FILE] (entry, done) {
691734
const mode = entry.mode & 0o7777 || this.fmode
692735

693736
const oner = er => {
@@ -699,6 +742,7 @@ class UnpackSync extends Unpack {
699742
}
700743
if (er || closeError)
701744
this[ONERROR](er || closeError, entry)
745+
done()
702746
}
703747

704748
let stream
@@ -759,11 +803,14 @@ class UnpackSync extends Unpack {
759803
})
760804
}
761805

762-
[DIRECTORY] (entry, _) {
806+
[DIRECTORY] (entry, done) {
763807
const mode = entry.mode & 0o7777 || this.dmode
764808
const er = this[MKDIR](entry.absolute, mode)
765-
if (er)
766-
return this[ONERROR](er, entry)
809+
if (er) {
810+
this[ONERROR](er, entry)
811+
done()
812+
return
813+
}
767814
if (entry.mtime && !this.noMtime) {
768815
try {
769816
fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime)
@@ -774,6 +821,7 @@ class UnpackSync extends Unpack {
774821
fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry))
775822
} catch (er) {}
776823
}
824+
done()
777825
entry.resume()
778826
}
779827

@@ -796,9 +844,10 @@ class UnpackSync extends Unpack {
796844
}
797845
}
798846

799-
[LINK] (entry, linkpath, link, _) {
847+
[LINK] (entry, linkpath, link, done) {
800848
try {
801849
fs[link + 'Sync'](linkpath, entry.absolute)
850+
done()
802851
entry.resume()
803852
} catch (er) {
804853
return this[ONERROR](er, entry)

test/unpack.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2982,3 +2982,146 @@ t.test('do not hang on large files that fail to open()', t => {
29822982
})
29832983
})
29842984
})
2985+
2986+
t.test('dirCache pruning unicode normalized collisions', {
2987+
skip: isWindows && 'symlinks not fully supported',
2988+
}, t => {
2989+
const data = makeTar([
2990+
{
2991+
type: 'Directory',
2992+
path: 'foo',
2993+
},
2994+
{
2995+
type: 'File',
2996+
path: 'foo/bar',
2997+
size: 1,
2998+
},
2999+
'x',
3000+
{
3001+
type: 'Directory',
3002+
// café
3003+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(),
3004+
},
3005+
{
3006+
type: 'SymbolicLink',
3007+
// cafe with a `
3008+
path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(),
3009+
linkpath: 'foo',
3010+
},
3011+
{
3012+
type: 'File',
3013+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString() + '/bar',
3014+
size: 1,
3015+
},
3016+
'y',
3017+
'',
3018+
'',
3019+
])
3020+
3021+
const check = (path, dirCache, t) => {
3022+
path = path.replace(/\\/g, '/')
3023+
t.strictSame([...dirCache.entries()], [
3024+
[path, true],
3025+
[`${path}/foo`, true],
3026+
])
3027+
t.equal(fs.readFileSync(path + '/foo/bar', 'utf8'), 'x')
3028+
t.end()
3029+
}
3030+
3031+
t.test('sync', t => {
3032+
const path = t.testdir()
3033+
const dirCache = new Map()
3034+
new UnpackSync({ cwd: path, dirCache }).end(data)
3035+
check(path, dirCache, t)
3036+
})
3037+
t.test('async', t => {
3038+
const path = t.testdir()
3039+
const dirCache = new Map()
3040+
new Unpack({ cwd: path, dirCache })
3041+
.on('close', () => check(path, dirCache, t))
3042+
.end(data)
3043+
})
3044+
3045+
t.end()
3046+
})
3047+
3048+
t.test('dircache prune all on windows when symlink encountered', t => {
3049+
if (process.platform !== 'win32') {
3050+
process.env.TESTING_TAR_FAKE_PLATFORM = 'win32'
3051+
t.teardown(() => {
3052+
delete process.env.TESTING_TAR_FAKE_PLATFORM
3053+
})
3054+
}
3055+
const symlinks = []
3056+
const Unpack = requireInject('../lib/unpack.js', {
3057+
fs: {
3058+
...fs,
3059+
symlink: (target, dest, cb) => {
3060+
symlinks.push(['async', target, dest])
3061+
process.nextTick(cb)
3062+
},
3063+
symlinkSync: (target, dest) => symlinks.push(['sync', target, dest]),
3064+
},
3065+
})
3066+
const UnpackSync = Unpack.Sync
3067+
3068+
const data = makeTar([
3069+
{
3070+
type: 'Directory',
3071+
path: 'foo',
3072+
},
3073+
{
3074+
type: 'File',
3075+
path: 'foo/bar',
3076+
size: 1,
3077+
},
3078+
'x',
3079+
{
3080+
type: 'Directory',
3081+
// café
3082+
path: Buffer.from([0x63, 0x61, 0x66, 0xc3, 0xa9]).toString(),
3083+
},
3084+
{
3085+
type: 'SymbolicLink',
3086+
// cafe with a `
3087+
path: Buffer.from([0x63, 0x61, 0x66, 0x65, 0xcc, 0x81]).toString(),
3088+
linkpath: 'safe/actually/but/cannot/be/too/careful',
3089+
},
3090+
{
3091+
type: 'File',
3092+
path: 'bar/baz',
3093+
size: 1,
3094+
},
3095+
'z',
3096+
'',
3097+
'',
3098+
])
3099+
3100+
const check = (path, dirCache, t) => {
3101+
// symlink blew away all dirCache entries before it
3102+
path = path.replace(/\\/g, '/')
3103+
t.strictSame([...dirCache.entries()], [
3104+
[`${path}/bar`, true],
3105+
])
3106+
t.equal(fs.readFileSync(`${path}/foo/bar`, 'utf8'), 'x')
3107+
t.equal(fs.readFileSync(`${path}/bar/baz`, 'utf8'), 'z')
3108+
t.end()
3109+
}
3110+
3111+
t.test('sync', t => {
3112+
const path = t.testdir()
3113+
const dirCache = new Map()
3114+
new UnpackSync({ cwd: path, dirCache }).end(data)
3115+
check(path, dirCache, t)
3116+
})
3117+
3118+
t.test('async', t => {
3119+
const path = t.testdir()
3120+
const dirCache = new Map()
3121+
new Unpack({ cwd: path, dirCache })
3122+
.on('close', () => check(path, dirCache, t))
3123+
.end(data)
3124+
})
3125+
3126+
t.end()
3127+
})

0 commit comments

Comments
 (0)