Skip to content

Commit 2edab36

Browse files
committedMar 30, 2022
Sanitize filenames with loadAsync to prevent zip slip attacks
1 parent 1f631b0 commit 2edab36

File tree

9 files changed

+113
-15
lines changed

9 files changed

+113
-15
lines changed
 

‎CHANGES.md

+5-1
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ layout: default
44
section: main
55
---
66

7+
### v3.8.0 2022-03-30
8+
9+
- Santize filenames when files are loaded with `loadAsync`, to avoid ["zip slip" attacks](https://snyk.io/research/zip-slip-vulnerability). The original filename is available on each zip entry as `unsafeOriginalName`. See the [documentation](https://stuk.github.io/jszip/documentation/api_jszip/load_async.html). Many thanks to McCaulay Hudson for reporting.
10+
711
### v3.7.1 2021-08-05
812

913
- Fix build of `dist` files.
10-
+ Note: this version ensures the changes from 3.7.0 are actually included in the `dist` files. Thanks to Evan W for reporting.
14+
+ Note: this version ensures the changes from 3.7.0 are actually included in the `dist` files. Thanks to Evan W for reporting.
1115

1216
### v3.7.0 2021-07-23
1317

‎dist/jszip.js

+37-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/*!
22
3-
JSZip v3.7.1 - A JavaScript class for generating and reading zip files
3+
JSZip v3.8.0 - A JavaScript class for generating and reading zip files
44
<http://stuartk.com/jszip>
55
66
(c) 2009-2016 Stuart Knightley <stuart [at] stuartk.com>
@@ -1059,7 +1059,7 @@ JSZip.defaults = require('./defaults');
10591059

10601060
// TODO find a better way to handle this version,
10611061
// a require('package.json').version doesn't work with webpack, see #327
1062-
JSZip.version = "3.7.1";
1062+
JSZip.version = "3.8.0";
10631063

10641064
JSZip.loadAsync = function (content, options) {
10651065
return new JSZip().loadAsync(content, options);
@@ -1132,7 +1132,11 @@ module.exports = function (data, options) {
11321132
var files = zipEntries.files;
11331133
for (var i = 0; i < files.length; i++) {
11341134
var input = files[i];
1135-
zip.file(input.fileNameStr, input.decompressed, {
1135+
1136+
var unsafeName = input.fileNameStr;
1137+
var safeName = utils.resolve(input.fileNameStr);
1138+
1139+
zip.file(safeName, input.decompressed, {
11361140
binary: true,
11371141
optimizedBinaryString: true,
11381142
date: input.date,
@@ -1142,6 +1146,9 @@ module.exports = function (data, options) {
11421146
dosPermissions: input.dosPermissions,
11431147
createFolders: options.createFolders
11441148
});
1149+
if (!input.dir) {
1150+
zip.file(safeName).unsafeOriginalName = unsafeName;
1151+
}
11451152
}
11461153
if (zipEntries.zipComment.length) {
11471154
zip.comment = zipEntries.zipComment;
@@ -3352,6 +3359,31 @@ exports.transformTo = function(outputType, input) {
33523359
return result;
33533360
};
33543361

3362+
/**
3363+
* Resolve all relative path components, "." and "..", in a path. If these relative components
3364+
* traverse above the root then the resulting path will only contain the final path component.
3365+
*
3366+
* All empty components, e.g. "//", are removed.
3367+
* @param {string} path A path with / or \ separators
3368+
* @returns {string} The path with all relative path components resolved.
3369+
*/
3370+
exports.resolve = function(path) {
3371+
var parts = path.split("/");
3372+
var result = [];
3373+
for (var index = 0; index < parts.length; index++) {
3374+
var part = parts[index];
3375+
// Allow the first and last component to be empty for trailing slashes.
3376+
if (part === "." || (part === "" && index !== 0 && index !== parts.length - 1)) {
3377+
continue;
3378+
} else if (part === "..") {
3379+
result.pop();
3380+
} else {
3381+
result.push(part);
3382+
}
3383+
}
3384+
return result.join("/");
3385+
};
3386+
33553387
/**
33563388
* Return the type of the input.
33573389
* The type will be in a format valid for JSZip.utils.transformTo : string, array, uint8array, arraybuffer.
@@ -3460,8 +3492,8 @@ exports.prepareContent = function(name, inputData, isBinary, isOptimizedBinarySt
34603492

34613493
// if inputData is already a promise, this flatten it.
34623494
var promise = external.Promise.resolve(inputData).then(function(data) {
3463-
3464-
3495+
3496+
34653497
var isBlob = support.blob && (data instanceof Blob || ['[object File]', '[object Blob]'].indexOf(Object.prototype.toString.call(data)) !== -1);
34663498

34673499
if (isBlob && typeof FileReader !== "undefined") {

‎dist/jszip.min.js

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎documentation/api_jszip/load_async.md

+23
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ object at the current folder level. This technique has some limitations, see
1010
If the JSZip object already contains entries, new entries will be merged. If
1111
two have the same name, the loaded one will replace the other.
1212

13+
Since v3.8.0 this method will santize relative path components (i.e. `..`) in loaded filenames to avoid ["zip slip" attacks](https://snyk.io/research/zip-slip-vulnerability). For example: `../../../example.txt``example.txt`, `src/images/../example.txt``src/example.txt`. The original filename is available on each zip entry as `unsafeOriginalName`.
14+
1315
__Returns__ : A [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the updated zip object.
1416
The promise can fail if the loaded data is not valid zip data or if it
1517
uses unsupported features (multi volume, password protected, etc).
@@ -194,3 +196,24 @@ zip.loadAsync(bin1)
194196
// file3.txt, from bin2
195197
});
196198
```
199+
200+
Reading a zip file with relative filenames:
201+
202+
```js
203+
// here, "unsafe.zip" is zip file containing:
204+
// src/images/../file.txt
205+
// ../../example.txt
206+
207+
require("fs").readFile("unsafe.zip", function (err, data) {
208+
if (err) throw err;
209+
var zip = new JSZip();
210+
zip.loadAsync(data)
211+
.then(function (zip) {
212+
console.log(zip.files);
213+
// src/file.txt
214+
// example.txt
215+
console.log(zip.files["example.txt"].unsafeOriginalName);
216+
// "../../example.txt"
217+
});
218+
}
219+
```

‎index.d.ts

+5
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ declare namespace JSZip {
6565

6666
interface JSZipObject {
6767
name: string;
68+
/**
69+
* Present for files loadded with `loadAsync`. May contain ".." path components that could
70+
* result in a zip-slip attack. See https://snyk.io/research/zip-slip-vulnerability
71+
*/
72+
unsafeOriginalName?: string;
6873
dir: boolean;
6974
date: Date;
7075
comment: string;

‎lib/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ JSZip.defaults = require('./defaults');
4545

4646
// TODO find a better way to handle this version,
4747
// a require('package.json').version doesn't work with webpack, see #327
48-
JSZip.version = "3.7.1";
48+
JSZip.version = "3.8.0";
4949

5050
JSZip.loadAsync = function (content, options) {
5151
return new JSZip().loadAsync(content, options);

‎lib/load.js

+8-1
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ module.exports = function (data, options) {
6161
var files = zipEntries.files;
6262
for (var i = 0; i < files.length; i++) {
6363
var input = files[i];
64-
zip.file(input.fileNameStr, input.decompressed, {
64+
65+
var unsafeName = input.fileNameStr;
66+
var safeName = utils.resolve(input.fileNameStr);
67+
68+
zip.file(safeName, input.decompressed, {
6569
binary: true,
6670
optimizedBinaryString: true,
6771
date: input.date,
@@ -71,6 +75,9 @@ module.exports = function (data, options) {
7175
dosPermissions: input.dosPermissions,
7276
createFolders: options.createFolders
7377
});
78+
if (!input.dir) {
79+
zip.file(safeName).unsafeOriginalName = unsafeName;
80+
}
7481
}
7582
if (zipEntries.zipComment.length) {
7683
zip.comment = zipEntries.zipComment;

‎lib/utils.js

+27-2
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,31 @@ exports.transformTo = function(outputType, input) {
317317
return result;
318318
};
319319

320+
/**
321+
* Resolve all relative path components, "." and "..", in a path. If these relative components
322+
* traverse above the root then the resulting path will only contain the final path component.
323+
*
324+
* All empty components, e.g. "//", are removed.
325+
* @param {string} path A path with / or \ separators
326+
* @returns {string} The path with all relative path components resolved.
327+
*/
328+
exports.resolve = function(path) {
329+
var parts = path.split("/");
330+
var result = [];
331+
for (var index = 0; index < parts.length; index++) {
332+
var part = parts[index];
333+
// Allow the first and last component to be empty for trailing slashes.
334+
if (part === "." || (part === "" && index !== 0 && index !== parts.length - 1)) {
335+
continue;
336+
} else if (part === "..") {
337+
result.pop();
338+
} else {
339+
result.push(part);
340+
}
341+
}
342+
return result.join("/");
343+
};
344+
320345
/**
321346
* Return the type of the input.
322347
* The type will be in a format valid for JSZip.utils.transformTo : string, array, uint8array, arraybuffer.
@@ -425,8 +450,8 @@ exports.prepareContent = function(name, inputData, isBinary, isOptimizedBinarySt
425450

426451
// if inputData is already a promise, this flatten it.
427452
var promise = external.Promise.resolve(inputData).then(function(data) {
428-
429-
453+
454+
430455
var isBlob = support.blob && (data instanceof Blob || ['[object File]', '[object Blob]'].indexOf(Object.prototype.toString.call(data)) !== -1);
431456

432457
if (isBlob && typeof FileReader !== "undefined") {

‎test/asserts/utils.js

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
/* global QUnit,JSZip,JSZipTestUtils */
22
'use strict';
33

4+
// These tests only run in Node
45
var utils = require("../../lib/utils");
56

67
QUnit.module("utils");
78

89
QUnit.test("Paths are resolved correctly", function (assert) {
9-
assert.strictEqual(utils.resolve("root\\a\\b"), "root/a/b");
10+
// Backslashes can be part of filenames
11+
assert.strictEqual(utils.resolve("root\\a\\b"), "root\\a\\b");
1012
assert.strictEqual(utils.resolve("root/a/b"), "root/a/b");
1113
assert.strictEqual(utils.resolve("root/a/.."), "root");
1214
assert.strictEqual(utils.resolve("root/a/../b"), "root/b");
1315
assert.strictEqual(utils.resolve("root/a/./b"), "root/a/b");
1416
assert.strictEqual(utils.resolve("root/../../../"), "");
15-
assert.strictEqual(utils.resolve("////"), "");
16-
assert.strictEqual(utils.resolve("/a/b/c"), "a/b/c");
17+
assert.strictEqual(utils.resolve("////"), "/");
18+
assert.strictEqual(utils.resolve("/a/b/c"), "/a/b/c");
1719
assert.strictEqual(utils.resolve("a/b/c/"), "a/b/c/");
1820
assert.strictEqual(utils.resolve("../../../../../a"), "a");
1921
assert.strictEqual(utils.resolve("../app.js"), "app.js");

0 commit comments

Comments
 (0)
Please sign in to comment.