Skip to content

Commit 967146e

Browse files
authored
Prevent directory traversal (#73)
1 parent 74a462a commit 967146e

11 files changed

+136
-14
lines changed

Diff for: .travis.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
sudo: false
22
language: node_js
33
node_js:
4-
- '8'
5-
- '6'
6-
- '4'
4+
- '13'
5+
- '12'
6+
- '10'

Diff for: fixtures/edge_case_dots.tar.gz

318 Bytes
Binary file not shown.

Diff for: fixtures/slip.zip

1.91 KB
Binary file not shown.

Diff for: fixtures/slip2.zip

1.9 KB
Binary file not shown.

Diff for: fixtures/slip3.zip

2.38 KB
Binary file not shown.

Diff for: fixtures/slipping.tar.gz

188 Bytes
Binary file not shown.

Diff for: fixtures/slipping_directory.tar.gz

161 Bytes
Binary file not shown.

Diff for: fixtures/top_level_example.tar.gz

113 Bytes
Binary file not shown.

Diff for: index.js

+57-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,38 @@ const runPlugins = (input, opts) => {
1919
return Promise.all(opts.plugins.map(x => x(input, opts))).then(files => files.reduce((a, b) => a.concat(b)));
2020
};
2121

22+
const safeMakeDir = (dir, realOutputPath) => {
23+
return fsP.realpath(dir)
24+
.catch(_ => {
25+
const parent = path.dirname(dir);
26+
return safeMakeDir(parent, realOutputPath);
27+
})
28+
.then(realParentPath => {
29+
if (realParentPath.indexOf(realOutputPath) !== 0) {
30+
throw (new Error('Refusing to create a directory outside the output path.'));
31+
}
32+
33+
return makeDir(dir).then(fsP.realpath);
34+
});
35+
};
36+
37+
const preventWritingThroughSymlink = (destination, realOutputPath) => {
38+
return fsP.readlink(destination)
39+
.catch(_ => {
40+
// Either no file exists, or it's not a symlink. In either case, this is
41+
// not an escape we need to worry about in this phase.
42+
return null;
43+
})
44+
.then(symlinkPointsTo => {
45+
if (symlinkPointsTo) {
46+
throw new Error('Refusing to write into a symlink');
47+
}
48+
49+
// No symlink exists at `destination`, so we can continue
50+
return realOutputPath;
51+
});
52+
};
53+
2254
const extractFile = (input, output, opts) => runPlugins(input, opts).then(files => {
2355
if (opts.strip > 0) {
2456
files = files
@@ -47,12 +79,35 @@ const extractFile = (input, output, opts) => runPlugins(input, opts).then(files
4779
const now = new Date();
4880

4981
if (x.type === 'directory') {
50-
return makeDir(dest)
82+
return makeDir(output)
83+
.then(outputPath => fsP.realpath(outputPath))
84+
.then(realOutputPath => safeMakeDir(dest, realOutputPath))
5185
.then(() => fsP.utimes(dest, now, x.mtime))
5286
.then(() => x);
5387
}
5488

55-
return makeDir(path.dirname(dest))
89+
return makeDir(output)
90+
.then(outputPath => fsP.realpath(outputPath))
91+
.then(realOutputPath => {
92+
// Attempt to ensure parent directory exists (failing if it's outside the output dir)
93+
return safeMakeDir(path.dirname(dest), realOutputPath)
94+
.then(() => realOutputPath);
95+
})
96+
.then(realOutputPath => {
97+
if (x.type === 'file') {
98+
return preventWritingThroughSymlink(dest, realOutputPath);
99+
}
100+
101+
return realOutputPath;
102+
})
103+
.then(realOutputPath => {
104+
return fsP.realpath(path.dirname(dest))
105+
.then(realDestinationDir => {
106+
if (realDestinationDir.indexOf(realOutputPath) !== 0) {
107+
throw (new Error('Refusing to write outside output directory: ' + realDestinationDir));
108+
}
109+
});
110+
})
56111
.then(() => {
57112
if (x.type === 'link') {
58113
return fsP.link(x.linkname, dest);

Diff for: package.json

+13-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"url": "github.com/kevva"
1111
},
1212
"engines": {
13-
"node": ">=4"
13+
"node": ">=7.6.0"
1414
},
1515
"scripts": {
1616
"test": "xo && ava"
@@ -41,9 +41,21 @@
4141
},
4242
"devDependencies": {
4343
"ava": "*",
44+
"esm": "^3.2.25",
4445
"is-jpg": "^1.0.0",
4546
"path-exists": "^3.0.0",
4647
"pify": "^2.3.0",
48+
"rimraf": "^3.0.2",
4749
"xo": "*"
50+
},
51+
"ava": {
52+
"require": [
53+
"esm"
54+
]
55+
},
56+
"xo": {
57+
"rules": {
58+
"promise/prefer-await-to-then": "off"
59+
}
4860
}
4961
}

Diff for: test.js

+63-8
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,22 @@ import path from 'path';
33
import isJpg from 'is-jpg';
44
import pathExists from 'path-exists';
55
import pify from 'pify';
6+
import rimraf from 'rimraf';
67
import test from 'ava';
78
import m from '.';
89

910
const fsP = pify(fs);
11+
const rimrafP = pify(rimraf);
12+
13+
test.serial.afterEach('ensure decompressed files and directories are cleaned up', async () => {
14+
await rimrafP(path.join(__dirname, 'directory'));
15+
await rimrafP(path.join(__dirname, 'dist'));
16+
await rimrafP(path.join(__dirname, 'example.txt'));
17+
await rimrafP(path.join(__dirname, 'file.txt'));
18+
await rimrafP(path.join(__dirname, 'edge_case_dots'));
19+
await rimrafP(path.join(__dirname, 'symlink'));
20+
await rimrafP(path.join(__dirname, 'test.jpg'));
21+
});
1022

1123
test('extract file', async t => {
1224
const tarFiles = await m(path.join(__dirname, 'fixtures', 'file.tar'));
@@ -46,21 +58,16 @@ test.serial('extract file to directory', async t => {
4658
t.is(files[0].path, 'test.jpg');
4759
t.true(isJpg(files[0].data));
4860
t.true(await pathExists(path.join(__dirname, 'test.jpg')));
49-
50-
await fsP.unlink(path.join(__dirname, 'test.jpg'));
5161
});
5262

53-
test('extract symlink', async t => {
63+
test.serial('extract symlink', async t => {
5464
await m(path.join(__dirname, 'fixtures', 'symlink.tar'), __dirname, {strip: 1});
5565
t.is(await fsP.realpath(path.join(__dirname, 'symlink')), path.join(__dirname, 'file.txt'));
56-
await fsP.unlink(path.join(__dirname, 'symlink'));
57-
await fsP.unlink(path.join(__dirname, 'file.txt'));
5866
});
5967

60-
test('extract directory', async t => {
68+
test.serial('extract directory', async t => {
6169
await m(path.join(__dirname, 'fixtures', 'directory.tar'), __dirname);
6270
t.true(await pathExists(path.join(__dirname, 'directory')));
63-
await fsP.rmdir(path.join(__dirname, 'directory'));
6471
});
6572

6673
test('strip option', async t => {
@@ -96,10 +103,58 @@ test.serial('set mtime', async t => {
96103
const files = await m(path.join(__dirname, 'fixtures', 'file.tar'), __dirname);
97104
const stat = await fsP.stat(path.join(__dirname, 'test.jpg'));
98105
t.deepEqual(files[0].mtime, stat.mtime);
99-
await fsP.unlink(path.join(__dirname, 'test.jpg'));
100106
});
101107

102108
test('return emptpy array if no plugins are set', async t => {
103109
const files = await m(path.join(__dirname, 'fixtures', 'file.tar'), {plugins: []});
104110
t.is(files.length, 0);
105111
});
112+
113+
test.serial('throw when a location outside the root is given', async t => {
114+
await t.throwsAsync(async () => {
115+
await m(path.join(__dirname, 'fixtures', 'slipping.tar.gz'), 'dist');
116+
}, {message: /Refusing/});
117+
});
118+
119+
test.serial('throw when a location outside the root including symlinks is given', async t => {
120+
await t.throwsAsync(async () => {
121+
await m(path.join(__dirname, 'fixtures', 'slip.zip'), 'dist');
122+
}, {message: /Refusing/});
123+
});
124+
125+
test.serial('throw when a top-level symlink outside the root is given', async t => {
126+
await t.throwsAsync(async () => {
127+
await m(path.join(__dirname, 'fixtures', 'slip2.zip'), 'dist');
128+
}, {message: /Refusing/});
129+
});
130+
131+
test.serial('throw when a directory outside the root including symlinks is given', async t => {
132+
await t.throwsAsync(async () => {
133+
await m(path.join(__dirname, 'fixtures', 'slipping_directory.tar.gz'), 'dist');
134+
}, {message: /Refusing/});
135+
});
136+
137+
test.serial('allows filenames and directories to be written with dots in their names', async t => {
138+
const files = await m(path.join(__dirname, 'fixtures', 'edge_case_dots.tar.gz'), __dirname);
139+
t.is(files.length, 6);
140+
t.deepEqual(files.map(f => f.path).sort(), [
141+
'edge_case_dots/',
142+
'edge_case_dots/internal_dots..txt',
143+
'edge_case_dots/sample../',
144+
'edge_case_dots/ending_dots..',
145+
'edge_case_dots/x',
146+
'edge_case_dots/sample../test.txt'
147+
].sort());
148+
});
149+
150+
test.serial('allows top-level file', async t => {
151+
const files = await m(path.join(__dirname, 'fixtures', 'top_level_example.tar.gz'), 'dist');
152+
t.is(files.length, 1);
153+
t.is(files[0].path, 'example.txt');
154+
});
155+
156+
test.serial('throw when chained symlinks to /tmp/dist allow escape outside root directory', async t => {
157+
await t.throwsAsync(async () => {
158+
await m(path.join(__dirname, 'fixtures', 'slip3.zip'), '/tmp/dist');
159+
}, {message: /Refusing/});
160+
});

0 commit comments

Comments
 (0)