Skip to content

Commit b9bdc9e

Browse files
committed
fix: path traversal (#474)
1 parent 6b982a4 commit b9bdc9e

9 files changed

Lines changed: 313 additions & 16 deletions

File tree

‎server/markdown/index.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ function parseName(query, name, rootDir) {
3232
const shortName = name.replace('/markdown', '');
3333

3434
if (query === 'relative')
35-
return DIR_ROOT + shortName;
35+
return root.resolve(shortName, DIR_ROOT);
3636

37-
return root(shortName, rootDir);
37+
return root.resolve(shortName, rootDir);
3838
}
3939

4040
async function onGET(request, name, root) {

‎server/markdown/index.spec.js‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import fs from 'node:fs';
22
import {join} from 'node:path';
3+
import {tmpdir} from 'node:os';
34
import {promisify} from 'node:util';
45
import {tryToCatch} from 'try-to-catch';
56
import test from 'supertape';
@@ -126,3 +127,25 @@ test('cloudcmd: markdown: zip', async (t) => {
126127
t.equal(body, '<h1>hello</h1>\n');
127128
t.end();
128129
});
130+
131+
test('cloudcmd: markdown: relative: path traversal', async (t) => {
132+
const configManager = cloudcmd.createConfigManager();
133+
const sandboxDir = fs.mkdtempSync(join(tmpdir(), 'cloudcmd-test-'));
134+
135+
const config = {
136+
auth: false,
137+
root: sandboxDir,
138+
};
139+
140+
const {request} = serveOnce(cloudcmd, {
141+
config,
142+
configManager,
143+
});
144+
145+
const {body} = await request.get('/api/v1/markdown/..%2f..%2f..%2fetc%2fpasswd?relative');
146+
147+
fs.rmdirSync(sandboxDir);
148+
149+
t.match(body, 'beyond root', 'should reject url-encoded traversal');
150+
t.end();
151+
});

‎server/rest/index.js‎

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import json from 'jonny';
1111
import * as ponse from 'ponse';
1212
import {copymitter} from 'copymitter';
1313
import {moveFiles as _moveFiles} from '@cloudcmd/move-files';
14+
import {tryCatch} from 'try-catch';
1415
import * as CloudFunc from '#common/cloudfunc';
1516
import root from '../root.js';
1617
import markdown from '../markdown/index.js';
@@ -216,8 +217,15 @@ function onPUT({name, fs, moveFiles, config, body}, callback) {
216217
const msg = getMoveMsg(names);
217218
const fn = swap(callback, msg);
218219

219-
const fromRooted = root(from, rootDir);
220-
const toRooted = root(to, rootDir);
220+
const [errorFrom, fromRooted] = tryCatch(root.resolve, from, rootDir);
221+
222+
if (errorFrom)
223+
return callback(errorFrom);
224+
225+
const [errorTo, toRooted] = tryCatch(root.resolve, to, rootDir);
226+
227+
if (errorTo)
228+
return callback(errorTo);
221229

222230
return moveFiles(fromRooted, toRooted, names)
223231
.on('error', fn)
@@ -227,21 +235,32 @@ function onPUT({name, fs, moveFiles, config, body}, callback) {
227235
case 'rename':
228236
return rename(rootDir, files.from, files.to, fs, callback);
229237

230-
case 'copy':
238+
case 'copy': {
231239
if (!files.from || !files.names || !files.to)
232240
return callback(body);
233241

234242
if (isRootAll(rootDir, [files.to, files.from]))
235243
return callback(getWin32RootMsg());
236244

237-
files.from = root(files.from, rootDir);
238-
files.to = root(files.to, rootDir);
245+
const [errorFrom, resolvedFrom] = tryCatch(root.resolve, files.from, rootDir);
246+
247+
if (errorFrom)
248+
return callback(errorFrom);
249+
250+
const [errorTo, resolvedTo] = tryCatch(root.resolve, files.to, rootDir);
251+
252+
if (errorTo)
253+
return callback(errorTo);
254+
255+
files.from = resolvedFrom;
256+
files.to = resolvedTo;
239257

240258
copy(files.from, files.to, files.names, (error) => {
241259
const msg = formatMsg('copy', files.names);
242260
callback(error, msg);
243261
});
244262
break;
263+
}
245264

246265
case 'pack':
247266
if (!files.from)
@@ -274,8 +293,15 @@ function rename(rootDir, from, to, fs, callback) {
274293
const msg = getRenameMsg(from, to);
275294
const fn = swap(callback, msg);
276295

277-
const fromRooted = root(from, rootDir);
278-
const toRooted = root(to, rootDir);
296+
const [errorFrom, fromRooted] = tryCatch(root.resolve, from, rootDir);
297+
298+
if (errorFrom)
299+
return callback(errorFrom);
300+
301+
const [errorTo, toRooted] = tryCatch(root.resolve, to, rootDir);
302+
303+
if (errorTo)
304+
return callback(errorTo);
279305

280306
return fs.rename(fromRooted, toRooted, fn);
281307
}
@@ -286,8 +312,18 @@ function pack(from, to, names, config, fn) {
286312
const rootDir = config('root');
287313
const packer = config('packer');
288314

289-
from = root(from, rootDir);
290-
to = root(to, rootDir);
315+
const [errorFrom, resolvedFrom] = tryCatch(root.resolve, from, rootDir);
316+
317+
if (errorFrom)
318+
return fn(errorFrom);
319+
320+
const [errorTo, resolvedTo] = tryCatch(root.resolve, to, rootDir);
321+
322+
if (errorTo)
323+
return fn(errorTo);
324+
325+
from = resolvedFrom;
326+
to = resolvedTo;
291327

292328
if (!names) {
293329
names = [
@@ -305,12 +341,23 @@ export const _pack = pack;
305341
function extract(from, to, config, fn) {
306342
const rootDir = config('root');
307343

308-
from = root(from, rootDir);
344+
const [errorFrom, resolvedFrom] = tryCatch(root.resolve, from, rootDir);
309345

310-
if (to)
311-
to = root(to, rootDir);
312-
else
346+
if (errorFrom)
347+
return fn(errorFrom);
348+
349+
from = resolvedFrom;
350+
351+
if (to) {
352+
const [errorTo, resolvedTo] = tryCatch(root.resolve, to, rootDir);
353+
354+
if (errorTo)
355+
return fn(errorTo);
356+
357+
to = resolvedTo;
358+
} else {
313359
to = from.replace(/\.tar\.gz$/, '');
360+
}
314361

315362
operation('extract', config('packer'), from, to, fn);
316363
}

‎server/root.js‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1+
import path from 'node:path';
12
import mellow from 'mellow';
23

3-
export default (dir, root, {webToWin = mellow.webToWin} = {}) => {
4+
const rootFn = (dir, root, {webToWin = mellow.webToWin} = {}) => {
45
return webToWin(dir, root || '/');
56
};
7+
8+
rootFn.resolve = (dir, rootDir) => {
9+
const normalizedRoot = path.normalize(rootDir);
10+
const resolved = rootFn(dir, normalizedRoot);
11+
12+
const sep = normalizedRoot.endsWith(path.sep) ? '' : path.sep;
13+
14+
if (resolved !== normalizedRoot && !resolved.startsWith(normalizedRoot + sep))
15+
throw Error(`Path ${resolved} beyond root ${normalizedRoot}!`);
16+
17+
return resolved;
18+
};
19+
20+
export default rootFn;

‎test/rest/copy.js‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,45 @@ test('cloudcmd: rest: copy', async (t) => {
4242
t.equal(body, 'copy: ok("["copy.txt"]")', 'should return result');
4343
t.end();
4444
});
45+
46+
test('cloudcmd: rest: copy: path traversal: from', async (t) => {
47+
const configManager = cloudcmd.createConfigManager();
48+
configManager('auth', false);
49+
configManager('root', '/sandbox');
50+
51+
const {request} = serveOnce(cloudcmd, {
52+
configManager,
53+
});
54+
55+
const {body} = await request.put('/api/v1/copy', {
56+
body: {
57+
from: '../../../../etc/passwd',
58+
to: '/file.txt',
59+
names: ['copy.txt'],
60+
},
61+
});
62+
63+
t.equal(body, 'Path /etc/passwd beyond root /sandbox!', 'should reject traversal in from');
64+
t.end();
65+
});
66+
67+
test('cloudcmd: rest: copy: path traversal: to', async (t) => {
68+
const configManager = cloudcmd.createConfigManager();
69+
configManager('auth', false);
70+
configManager('root', '/sandbox');
71+
72+
const {request} = serveOnce(cloudcmd, {
73+
configManager,
74+
});
75+
76+
const {body} = await request.put('/api/v1/copy', {
77+
body: {
78+
from: '/file.txt',
79+
to: '../../../../etc/cron.d/evil',
80+
names: ['copy.txt'],
81+
},
82+
});
83+
84+
t.equal(body, 'Path /etc/cron.d/evil beyond root /sandbox!', 'should reject traversal in to');
85+
t.end();
86+
});

‎test/rest/extract.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import test from 'supertape';
2+
import {serveOnce} from 'serve-once';
3+
import {cloudcmd} from '#server/cloudcmd';
4+
5+
test('cloudcmd: rest: extract: path traversal: from', async (t) => {
6+
const configManager = cloudcmd.createConfigManager();
7+
configManager('auth', false);
8+
configManager('root', '/sandbox');
9+
10+
const {request} = serveOnce(cloudcmd, {
11+
configManager,
12+
});
13+
14+
const {body} = await request.put('/api/v1/extract', {
15+
body: {
16+
from: '../../../../tmp/cc-outside/evil.zip',
17+
},
18+
});
19+
20+
t.equal(body, 'Path /tmp/cc-outside/evil.zip beyond root /sandbox!', 'should reject traversal in from');
21+
t.end();
22+
});

‎test/rest/move.js‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,45 @@ test('cloudcmd: rest: move: no to', async (t) => {
8585
t.equal(body, expected);
8686
t.end();
8787
});
88+
89+
test('cloudcmd: rest: move: path traversal: from', async (t) => {
90+
const configManager = cloudcmd.createConfigManager();
91+
configManager('auth', false);
92+
configManager('root', '/sandbox');
93+
94+
const {request} = serveOnce(cloudcmd, {
95+
configManager,
96+
});
97+
98+
const {body} = await request.put('/api/v1/move', {
99+
body: {
100+
from: '../../../../etc/passwd',
101+
to: '/file.txt',
102+
names: ['move.txt'],
103+
},
104+
});
105+
106+
t.equal(body, 'Path /etc/passwd beyond root /sandbox!', 'should reject traversal in from');
107+
t.end();
108+
});
109+
110+
test('cloudcmd: rest: move: path traversal: to', async (t) => {
111+
const configManager = cloudcmd.createConfigManager();
112+
configManager('auth', false);
113+
configManager('root', '/sandbox');
114+
115+
const {request} = serveOnce(cloudcmd, {
116+
configManager,
117+
});
118+
119+
const {body} = await request.put('/api/v1/move', {
120+
body: {
121+
from: '/file.txt',
122+
to: '../../../../etc/cron.d/evil',
123+
names: ['move.txt'],
124+
},
125+
});
126+
127+
t.equal(body, 'Path /etc/cron.d/evil beyond root /sandbox!', 'should reject traversal in to');
128+
t.end();
129+
});

‎test/rest/pack.js‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,45 @@ const getPackOptions = (to, names = ['pack']) => ({
229229
names,
230230
from: '/fixture',
231231
});
232+
233+
test('cloudcmd: rest: pack: path traversal: from', async (t) => {
234+
const configManager = cloudcmd.createConfigManager();
235+
configManager('auth', false);
236+
configManager('root', '/sandbox');
237+
238+
const {request} = serveOnce(cloudcmd, {
239+
configManager,
240+
});
241+
242+
const {body} = await request.put('/api/v1/pack', {
243+
body: {
244+
from: '../../../../etc',
245+
to: '/archive.zip',
246+
names: ['passwd'],
247+
},
248+
});
249+
250+
t.equal(body, 'Path /etc beyond root /sandbox!', 'should reject traversal in from');
251+
t.end();
252+
});
253+
254+
test('cloudcmd: rest: pack: path traversal: to', async (t) => {
255+
const configManager = cloudcmd.createConfigManager();
256+
configManager('auth', false);
257+
configManager('root', '/sandbox');
258+
259+
const {request} = serveOnce(cloudcmd, {
260+
configManager,
261+
});
262+
263+
const {body} = await request.put('/api/v1/pack', {
264+
body: {
265+
from: '/files',
266+
to: '../../../../tmp/cc-outside/evil.zip',
267+
names: ['file.txt'],
268+
},
269+
});
270+
271+
t.equal(body, 'Path /tmp/cc-outside/evil.zip beyond root /sandbox!', 'should reject traversal in to');
272+
t.end();
273+
});

0 commit comments

Comments
 (0)