From 967146e70f48be32ed1a69daa3941d681944d513 Mon Sep 17 00:00:00 2001 From: Colin Jones Date: Wed, 1 Apr 2020 08:45:07 -0500 Subject: [PATCH] Prevent directory traversal (#73) --- .travis.yml | 6 +-- fixtures/edge_case_dots.tar.gz | Bin 0 -> 318 bytes fixtures/slip.zip | Bin 0 -> 1953 bytes fixtures/slip2.zip | Bin 0 -> 1948 bytes fixtures/slip3.zip | Bin 0 -> 2441 bytes fixtures/slipping.tar.gz | Bin 0 -> 188 bytes fixtures/slipping_directory.tar.gz | Bin 0 -> 161 bytes fixtures/top_level_example.tar.gz | Bin 0 -> 113 bytes index.js | 59 +++++++++++++++++++++++- package.json | 14 +++++- test.js | 71 +++++++++++++++++++++++++---- 11 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 fixtures/edge_case_dots.tar.gz create mode 100644 fixtures/slip.zip create mode 100644 fixtures/slip2.zip create mode 100644 fixtures/slip3.zip create mode 100644 fixtures/slipping.tar.gz create mode 100644 fixtures/slipping_directory.tar.gz create mode 100644 fixtures/top_level_example.tar.gz diff --git a/.travis.yml b/.travis.yml index 57505cf..96c6315 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ sudo: false language: node_js node_js: - - '8' - - '6' - - '4' + - '13' + - '12' + - '10' diff --git a/fixtures/edge_case_dots.tar.gz b/fixtures/edge_case_dots.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..935f979ddf2c3443430b331aaac7e1db35042600 GIT binary patch literal 318 zcmV-E0m1$siwFQ_zE@rV1MQbhYQr!Pg}v@6bOKlZx9#;-Zo5Vu8c5@X+oLg zS`18&-r088m1Q`E3^n5W&{9t5 zA2lEKmnt5l%owQ)LNnB!W#jt4je?V_;7@#)S2u1yrhl#)^Z74-@}Kjlzt)0c{;$F7AULM~R!RJSTB61S^Pe_SUFUzP8Tx+> z-W`zYV0&A|IS><{9 literal 0 HcmV?d00001 diff --git a/fixtures/slip.zip b/fixtures/slip.zip new file mode 100644 index 0000000000000000000000000000000000000000..d94b89d549138c8174bf254f6b67c9fe1e719362 GIT binary patch literal 1953 zcmWIWW@h1H00A|BAO9|)F*&xi%Aj42xnVXZDmmOb{A77N8UlJO^$-o@mzAzSq zODnh;7+GF0GcbS&{Qwk`dfQxbfl>@0tOztIJvA@2C^I=eC9_B$$<%_xqSU++kSIR0 z_4Lq8_xR`?B?vSbgikXOVY)s+`$tuPVi^%1qv1h=@IXrWp!}p?l3So(oRe8lkeU)- zm06%yQh_&*MP(K!lw>59C_wda1$Z+u$uZ-~zY@UQ0RjIPf@n0`Lqk|0xfv}dqZ^8v zm5~kg0vbv%V-af&YMzEz11g8MG_D}Vn$h%1gY-%;az{fQE!5HS81@{FE8nrQ0aH4# Q%4B9Z3#>Qyf$B~M0Ci`!G5`Po literal 0 HcmV?d00001 diff --git a/fixtures/slip2.zip b/fixtures/slip2.zip new file mode 100644 index 0000000000000000000000000000000000000000..bd6180b099f75cf131f29702d4ac288743235ce9 GIT binary patch literal 1948 zcmWIWW@h1H00A}Qm;n1Qg;`=iHV6wc$S@S=WEK>pro>le7Q`Frl~k03hHx@4TP%r) z1L4vNZU#n{7t9O{U_!qnw?H35M*xZiy=^YJK=lkDtO&FqJvA@2C^I=eC9_DsxH2~< zGcP;7BtO0&u_!gK1SE>jLOnfwRMThwdK@MWG#P|dJTNj1 zzEmHTS)fpokyxSt)uoV_o|u`(72wUtB*%;^D@y=#1O)tF2%@pr$O_5WXqg(_Skw%R zY-}RXSc2IpG=w;7P;)ZG8b+W6TN?KhW6fwPr9mns7`daNjuz@9=Y!K#RkYPy6EGjOE&&kZo4h`XCVE)?_6$iql72FJr zEMFNJ7+6GrD)mcp3-nVmi%ay2Q zaZYAIL262TRc1lFv0h0<399kEZ7#XYK%+re5ommRYF=tlW^#N=W|4kzWiH4A@g@23 z1&Kwec_koGe3t6z>4QuM`HTY`Dkl?DzsZ74W?+b6Cc<=mg7%N9K#h;l@EFnIL0mc0 zeM%=l`9Iu2+&+#PIg~Uamt1B5e=P#JT1jn03W$uB$ailR)=_ zh%ASNQJ#;_KaJ*Un#|Xvv4@}i-cy};k={lBwyn$JQ-6P}OroZ;aG&mLjXO<0=DeDr z@^bdfE&0y_mp*cNv9U8)ID2lY(@USFD<^o>9Nfvfbb3&T?_T?TKX$*Hv-*Bdjg;!Y kBRP4WGdFILj=XWwYWwPwNeT=o!2Uhoz8eu27&I6d08nF9SpWb4 literal 0 HcmV?d00001 diff --git a/fixtures/slipping_directory.tar.gz b/fixtures/slipping_directory.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..0ff297c3b9fba971ad38165b74a23cbd8fedafaf GIT binary patch literal 161 zcmb2|=HPG>iHKwRU!0R!P>`9I9-op~l$u_Pirkvw=YCLrX6|0pZg} z9(UYuXpQjRGUeVrN5NfL=37_)PkwOrQSQ90Q)Bbr9gFUITX|w*{IWl5o}|ZIT#+Za zP3K!oeLYuc!9G3L!nmu}hFbLn^QY@cEB~F+_PYJvW1-|)*=Qjaib HFfafB0aZnQ literal 0 HcmV?d00001 diff --git a/fixtures/top_level_example.tar.gz b/fixtures/top_level_example.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6c0a642d5f748d9398c341328f3467f9294065c7 GIT binary patch literal 113 zcmV-%0FM73iwFRKNLgM0152$)%q_@C)hnqeVW1T-FfcGPF;M`~=4Pf~8VF!CgkxxI zW@Kh$Vq$1wreI)bY-(u4pkP2V7a@Vt;*!K7pi`3bb29Vrs4LG%P0hh0kLoD}D!XzN TjDk@x3aAbM{$n^700sa6m5VF5 literal 0 HcmV?d00001 diff --git a/index.js b/index.js index 9193ebd..6aa67ca 100644 --- a/index.js +++ b/index.js @@ -19,6 +19,38 @@ const runPlugins = (input, opts) => { return Promise.all(opts.plugins.map(x => x(input, opts))).then(files => files.reduce((a, b) => a.concat(b))); }; +const safeMakeDir = (dir, realOutputPath) => { + return fsP.realpath(dir) + .catch(_ => { + const parent = path.dirname(dir); + return safeMakeDir(parent, realOutputPath); + }) + .then(realParentPath => { + if (realParentPath.indexOf(realOutputPath) !== 0) { + throw (new Error('Refusing to create a directory outside the output path.')); + } + + return makeDir(dir).then(fsP.realpath); + }); +}; + +const preventWritingThroughSymlink = (destination, realOutputPath) => { + return fsP.readlink(destination) + .catch(_ => { + // Either no file exists, or it's not a symlink. In either case, this is + // not an escape we need to worry about in this phase. + return null; + }) + .then(symlinkPointsTo => { + if (symlinkPointsTo) { + throw new Error('Refusing to write into a symlink'); + } + + // No symlink exists at `destination`, so we can continue + return realOutputPath; + }); +}; + const extractFile = (input, output, opts) => runPlugins(input, opts).then(files => { if (opts.strip > 0) { files = files @@ -47,12 +79,35 @@ const extractFile = (input, output, opts) => runPlugins(input, opts).then(files const now = new Date(); if (x.type === 'directory') { - return makeDir(dest) + return makeDir(output) + .then(outputPath => fsP.realpath(outputPath)) + .then(realOutputPath => safeMakeDir(dest, realOutputPath)) .then(() => fsP.utimes(dest, now, x.mtime)) .then(() => x); } - return makeDir(path.dirname(dest)) + return makeDir(output) + .then(outputPath => fsP.realpath(outputPath)) + .then(realOutputPath => { + // Attempt to ensure parent directory exists (failing if it's outside the output dir) + return safeMakeDir(path.dirname(dest), realOutputPath) + .then(() => realOutputPath); + }) + .then(realOutputPath => { + if (x.type === 'file') { + return preventWritingThroughSymlink(dest, realOutputPath); + } + + return realOutputPath; + }) + .then(realOutputPath => { + return fsP.realpath(path.dirname(dest)) + .then(realDestinationDir => { + if (realDestinationDir.indexOf(realOutputPath) !== 0) { + throw (new Error('Refusing to write outside output directory: ' + realDestinationDir)); + } + }); + }) .then(() => { if (x.type === 'link') { return fsP.link(x.linkname, dest); diff --git a/package.json b/package.json index ff67270..1ee81e2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "url": "github.com/kevva" }, "engines": { - "node": ">=4" + "node": ">=7.6.0" }, "scripts": { "test": "xo && ava" @@ -41,9 +41,21 @@ }, "devDependencies": { "ava": "*", + "esm": "^3.2.25", "is-jpg": "^1.0.0", "path-exists": "^3.0.0", "pify": "^2.3.0", + "rimraf": "^3.0.2", "xo": "*" + }, + "ava": { + "require": [ + "esm" + ] + }, + "xo": { + "rules": { + "promise/prefer-await-to-then": "off" + } } } diff --git a/test.js b/test.js index d145ba0..ba99d68 100644 --- a/test.js +++ b/test.js @@ -3,10 +3,22 @@ import path from 'path'; import isJpg from 'is-jpg'; import pathExists from 'path-exists'; import pify from 'pify'; +import rimraf from 'rimraf'; import test from 'ava'; import m from '.'; const fsP = pify(fs); +const rimrafP = pify(rimraf); + +test.serial.afterEach('ensure decompressed files and directories are cleaned up', async () => { + await rimrafP(path.join(__dirname, 'directory')); + await rimrafP(path.join(__dirname, 'dist')); + await rimrafP(path.join(__dirname, 'example.txt')); + await rimrafP(path.join(__dirname, 'file.txt')); + await rimrafP(path.join(__dirname, 'edge_case_dots')); + await rimrafP(path.join(__dirname, 'symlink')); + await rimrafP(path.join(__dirname, 'test.jpg')); +}); test('extract file', async t => { const tarFiles = await m(path.join(__dirname, 'fixtures', 'file.tar')); @@ -46,21 +58,16 @@ test.serial('extract file to directory', async t => { t.is(files[0].path, 'test.jpg'); t.true(isJpg(files[0].data)); t.true(await pathExists(path.join(__dirname, 'test.jpg'))); - - await fsP.unlink(path.join(__dirname, 'test.jpg')); }); -test('extract symlink', async t => { +test.serial('extract symlink', async t => { await m(path.join(__dirname, 'fixtures', 'symlink.tar'), __dirname, {strip: 1}); t.is(await fsP.realpath(path.join(__dirname, 'symlink')), path.join(__dirname, 'file.txt')); - await fsP.unlink(path.join(__dirname, 'symlink')); - await fsP.unlink(path.join(__dirname, 'file.txt')); }); -test('extract directory', async t => { +test.serial('extract directory', async t => { await m(path.join(__dirname, 'fixtures', 'directory.tar'), __dirname); t.true(await pathExists(path.join(__dirname, 'directory'))); - await fsP.rmdir(path.join(__dirname, 'directory')); }); test('strip option', async t => { @@ -96,10 +103,58 @@ test.serial('set mtime', async t => { const files = await m(path.join(__dirname, 'fixtures', 'file.tar'), __dirname); const stat = await fsP.stat(path.join(__dirname, 'test.jpg')); t.deepEqual(files[0].mtime, stat.mtime); - await fsP.unlink(path.join(__dirname, 'test.jpg')); }); test('return emptpy array if no plugins are set', async t => { const files = await m(path.join(__dirname, 'fixtures', 'file.tar'), {plugins: []}); t.is(files.length, 0); }); + +test.serial('throw when a location outside the root is given', async t => { + await t.throwsAsync(async () => { + await m(path.join(__dirname, 'fixtures', 'slipping.tar.gz'), 'dist'); + }, {message: /Refusing/}); +}); + +test.serial('throw when a location outside the root including symlinks is given', async t => { + await t.throwsAsync(async () => { + await m(path.join(__dirname, 'fixtures', 'slip.zip'), 'dist'); + }, {message: /Refusing/}); +}); + +test.serial('throw when a top-level symlink outside the root is given', async t => { + await t.throwsAsync(async () => { + await m(path.join(__dirname, 'fixtures', 'slip2.zip'), 'dist'); + }, {message: /Refusing/}); +}); + +test.serial('throw when a directory outside the root including symlinks is given', async t => { + await t.throwsAsync(async () => { + await m(path.join(__dirname, 'fixtures', 'slipping_directory.tar.gz'), 'dist'); + }, {message: /Refusing/}); +}); + +test.serial('allows filenames and directories to be written with dots in their names', async t => { + const files = await m(path.join(__dirname, 'fixtures', 'edge_case_dots.tar.gz'), __dirname); + t.is(files.length, 6); + t.deepEqual(files.map(f => f.path).sort(), [ + 'edge_case_dots/', + 'edge_case_dots/internal_dots..txt', + 'edge_case_dots/sample../', + 'edge_case_dots/ending_dots..', + 'edge_case_dots/x', + 'edge_case_dots/sample../test.txt' + ].sort()); +}); + +test.serial('allows top-level file', async t => { + const files = await m(path.join(__dirname, 'fixtures', 'top_level_example.tar.gz'), 'dist'); + t.is(files.length, 1); + t.is(files[0].path, 'example.txt'); +}); + +test.serial('throw when chained symlinks to /tmp/dist allow escape outside root directory', async t => { + await t.throwsAsync(async () => { + await m(path.join(__dirname, 'fixtures', 'slip3.zip'), '/tmp/dist'); + }, {message: /Refusing/}); +});