diff --git a/index.d.ts b/index.d.ts index da00026..458458d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -51,6 +51,11 @@ export type OutputType = keyof OutputByType; export 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; diff --git a/lib/load.js b/lib/load.js index 6c27ae9..a4dec77 100644 --- a/lib/load.js +++ b/lib/load.js @@ -62,7 +62,11 @@ export default 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, @@ -72,6 +76,9 @@ export default 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; diff --git a/lib/utils.js b/lib/utils.js index 29ae8fa..c3203ac 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -254,6 +254,31 @@ export const 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. + */ +export const 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. diff --git a/test/asserts/utils.js b/test/asserts/utils.js new file mode 100644 index 0000000..c9987cc --- /dev/null +++ b/test/asserts/utils.js @@ -0,0 +1,36 @@ +/* global QUnit,JSZip,JSZipTestUtils,Promise */ +'use strict'; + +QUnit.module("utils"); + +function resolve(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("/"); +}; + +QUnit.test("Paths are resolved correctly", function (assert) { + // Backslashes can be part of filenames + assert.strictEqual(resolve("root\\a\\b"), "root\\a\\b"); + assert.strictEqual(resolve("root/a/b"), "root/a/b"); + assert.strictEqual(resolve("root/a/.."), "root"); + assert.strictEqual(resolve("root/a/../b"), "root/b"); + assert.strictEqual(resolve("root/a/./b"), "root/a/b"); + assert.strictEqual(resolve("root/../../../"), ""); + assert.strictEqual(resolve("////"), "/"); + assert.strictEqual(resolve("/a/b/c"), "/a/b/c"); + assert.strictEqual(resolve("a/b/c/"), "a/b/c/"); + assert.strictEqual(resolve("../../../../../a"), "a"); + assert.strictEqual(resolve("../app.js"), "app.js"); +}); diff --git a/test/index.html b/test/index.html index e29f79f..ee26a1f 100644 --- a/test/index.html +++ b/test/index.html @@ -63,6 +63,7 @@ +