Skip to content

Commit 52b09e3

Browse files
committed
fix: prevent path escape using drive-relative paths
On Windows, a path like `c:foo` is not considered "absolute", but if the cwd it's being resolved against is on a different drive letter, then `resolve(cwd, path)` will not end up contained within `cwd`, even in the absence of `..` portions. This change strips path roots from all paths prior to being resolved against the extraction target folder, even if such paths are not "absolute". Additionally, a path starting with a drive letter and then two dots, like `c:../`, would bypass the check for `..` path portions. This is now being checked properly. Finally, a defense in depth check is added, such that if the entry.absolute is outside of the extraction taret, and we are not in preservePaths:true mode, a warning is raised on that entry, and it is skipped. Currently, it is believed that this check is redundant, but it did catch some oversights in development.
1 parent bb93ba2 commit 52b09e3

File tree

3 files changed

+79
-15
lines changed

3 files changed

+79
-15
lines changed

lib/strip-absolute-path.js

+12-2
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@
22
const { isAbsolute, parse } = require('path').win32
33

44
// returns [root, stripped]
5+
// Note that windows will think that //x/y/z/a has a "root" of //x/y, and in
6+
// those cases, we want to sanitize it to x/y/z/a, not z/a, so we strip /
7+
// explicitly if it's the first character.
8+
// drive-specific relative paths on Windows get their root stripped off even
9+
// though they are not absolute, so `c:../foo` becomes ['c:', '../foo']
510
module.exports = path => {
611
let r = ''
7-
while (isAbsolute(path)) {
12+
13+
let parsed = parse(path)
14+
while (isAbsolute(path) || parsed.root) {
815
// windows will think that //x/y/z has a "root" of //x/y/
9-
const root = path.charAt(0) === '/' ? '/' : parse(path).root
16+
// but strip the //?/C:/ off of //?/C:/path
17+
const root = path.charAt(0) === '/' && path.slice(0, 4) !== '//?/' ? '/'
18+
: parsed.root
1019
path = path.substr(root.length)
1120
r += root
21+
parsed = parse(path)
1222
}
1323
return [r, path]
1424
}

lib/unpack.js

+19-3
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,13 @@ class Unpack extends Parser {
236236

237237
if (!this.preservePaths) {
238238
const p = normPath(entry.path)
239-
if (p.split('/').includes('..')) {
239+
const parts = p.split('/')
240+
if (parts.includes('..') || isWindows && /^[a-z]:\.\.$/i.test(parts[0])) {
240241
this.warn(`path contains '..'`, p)
241242
return false
242243
}
243244

244-
// absolutes on posix are also absolutes on win32
245-
// so we only need to test this one to get both
245+
// strip off the root
246246
const s = stripAbsolutePath(p)
247247
if (s[0]) {
248248
entry.path = s[1]
@@ -255,6 +255,22 @@ class Unpack extends Parser {
255255
else
256256
entry.absolute = normPath(path.resolve(this.cwd, entry.path))
257257

258+
// if we somehow ended up with a path that escapes the cwd, and we are
259+
// not in preservePaths mode, then something is fishy! This should have
260+
// been prevented above, so ignore this for coverage.
261+
/* istanbul ignore if - defense in depth */
262+
if (!this.preservePaths &&
263+
entry.absolute.indexOf(this.cwd + '/') !== 0 &&
264+
entry.absolute !== this.cwd) {
265+
this.warn('TAR_ENTRY_ERROR', 'path escaped extraction target', {
266+
entry,
267+
path: normPath(entry.path),
268+
resolvedPath: entry.absolute,
269+
cwd: this.cwd,
270+
})
271+
return false
272+
}
273+
258274
// an archive can set properties on the extraction directory, but it
259275
// may not replace the cwd with a different kind of thing entirely.
260276
if (entry.absolute === this.cwd &&

test/strip-absolute-path.js

+48-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,52 @@
11
const t = require('tap')
22
const stripAbsolutePath = require('../lib/strip-absolute-path.js')
3+
const cwd = process.cwd()
4+
const requireInject = require('require-inject')
35

4-
const cases = {
5-
'/': ['/', ''],
6-
'////': ['////', ''],
7-
'c:///a/b/c': ['c:///', 'a/b/c'],
8-
'\\\\foo\\bar\\baz': ['\\\\foo\\bar\\', 'baz'],
9-
'//foo//bar//baz': ['//', 'foo//bar//baz'],
10-
'c:\\c:\\c:\\c:\\\\d:\\e/f/g': ['c:\\c:\\c:\\c:\\\\d:\\', 'e/f/g'],
11-
}
6+
t.test('basic', t => {
7+
const cases = {
8+
'/': ['/', ''],
9+
'////': ['////', ''],
10+
'c:///a/b/c': ['c:///', 'a/b/c'],
11+
'\\\\foo\\bar\\baz': ['\\\\foo\\bar\\', 'baz'],
12+
'//foo//bar//baz': ['//', 'foo//bar//baz'],
13+
'c:\\c:\\c:\\c:\\\\d:\\e/f/g': ['c:\\c:\\c:\\c:\\\\d:\\', 'e/f/g'],
14+
}
1215

13-
for (const [input, [root, stripped]] of Object.entries(cases))
14-
t.strictSame(stripAbsolutePath(input), [root, stripped], input)
16+
for (const [input, [root, stripped]] of Object.entries(cases))
17+
t.strictSame(stripAbsolutePath(input, cwd), [root, stripped], input)
18+
t.end()
19+
})
20+
21+
t.test('drive-local paths', t => {
22+
const env = process.env
23+
t.teardown(() => process.env = env)
24+
const cwd = 'D:\\safety\\land'
25+
const realPath = require('path')
26+
// be windowsy
27+
const path = {
28+
...realPath.win32,
29+
win32: realPath.win32,
30+
posix: realPath.posix,
31+
}
32+
const stripAbsolutePath = requireInject('../lib/strip-absolute-path.js', { path })
33+
const cases = {
34+
'/': ['/', ''],
35+
'////': ['////', ''],
36+
'c:///a/b/c': ['c:///', 'a/b/c'],
37+
'\\\\foo\\bar\\baz': ['\\\\foo\\bar\\', 'baz'],
38+
'//foo//bar//baz': ['//', 'foo//bar//baz'],
39+
'c:\\c:\\c:\\c:\\\\d:\\e/f/g': ['c:\\c:\\c:\\c:\\\\d:\\', 'e/f/g'],
40+
'c:..\\system\\explorer.exe': ['c:', '..\\system\\explorer.exe'],
41+
'd:..\\..\\unsafe\\land': ['d:', '..\\..\\unsafe\\land'],
42+
'c:foo': ['c:', 'foo'],
43+
'D:mark': ['D:', 'mark'],
44+
'//?/X:/y/z': ['//?/X:/', 'y/z'],
45+
'\\\\?\\X:\\y\\z': ['\\\\?\\X:\\', 'y\\z'],
46+
}
47+
for (const [input, [root, stripped]] of Object.entries(cases)) {
48+
if (!t.strictSame(stripAbsolutePath(input, cwd), [root, stripped], input))
49+
break
50+
}
51+
t.end()
52+
})

0 commit comments

Comments
 (0)