diff --git a/mod.ts b/mod.ts index d5a5a5d..9f6855f 100644 --- a/mod.ts +++ b/mod.ts @@ -1,9 +1,9 @@ -import _JSZip from "https://dev.jspm.io/jszip@3.5.0"; +import _JSZip from "https://dev.jspm.io/jszip@3.5.0"; import { WalkOptions, walk } from "https://deno.land/std@0.99.0/fs/walk.ts"; -import { SEP, join } from "https://deno.land/std@0.99.0/path/mod.ts"; +import { SEP, resolve, dirname, globToRegExp } from "https://deno.land/std@0.99.0/path/mod.ts"; import type { InputFileFormat, - JSZipFileOptions, + JSZipAddFileOptions, JSZipGeneratorOptions, JSZipLoadOptions, JSZipObject, @@ -107,10 +107,13 @@ export class JSZip { addFile( path: string, content?: string | Uint8Array, - options?: JSZipFileOptions, + options?: JSZipAddFileOptions, ): JSZipObject { + const replaceBackslashes = options?.replaceBackslashes === undefined || options.replaceBackslashes; + const finalPath = replaceBackslashes ? path.replaceAll("\\", "/") : path; + // @ts-ignores - const f = this._z.file(path, content, options); + const f = this._z.file(finalPath, content, options); return f as JSZipObject; } @@ -192,16 +195,27 @@ export class JSZip { */ async unzip(dir: string = "."): Promise { // FIXME optionally replace the existing folder prefix with dir. - for (const f of this) { - const ff = join(dir, f.name); - if (f.dir) { - // hopefully the directory is prior to any files inside it! - await Deno.mkdir(ff, { recursive: true }); - continue; + const createdDirs = new Set(); + const allowedFileLocRegex = globToRegExp(resolve(dir, "**")); + + for (const fileEntry of this) { + const filePath = resolve(dir, fileEntry.name); + if (!allowedFileLocRegex.test(filePath)) { + throw new Error("Not allowed!"); + } + + const dirPath = fileEntry.dir ? filePath : dirname(filePath); + + if (!createdDirs.has(dirPath)) { + await Deno.mkdir(dirPath, { recursive: true }); + createdDirs.add(dirPath); + } + + if (!fileEntry.dir) { + const content = await fileEntry.async("uint8array"); + // TODO pass WriteFileOptions e.g. mode + await Deno.writeFile(filePath, content); } - const content = await f.async("uint8array"); - // TODO pass WriteFileOptions e.g. mode - await Deno.writeFile(ff, content); } } diff --git a/test.ts b/test.ts index a966c88..794deb9 100644 --- a/test.ts +++ b/test.ts @@ -1,19 +1,39 @@ import { decode, encode } from "https://deno.land/std@0.74.0/encoding/utf8.ts"; import { join } from "https://deno.land/std@0.74.0/path/mod.ts"; -import { assertEquals } from "https://deno.land/std@0.74.0/testing/asserts.ts"; +import { assert, assertEquals, assertThrowsAsync } from "https://deno.land/std@0.74.0/testing/asserts.ts"; import { JSZip, readZip, zipDir } from "./mod.ts"; +import { exists } from "https://deno.land/std@0.100.0/fs/mod.ts"; // FIXME use tmp directory and clean up. -async function exampleZip(path: string) { +async function exampleZip(path: string, createDirectories = true) { const zip = new JSZip(); zip.addFile("Hello.txt", "Hello World\n"); - const img = zip.folder("images"); - img.addFile("smile.gif", "\0", { base64: true }); + if (createDirectories) { + const img = zip.folder("images"); + img.addFile("smile.gif", "\0", { base64: true }); + } + else { + // Use backslashed for edge case where directory is missing. + zip.addFile("images\\smile.gif", "\0", { base64: true }) + } await zip.writeZip(path); } +// Used for testing path exploits +async function pathExploitExampleZip() { + const zip = new JSZip(); + zip.addFile("../Hello.txt", "Hello World\n"); + + const tempFileName = await Deno.makeTempFile({ + suffix: ".zip" + }); + + await zip.writeZip(tempFileName); + return tempFileName; +} + async function fromDir(dir: string, f: () => Promise) { const cwd = Deno.cwd(); Deno.chdir(dir); @@ -75,3 +95,35 @@ Deno.test("unzip", async () => { const smile = await Deno.readFile(join(dir, "images", "smile.gif")); assertEquals("", decode(smile)); }); + +Deno.test("unzip without dir", async () => { + const dir = await Deno.makeTempDir(); + + await exampleZip("example.zip", false); + const z = await readZip("example.zip"); + await z.unzip(dir); + + const content = await Deno.readFile(join(dir, "Hello.txt")); + assertEquals("Hello World\n", decode(content)); + + const smile = await Deno.readFile(join(dir, "images", "smile.gif")); + assertEquals("", decode(smile)); +}); + +Deno.test("unzip exploit test", async () => { + const dir = await Deno.makeTempDir(); + const unpackDir = join(dir, "unpack"); + await Deno.mkdir(unpackDir); + + const zipFile = await pathExploitExampleZip(); + const z = await readZip(zipFile); + + await Deno.remove(zipFile); + + assertThrowsAsync(async () => await z.unzip(unpackDir)); + assert(!(await exists(join(dir, "Hello.txt")))); + + await Deno.remove(dir, { + recursive: true + }); +}); \ No newline at end of file diff --git a/types.ts b/types.ts index ca685ae..4099bd2 100644 --- a/types.ts +++ b/types.ts @@ -58,6 +58,10 @@ export interface JSZipFileOptions { unixPermissions?: number | string | null; } +export interface JSZipAddFileOptions extends JSZipFileOptions { + replaceBackslashes?: boolean; +} + export interface JSZipGeneratorOptions { compression?: Compression; compressionOptions?: null | {