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);
    });
  });
}