Skip to content

Commit a05ca42

Browse files
committed
feat: safe zip reader (VF-000) (#71)
<!-- You can erase any parts of this template not applicable to your Pull Request. --> **Fixes or implements VF-000** ### Brief description. What is this change? Naively reading from a zip file is dangerous as it can potentially crash a system with a [zip bomb](https://en.wikipedia.org/wiki/Zip_bomb). This provides an interface to safely read all files from a zip in a recursive manner by providing checks for number of files and total size read. Co-authored-by: Tyler Stewart <[email protected]>
1 parent 8690870 commit a05ca42

File tree

9 files changed

+247
-13
lines changed

9 files changed

+247
-13
lines changed

package.json

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,29 @@
1212
}
1313
},
1414
"dependencies": {
15-
"@types/chai": "^4.2.16",
16-
"@types/ioredis": "^4.26.4",
17-
"@types/lodash": "^4.14.168",
18-
"@types/luxon": "^1.26.4",
19-
"@types/sinon": "^10.0.0",
2015
"@voiceflow/logger": "1.6.1",
2116
"@voiceflow/verror": "^1.1.0",
22-
"chai": "^4.3.4",
2317
"dotenv": "^10.0.0",
2418
"http-errors": "^2.0.0",
2519
"http-status": "^1.4.2",
2620
"lodash": "^4.17.11",
2721
"luxon": "^1.21.3",
28-
"rate-limiter-flexible": "^2.2.2",
29-
"sinon": "^10.0.0"
22+
"rate-limiter-flexible": "^2.2.2"
3023
},
3124
"devDependencies": {
3225
"@commitlint/cli": "16.1.0",
3326
"@istanbuljs/nyc-config-typescript": "1.0.2",
3427
"@types/axios": "^0.14.0",
28+
"@types/chai": "^4.2.16",
3529
"@types/chai-as-promised": "^7.1.4",
3630
"@types/express": "^4.17.13",
3731
"@types/http-errors": "^1.8.2",
32+
"@types/ioredis": "^4.26.4",
33+
"@types/lodash": "^4.14.168",
34+
"@types/luxon": "^1.26.4",
3835
"@types/mocha": "9.1.0",
36+
"@types/node": "^17.0.23",
37+
"@types/sinon": "^10.0.0",
3938
"@types/supertest": "^2.0.11",
4039
"@voiceflow/commitlint-config": "2.0.0",
4140
"@voiceflow/common": "7.22.0",
@@ -44,6 +43,7 @@
4443
"@voiceflow/prettier-config": "1.0.6",
4544
"@voiceflow/tsconfig": "1.2.5",
4645
"@zerollup/ts-transform-paths": "^1.7.18",
46+
"chai": "^4.3.4",
4747
"chai-as-promised": "^7.1.1",
4848
"commitizen": "4.2.4",
4949
"cz-conventional-changelog": "^3.3.0",
@@ -54,11 +54,13 @@
5454
"express-validator": "^6.10.1",
5555
"fixpack": "^4.0.0",
5656
"husky": "^4.3.8",
57+
"jszip": "^3.7.1",
5758
"lint-staged": "12.2.2",
5859
"mocha": "9.1.4",
5960
"nyc": "^15.1.0",
6061
"prettier": "^2.2.1",
6162
"rimraf": "^3.0.2",
63+
"sinon": "^10.0.0",
6264
"supertest": "6.2.2",
6365
"ts-mocha": "9.0.2",
6466
"ttypescript": "1.5.13",
@@ -76,7 +78,8 @@
7678
"peerDependencies": {
7779
"@types/express": "^4.17.13",
7880
"@voiceflow/common": "^7.2.0",
79-
"express-validator": "^6.3.0"
81+
"express-validator": "^6.3.0",
82+
"jszip": "^3.7.1"
8083
},
8184
"prettier": "@voiceflow/prettier-config",
8285
"repository": {

src/common/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './zip';

src/common/zip/guard.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
const ZIP_MAGIC_BYTES = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
2+
3+
export const isZipFile = (content: Uint8Array) => {
4+
if (content.length < ZIP_MAGIC_BYTES.length) return false;
5+
return ZIP_MAGIC_BYTES.every((byte, i) => content[i] === byte);
6+
};

src/common/zip/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './guard';
2+
export * from './reader';

src/common/zip/reader.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import JSZip from 'jszip';
2+
3+
import { isZipFile } from './guard';
4+
5+
const DEFAULT_MAX_FILE_COUNT = 10000;
6+
const DEFAULT_MAX_UNZIP_SIZE_BYTES = 1e9; // 1GB
7+
const DEFAULT_MAX_ZIP_RECURSION_DEPTH = Infinity;
8+
9+
/**
10+
* Provides an interface to safely read all files from a zip.
11+
*
12+
* Ensure `jszip` is installed as a dependency to use.
13+
*/
14+
export interface ZipReaderConfig {
15+
/**
16+
* Provides protection from zip bombs by only reading up to this many files.
17+
* Will throw an error if the zip file exceeds this count.
18+
*/
19+
maxFileCount: number;
20+
/**
21+
* Provides protection from zip bombs by only reading up to this many bytes total.
22+
* Will throw an error if the total bytes exceeds this count.
23+
*/
24+
maxUnzipSizeBytes: number;
25+
/**
26+
* If the zip file contains zip files, they will be extracted up to this many deep.
27+
* If the depth exceeds this number, then these deeper zip files will be returned instead of extracted.
28+
*
29+
* `0` means do not recurse.
30+
*/
31+
maxZipRecursionDepth: number;
32+
}
33+
34+
export interface ZipEntry {
35+
name: string;
36+
content: Uint8Array;
37+
}
38+
39+
export class ZipReader {
40+
private readonly config: ZipReaderConfig;
41+
42+
public constructor(private readonly zip: JSZip, config: Partial<ZipReaderConfig> = {}) {
43+
this.config = {
44+
maxFileCount: DEFAULT_MAX_FILE_COUNT,
45+
maxUnzipSizeBytes: DEFAULT_MAX_UNZIP_SIZE_BYTES,
46+
maxZipRecursionDepth: DEFAULT_MAX_ZIP_RECURSION_DEPTH,
47+
...config,
48+
};
49+
}
50+
51+
public async *getFiles(): AsyncGenerator<ZipEntry> {
52+
yield* this.getFilesRecursively(this.zip, this.config.maxZipRecursionDepth);
53+
}
54+
55+
private async *getFilesRecursively(zip: JSZip, maxDepth: number, currentDepth = 0): AsyncGenerator<ZipEntry> {
56+
const objects = zip.filter(() => true);
57+
58+
let fileCount = 0;
59+
let totalSize = 0;
60+
61+
// eslint-disable-next-line no-restricted-syntax
62+
for (const obj of objects) {
63+
if (obj.dir) continue;
64+
65+
fileCount += 1;
66+
if (fileCount > this.config.maxFileCount) {
67+
throw new Error(`File count exceeded maximum (${this.config.maxFileCount})`);
68+
}
69+
70+
// eslint-disable-next-line no-await-in-loop
71+
const content = await obj.async('uint8array');
72+
73+
totalSize += content.byteLength;
74+
if (totalSize > this.config.maxUnzipSizeBytes) {
75+
throw new Error(`Total file size exceeded maximum (${this.config.maxUnzipSizeBytes} bytes)`);
76+
}
77+
78+
if (currentDepth < maxDepth && isZipFile(content)) {
79+
// eslint-disable-next-line no-await-in-loop
80+
const zip = await JSZip.loadAsync(content);
81+
yield* this.getFilesRecursively(zip, maxDepth, currentDepth + 1);
82+
} else {
83+
yield {
84+
name: obj.name,
85+
content,
86+
};
87+
}
88+
}
89+
}
90+
}

src/fixtureGenerator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/* eslint-disable no-param-reassign,@typescript-eslint/no-empty-function */
2+
// eslint-disable-next-line import/no-extraneous-dependencies
23
import { expect } from 'chai';
34
import { Request, Response } from 'express';
45
import _ from 'lodash';
6+
// eslint-disable-next-line import/no-extraneous-dependencies
57
import sinon from 'sinon';
68

79
import { ServiceManager } from './types';

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './clients';
2+
export * as Common from './common';
23
export * from './env';
34
export { default as FixtureGenerator } from './fixtureGenerator';
45
export * from './middlewares';

tests/common/zip/reader.unit.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { expect } from 'chai';
2+
import JSZip from 'jszip';
3+
4+
import { ZipReader } from '../../../src/common/zip';
5+
6+
describe('ZipReader', () => {
7+
let zip: JSZip;
8+
9+
const asyncArrayFrom = async <T>(iterator: AsyncIterableIterator<T>): Promise<T[]> => {
10+
const array: T[] = [];
11+
// eslint-disable-next-line no-restricted-syntax
12+
for await (const item of iterator) {
13+
array.push(item);
14+
}
15+
return array;
16+
};
17+
18+
beforeEach(() => {
19+
zip = new JSZip();
20+
zip.file('file.json', JSON.stringify({ hello: 'world' }));
21+
zip.file('nested/file.zip', new JSZip().file('inside.json', JSON.stringify({ a: 1 })).generateNodeStream());
22+
});
23+
24+
afterEach(() => {
25+
(zip as any) = undefined;
26+
});
27+
28+
it('deeply reads all files, unzips internal zips', async () => {
29+
const reader = new ZipReader(zip);
30+
const files = await asyncArrayFrom(reader.getFiles());
31+
32+
expect(files.length).to.equal(2);
33+
expect(files[0].name).to.equal('file.json');
34+
expect(new TextDecoder().decode(files[0].content)).to.equal(JSON.stringify({ hello: 'world' }));
35+
36+
expect(files[1].name).to.equal('inside.json');
37+
expect(new TextDecoder().decode(files[1].content)).to.equal(JSON.stringify({ a: 1 }));
38+
});
39+
40+
it('throws if total size exceeds limit', async () => {
41+
const reader = new ZipReader(zip, {
42+
maxUnzipSizeBytes: 1,
43+
});
44+
expect(asyncArrayFrom(reader.getFiles())).to.eventually.throw(/^Total file size exceeded maximum/);
45+
});
46+
47+
it('throws if total file count exceeds limit', async () => {
48+
const reader = new ZipReader(zip, {
49+
maxFileCount: 1,
50+
});
51+
expect(asyncArrayFrom(reader.getFiles())).to.eventually.throw(/^File count exceeded maximum/);
52+
});
53+
54+
it('limits recursion to limit', async () => {
55+
const reader = new ZipReader(zip, { maxZipRecursionDepth: 0 });
56+
const files = await asyncArrayFrom(reader.getFiles());
57+
58+
expect(files.length).to.equal(2);
59+
expect(files[0].name).to.equal('file.json');
60+
expect(files[1].name).to.equal('nested/file.zip');
61+
});
62+
});

0 commit comments

Comments
 (0)