File size: 4,485 Bytes
7c5ed63 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | // apps/vis/server/src/lib/zip-import.ts
//
// Safe extraction of a user-supplied debug zip into a destination directory.
//
// The zip comes from someone else's machine via `/export-debug-zip`, so it is
// untrusted input: every entry path is validated against path traversal
// ("zip slip"), and total entry-count / uncompressed-size caps guard against
// zip bombs. Only regular files are written; directories are created lazily.
import { createWriteStream } from 'node:fs';
import { mkdir } from 'node:fs/promises';
import { dirname, resolve, sep } from 'node:path';
import { pipeline } from 'node:stream/promises';
import { fromBuffer, type Entry, type ZipFile } from 'yauzl';
export interface ExtractOptions {
/** Reject once this many entries have been seen. */
readonly maxEntries?: number;
/** Reject once the summed uncompressed size exceeds this many bytes. */
readonly maxTotalBytes?: number;
}
const DEFAULT_MAX_ENTRIES = 50_000;
const DEFAULT_MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
export class ZipImportError extends Error {}
/**
* Resolve a zip entry name to an absolute path under `root`, or return null
* when the entry would escape it (zip slip). Exposed for direct testing of
* the path-traversal guard, which is otherwise hard to exercise because zip
* writers refuse to emit `..` entries.
*/
export function resolveSafeTarget(root: string, entryName: string): string | null {
const absRoot = resolve(root);
const rootPrefix = absRoot + sep;
const rel = entryName.replaceAll('\\', '/');
const target = resolve(absRoot, rel);
if (target !== absRoot && !target.startsWith(rootPrefix)) return null;
return target;
}
/**
* Extract every file entry of `zipBuffer` under `destDir`, returning the list
* of written zip-relative paths (forward-slashed). `destDir` must already be a
* safe, caller-owned directory; this function never writes outside it.
*/
export async function extractZip(
zipBuffer: Buffer,
destDir: string,
options: ExtractOptions = {},
): Promise<string[]> {
const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
const root = resolve(destDir);
const zip = await openZip(zipBuffer);
const written: string[] = [];
let entryCount = 0;
let totalBytes = 0;
return new Promise<string[]>((resolvePromise, reject) => {
const fail = (message: string): void => {
zip.close();
reject(new ZipImportError(message));
};
zip.readEntry();
zip.on('entry', (entry: Entry) => {
entryCount += 1;
if (entryCount > maxEntries) {
fail(`zip has too many entries (> ${maxEntries})`);
return;
}
totalBytes += entry.uncompressedSize;
if (totalBytes > maxTotalBytes) {
fail(`zip uncompressed size exceeds ${maxTotalBytes} bytes`);
return;
}
// Directory entries end with '/'. Files inside still create their dirs.
if (entry.fileName.endsWith('/')) {
zip.readEntry();
return;
}
const rel = entry.fileName.replaceAll('\\', '/');
const target = resolveSafeTarget(root, rel);
if (target === null) {
fail(`zip entry escapes the import directory: "${entry.fileName}"`);
return;
}
zip.openReadStream(entry, (err, readStream) => {
if (err !== null || readStream === undefined) {
fail(`failed to read zip entry "${entry.fileName}": ${err?.message ?? 'unknown'}`);
return;
}
void mkdir(dirname(target), { recursive: true })
.then(() => pipeline(readStream, createWriteStream(target)))
.then(() => {
written.push(rel);
zip.readEntry();
})
.catch((error: unknown) => {
fail(`failed to write "${rel}": ${(error as Error).message}`);
});
});
});
zip.on('end', () => {
resolvePromise(written);
});
zip.on('error', (err: Error) => {
reject(new ZipImportError(`corrupt zip: ${err.message}`));
});
});
}
function openZip(buffer: Buffer): Promise<ZipFile> {
return new Promise<ZipFile>((resolvePromise, reject) => {
fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
if (err !== null || zipfile === undefined) {
reject(new ZipImportError(`not a valid zip file: ${err?.message ?? 'unknown'}`));
return;
}
resolvePromise(zipfile);
});
});
}
|