Skip to content

Commit 3aaf19b

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 6a9c51d commit 3aaf19b

File tree

2 files changed

+210
-19
lines changed

2 files changed

+210
-19
lines changed

lib/unpack.js

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,12 @@ const wc = require('./winchars.js')
1616
const pathReservations = require('./path-reservations.js')
1717
const stripAbsolutePath = require('./strip-absolute-path.js')
1818
const normPath = require('./normalize-windows-path.js')
19+
const stripSlash = require('./strip-trailing-slashes.js')
1920

2021
const ONENTRY = Symbol('onEntry')
2122
const CHECKFS = Symbol('checkFs')
2223
const CHECKFS2 = Symbol('checkFs2')
24+
const PRUNECACHE = Symbol('pruneCache')
2325
const ISREUSABLE = Symbol('isReusable')
2426
const MAKEFS = Symbol('makeFs')
2527
const FILE = Symbol('file')
@@ -43,6 +45,8 @@ const GID = Symbol('gid')
4345
const CHECKED_CWD = Symbol('checkedCwd')
4446
const crypto = require('crypto')
4547
const getFlag = require('./get-write-flag.js')
48+
const platform = process.env.TESTING_TAR_FAKE_PLATFORM || process.platform
49+
const isWindows = platform === 'win32'
4650

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

6771
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -74,7 +78,7 @@ const unlinkFile = (path, cb) => {
7478

7579
/* istanbul ignore next */
7680
const unlinkFileSync = path => {
77-
if (process.platform !== 'win32')
81+
if (!isWindows)
7882
return fs.unlinkSync(path)
7983

8084
const name = path + '.DELETE.' + crypto.randomBytes(16).toString('hex')
@@ -88,17 +92,33 @@ const uint32 = (a, b, c) =>
8892
: b === b >>> 0 ? b
8993
: c
9094

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

117+
const dropCache = cache => {
118+
for (const key of cache.keys())
119+
cache.delete(key)
120+
}
121+
102122
class Unpack extends Parser {
103123
constructor (opt) {
104124
if (!opt)
@@ -158,7 +178,7 @@ class Unpack extends Parser {
158178
this.forceChown = opt.forceChown === true
159179

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

163183
// do not unpack over files that are newer than what's in the archive
164184
this.newer = !!opt.newer
@@ -497,7 +517,7 @@ class Unpack extends Parser {
497517
!this.unlink &&
498518
st.isFile() &&
499519
st.nlink <= 1 &&
500-
process.platform !== 'win32'
520+
!isWindows
501521
}
502522

503523
// check if a thing is there, and if so, try to clobber it
@@ -509,13 +529,30 @@ class Unpack extends Parser {
509529
this.reservations.reserve(paths, done => this[CHECKFS2](entry, done))
510530
}
511531

512-
[CHECKFS2] (entry, done) {
532+
[PRUNECACHE] (entry) {
513533
// if we are not creating a directory, and the path is in the dirCache,
514534
// then that means we are about to delete the directory we created
515535
// previously, and it is no longer going to be a directory, and neither
516536
// is any of its children.
517-
if (entry.type !== 'Directory')
537+
// If a symbolic link is encountered on Windows, all bets are off.
538+
// There is no reasonable way to sanitize the cache in such a way
539+
// we will be able to avoid having filesystem collisions. If this
540+
// happens with a non-symlink entry, it'll just fail to unpack,
541+
// but a symlink to a directory, using an 8.3 shortname, can evade
542+
// detection and lead to arbitrary writes to anywhere on the system.
543+
if (isWindows && entry.type === 'SymbolicLink')
544+
dropCache(this.dirCache)
545+
else if (entry.type !== 'Directory')
518546
pruneCache(this.dirCache, entry.absolute)
547+
}
548+
549+
[CHECKFS2] (entry, fullyDone) {
550+
this[PRUNECACHE](entry)
551+
552+
const done = er => {
553+
this[PRUNECACHE](entry)
554+
fullyDone(er)
555+
}
519556

520557
const checkCwd = () => {
521558
this[MKDIR](this.cwd, this.dmode, er => {
@@ -566,7 +603,13 @@ class Unpack extends Parser {
566603
return afterChmod()
567604
return fs.chmod(entry.absolute, entry.mode, afterChmod)
568605
}
569-
// not a dir entry, have to remove it.
606+
// Not a dir entry, have to remove it.
607+
// NB: the only way to end up with an entry that is the cwd
608+
// itself, in such a way that == does not detect, is a
609+
// tricky windows absolute path with UNC or 8.3 parts (and
610+
// preservePaths:true, or else it will have been stripped).
611+
// In that case, the user has opted out of path protections
612+
// explicitly, so if they blow away the cwd, c'est la vie.
570613
if (entry.absolute !== this.cwd) {
571614
return fs.rmdir(entry.absolute, er =>
572615
this[MAKEFS](er, entry, done))
@@ -641,8 +684,7 @@ class UnpackSync extends Unpack {
641684
}
642685

643686
[CHECKFS] (entry) {
644-
if (entry.type !== 'Directory')
645-
pruneCache(this.dirCache, entry.absolute)
687+
this[PRUNECACHE](entry)
646688

647689
if (!this[CHECKED_CWD]) {
648690
const er = this[MKDIR](this.cwd, this.dmode)
@@ -691,7 +733,7 @@ class UnpackSync extends Unpack {
691733
this[MAKEFS](er, entry)
692734
}
693735

694-
[FILE] (entry, _) {
736+
[FILE] (entry, done) {
695737
const mode = entry.mode & 0o7777 || this.fmode
696738

697739
const oner = er => {
@@ -703,6 +745,7 @@ class UnpackSync extends Unpack {
703745
}
704746
if (er || closeError)
705747
this[ONERROR](er || closeError, entry)
748+
done()
706749
}
707750

708751
let fd
@@ -762,11 +805,14 @@ class UnpackSync extends Unpack {
762805
})
763806
}
764807

765-
[DIRECTORY] (entry, _) {
808+
[DIRECTORY] (entry, done) {
766809
const mode = entry.mode & 0o7777 || this.dmode
767810
const er = this[MKDIR](entry.absolute, mode)
768-
if (er)
769-
return this[ONERROR](er, entry)
811+
if (er) {
812+
this[ONERROR](er, entry)
813+
done()
814+
return
815+
}
770816
if (entry.mtime && !this.noMtime) {
771817
try {
772818
fs.utimesSync(entry.absolute, entry.atime || new Date(), entry.mtime)
@@ -777,6 +823,7 @@ class UnpackSync extends Unpack {
777823
fs.chownSync(entry.absolute, this[UID](entry), this[GID](entry))
778824
} catch (er) {}
779825
}
826+
done()
780827
entry.resume()
781828
}
782829

@@ -799,9 +846,10 @@ class UnpackSync extends Unpack {
799846
}
800847
}
801848

802-
[LINK] (entry, linkpath, link, _) {
849+
[LINK] (entry, linkpath, link, done) {
803850
try {
804851
fs[link + 'Sync'](linkpath, entry.absolute)
852+
done()
805853
entry.resume()
806854
} catch (er) {
807855
return this[ONERROR](er, entry)

test/unpack.js

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

0 commit comments

Comments
 (0)