Skip to content

Commit

Permalink
Sanitize filenames with loadAsync to prevent zip slip attacks
Browse files Browse the repository at this point in the history
  • Loading branch information
Stuk committed Mar 30, 2022
1 parent 1f631b0 commit 2edab36
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 15 deletions.
6 changes: 5 additions & 1 deletion CHANGES.md
Expand Up @@ -4,10 +4,14 @@ layout: default
section: main
---

### v3.8.0 2022-03-30

- 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.

### v3.7.1 2021-08-05

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

### v3.7.0 2021-07-23

Expand Down
42 changes: 37 additions & 5 deletions dist/jszip.js
@@ -1,6 +1,6 @@
/*!
JSZip v3.7.1 - A JavaScript class for generating and reading zip files
JSZip v3.8.0 - A JavaScript class for generating and reading zip files
<http://stuartk.com/jszip>
(c) 2009-2016 Stuart Knightley <stuart [at] stuartk.com>
Expand Down Expand Up @@ -1059,7 +1059,7 @@ JSZip.defaults = require('./defaults');

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

JSZip.loadAsync = function (content, options) {
return new JSZip().loadAsync(content, options);
Expand Down Expand Up @@ -1132,7 +1132,11 @@ module.exports = function (data, options) {
var files = zipEntries.files;
for (var i = 0; i < files.length; i++) {
var input = files[i];
zip.file(input.fileNameStr, input.decompressed, {

var unsafeName = input.fileNameStr;
var safeName = utils.resolve(input.fileNameStr);

zip.file(safeName, input.decompressed, {
binary: true,
optimizedBinaryString: true,
date: input.date,
Expand All @@ -1142,6 +1146,9 @@ module.exports = function (data, options) {
dosPermissions: input.dosPermissions,
createFolders: options.createFolders
});
if (!input.dir) {
zip.file(safeName).unsafeOriginalName = unsafeName;
}
}
if (zipEntries.zipComment.length) {
zip.comment = zipEntries.zipComment;
Expand Down Expand Up @@ -3352,6 +3359,31 @@ exports.transformTo = function(outputType, input) {
return result;
};

/**
* Resolve all relative path components, "." and "..", in a path. If these relative components
* traverse above the root then the resulting path will only contain the final path component.
*
* All empty components, e.g. "//", are removed.
* @param {string} path A path with / or \ separators
* @returns {string} The path with all relative path components resolved.
*/
exports.resolve = function(path) {
var parts = path.split("/");
var result = [];
for (var index = 0; index < parts.length; index++) {
var part = parts[index];
// Allow the first and last component to be empty for trailing slashes.
if (part === "." || (part === "" && index !== 0 && index !== parts.length - 1)) {
continue;
} else if (part === "..") {
result.pop();
} else {
result.push(part);
}
}
return result.join("/");
};

/**
* Return the type of the input.
* The type will be in a format valid for JSZip.utils.transformTo : string, array, uint8array, arraybuffer.
Expand Down Expand Up @@ -3460,8 +3492,8 @@ exports.prepareContent = function(name, inputData, isBinary, isOptimizedBinarySt

// if inputData is already a promise, this flatten it.
var promise = external.Promise.resolve(inputData).then(function(data) {


var isBlob = support.blob && (data instanceof Blob || ['[object File]', '[object Blob]'].indexOf(Object.prototype.toString.call(data)) !== -1);

if (isBlob && typeof FileReader !== "undefined") {
Expand Down
4 changes: 2 additions & 2 deletions dist/jszip.min.js

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions documentation/api_jszip/load_async.md
Expand Up @@ -10,6 +10,8 @@ object at the current folder level. This technique has some limitations, see
If the JSZip object already contains entries, new entries will be merged. If
two have the same name, the loaded one will replace the other.

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`.

__Returns__ : A [Promise](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise) with the updated zip object.
The promise can fail if the loaded data is not valid zip data or if it
uses unsupported features (multi volume, password protected, etc).
Expand Down Expand Up @@ -194,3 +196,24 @@ zip.loadAsync(bin1)
// file3.txt, from bin2
});
```
Reading a zip file with relative filenames:
```js
// here, "unsafe.zip" is zip file containing:
// src/images/../file.txt
// ../../example.txt

require("fs").readFile("unsafe.zip", function (err, data) {
if (err) throw err;
var zip = new JSZip();
zip.loadAsync(data)
.then(function (zip) {
console.log(zip.files);
// src/file.txt
// example.txt
console.log(zip.files["example.txt"].unsafeOriginalName);
// "../../example.txt"
});
}
```
5 changes: 5 additions & 0 deletions index.d.ts
Expand Up @@ -65,6 +65,11 @@ declare namespace JSZip {

interface JSZipObject {
name: string;
/**
* Present for files loadded with `loadAsync`. May contain ".." path components that could
* result in a zip-slip attack. See https://snyk.io/research/zip-slip-vulnerability
*/
unsafeOriginalName?: string;
dir: boolean;
date: Date;
comment: string;
Expand Down
2 changes: 1 addition & 1 deletion lib/index.js
Expand Up @@ -45,7 +45,7 @@ JSZip.defaults = require('./defaults');

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

JSZip.loadAsync = function (content, options) {
return new JSZip().loadAsync(content, options);
Expand Down
9 changes: 8 additions & 1 deletion lib/load.js
Expand Up @@ -61,7 +61,11 @@ module.exports = function (data, options) {
var files = zipEntries.files;
for (var i = 0; i < files.length; i++) {
var input = files[i];
zip.file(input.fileNameStr, input.decompressed, {

var unsafeName = input.fileNameStr;
var safeName = utils.resolve(input.fileNameStr);

zip.file(safeName, input.decompressed, {
binary: true,
optimizedBinaryString: true,
date: input.date,
Expand All @@ -71,6 +75,9 @@ module.exports = function (data, options) {
dosPermissions: input.dosPermissions,
createFolders: options.createFolders
});
if (!input.dir) {
zip.file(safeName).unsafeOriginalName = unsafeName;
}
}
if (zipEntries.zipComment.length) {
zip.comment = zipEntries.zipComment;
Expand Down
29 changes: 27 additions & 2 deletions lib/utils.js
Expand Up @@ -317,6 +317,31 @@ exports.transformTo = function(outputType, input) {
return result;
};

/**
* Resolve all relative path components, "." and "..", in a path. If these relative components
* traverse above the root then the resulting path will only contain the final path component.
*
* All empty components, e.g. "//", are removed.
* @param {string} path A path with / or \ separators
* @returns {string} The path with all relative path components resolved.
*/
exports.resolve = function(path) {
var parts = path.split("/");
var result = [];
for (var index = 0; index < parts.length; index++) {
var part = parts[index];
// Allow the first and last component to be empty for trailing slashes.
if (part === "." || (part === "" && index !== 0 && index !== parts.length - 1)) {
continue;
} else if (part === "..") {
result.pop();
} else {
result.push(part);
}
}
return result.join("/");
};

/**
* Return the type of the input.
* The type will be in a format valid for JSZip.utils.transformTo : string, array, uint8array, arraybuffer.
Expand Down Expand Up @@ -425,8 +450,8 @@ exports.prepareContent = function(name, inputData, isBinary, isOptimizedBinarySt

// if inputData is already a promise, this flatten it.
var promise = external.Promise.resolve(inputData).then(function(data) {


var isBlob = support.blob && (data instanceof Blob || ['[object File]', '[object Blob]'].indexOf(Object.prototype.toString.call(data)) !== -1);

if (isBlob && typeof FileReader !== "undefined") {
Expand Down
8 changes: 5 additions & 3 deletions test/asserts/utils.js
@@ -1,19 +1,21 @@
/* global QUnit,JSZip,JSZipTestUtils */
'use strict';

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

QUnit.module("utils");

QUnit.test("Paths are resolved correctly", function (assert) {
assert.strictEqual(utils.resolve("root\\a\\b"), "root/a/b");
// Backslashes can be part of filenames
assert.strictEqual(utils.resolve("root\\a\\b"), "root\\a\\b");
assert.strictEqual(utils.resolve("root/a/b"), "root/a/b");
assert.strictEqual(utils.resolve("root/a/.."), "root");
assert.strictEqual(utils.resolve("root/a/../b"), "root/b");
assert.strictEqual(utils.resolve("root/a/./b"), "root/a/b");
assert.strictEqual(utils.resolve("root/../../../"), "");
assert.strictEqual(utils.resolve("////"), "");
assert.strictEqual(utils.resolve("/a/b/c"), "a/b/c");
assert.strictEqual(utils.resolve("////"), "/");
assert.strictEqual(utils.resolve("/a/b/c"), "/a/b/c");
assert.strictEqual(utils.resolve("a/b/c/"), "a/b/c/");
assert.strictEqual(utils.resolve("../../../../../a"), "a");
assert.strictEqual(utils.resolve("../app.js"), "app.js");
Expand Down

0 comments on commit 2edab36

Please sign in to comment.