File size: 8,165 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import AdmZip from 'adm-zip';
import * as path from 'path';
import * as zlib from 'zlib';
import { BadRequestException } from '@nestjs/common';
import { PluginManifest, PluginType } from '../../core/plugins';

export interface PackageLimits {
  /** Max number of files in the archive (cheap zip-bomb / fork-bomb guard). */
  maxEntries: number;
  /** Max total uncompressed bytes (checked against the zip headers BEFORE decompressing). */
  maxTotalBytes: number;
}

export const DEFAULT_PACKAGE_LIMITS: PackageLimits = { maxEntries: 200, maxTotalBytes: 20 * 1024 * 1024 };

/**
 * Plugin ids an uploaded package must never use. `whatsapp-web.js` / `baileys` are built-in engines;
 * `auto-reply` / `translation` are the legacy bundled-extension ids (removed in v0.7 — superseded by
 * the marketplace `chat-flow` / `group-translate`) kept reserved so a re-upload can't shadow them.
 */
export const RESERVED_PLUGIN_IDS = new Set(['whatsapp-web.js', 'baileys', 'auto-reply', 'translation']);

/** Only extensions are user-installable; engines (and other tiers) are built-in by design. */
export const INSTALLABLE_TYPES = new Set<string>([PluginType.EXTENSION]);

const SAFE_ID = /^[a-z0-9][a-z0-9._-]*$/i;
const REQUIRED_FIELDS = ['id', 'name', 'version', 'type', 'main'] as const;

/**
 * Decompress one zip entry with a hard cap on actual output bytes. adm-zip only forwards zlib
 * `maxOutputLength` when the entry's declared uncompressed size is positive, so an entry that lies
 * about being empty (header.size = 0) would otherwise inflate with NO cap — a memory-exhaustion
 * vector. For that case we inflate bounded ourselves; every other path is left to `getData()` (a
 * corrupt/CRC mismatch or a lying-small header throws `BAD_CRC` / `ERR_BUFFER_TOO_LARGE`, which the
 * caller catches and maps to a clean 400).
 */
function readEntryData(entry: AdmZip.IZipEntry, maxBytes: number): Buffer {
  if (entry.header.size === 0 && entry.header.compressedSize > 0) {
    const compressed = entry.getCompressedData();
    if (compressed.length === 0) return Buffer.alloc(0);
    // maxOutputLength aborts inflation (ERR_BUFFER_TOO_LARGE) once output exceeds the cap, so a
    // lying size=0 entry cannot grow unbounded in memory before we reject the archive.
    return zlib.inflateRawSync(compressed, { maxOutputLength: maxBytes });
  }
  return entry.getData();
}

export interface ParsedPackage {
  manifest: PluginManifest;
  /** Files to write under the plugin directory, relative to the package root, zip-slip-safe. */
  entries: { relPath: string; data: Buffer }[];
}

/**
 * Parse + validate an uploaded plugin `.zip` without touching the filesystem. Locates the package
 * root (the shallowest `manifest.json`, so both a flat zip and a single-folder zip work), validates
 * the manifest and id, and resolves every file path defensively (rejects absolute / `..` escapes and
 * over-size archives). The caller writes the returned entries; this function decides what is safe.
 */
export function parsePluginPackage(buffer: Buffer, limits: PackageLimits = DEFAULT_PACKAGE_LIMITS): ParsedPackage {
  let zip: AdmZip;
  try {
    zip = new AdmZip(buffer);
  } catch {
    throw new BadRequestException('Uploaded file is not a valid .zip archive');
  }

  const files = zip.getEntries().filter(e => !e.isDirectory);
  if (files.length === 0) throw new BadRequestException('The archive is empty');
  if (files.length > limits.maxEntries) throw new BadRequestException('The archive has too many files');

  // Package root = directory of the shallowest manifest.json (handles flat and single-folder zips).
  const manifestEntry = files
    .filter(e => path.posix.basename(e.entryName) === 'manifest.json')
    .sort((a, b) => a.entryName.split('/').length - b.entryName.split('/').length)[0];
  if (!manifestEntry) throw new BadRequestException('The archive has no manifest.json');
  const dir = path.posix.dirname(manifestEntry.entryName);
  const prefix = dir === '.' ? '' : dir + '/';

  let manifestRaw: Buffer;
  try {
    manifestRaw = readEntryData(manifestEntry, limits.maxTotalBytes);
  } catch {
    // A corrupt / oversized manifest entry must surface as a clean 400, not an uncaught
    // decompression error (BAD_CRC / ERR_BUFFER_TOO_LARGE) that would escape as an HTTP 500.
    throw new BadRequestException('Plugin package is corrupt or too large to extract');
  }
  let parsed: unknown;
  try {
    parsed = JSON.parse(manifestRaw.toString('utf-8'));
  } catch {
    throw new BadRequestException('manifest.json is not valid JSON');
  }
  // JSON.parse("null") / "[]" / "5" don't throw — but indexing a field on a non-object then throws an
  // uncaught TypeError (HTTP 500) on the attacker-controlled install path. Require a plain object.
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
    throw new BadRequestException('manifest.json must be a JSON object');
  }
  const manifest = parsed as PluginManifest;
  for (const field of REQUIRED_FIELDS) {
    // Require a non-empty STRING: a non-string value (e.g. `main: 123`) is truthy and would pass a
    // bare falsy check, then crash a string-only API like path.posix.normalize with an uncaught
    // TypeError (HTTP 500). Reject it cleanly as a 400 here.
    if (typeof manifest[field] !== 'string' || manifest[field].length === 0) {
      throw new BadRequestException(`manifest.json is missing or has an invalid required field: ${field}`);
    }
  }
  if (!SAFE_ID.test(manifest.id) || manifest.id.includes('..')) {
    throw new BadRequestException(`Invalid plugin id: "${manifest.id}"`);
  }
  if (RESERVED_PLUGIN_IDS.has(manifest.id.toLowerCase())) {
    throw new BadRequestException(`Plugin id "${manifest.id}" is reserved by a built-in plugin`);
  }
  if (!INSTALLABLE_TYPES.has(manifest.type)) {
    throw new BadRequestException(
      `Plugin type "${manifest.type}" is not installable — only extension plugins can be installed (engines and other tiers are built-in).`,
    );
  }

  // Size guard FIRST, off the declared header sizes, so a zip bomb is rejected before we decompress.
  const packaged = files.filter(e => !prefix || e.entryName.startsWith(prefix));
  const declared = packaged.reduce((sum, e) => sum + e.header.size, 0);
  if (declared > limits.maxTotalBytes) throw new BadRequestException('The archive contents exceed the size limit');

  const entries: { relPath: string; data: Buffer }[] = [];
  let actualBytes = 0;
  for (const e of packaged) {
    const relPath = e.entryName.slice(prefix.length);
    if (!relPath) continue;
    const norm = path.posix.normalize(relPath);
    if (relPath.includes('\\') || norm.startsWith('..') || norm === '..' || path.posix.isAbsolute(norm)) {
      throw new BadRequestException(`Unsafe path in archive: ${e.entryName}`);
    }
    let data: Buffer;
    try {
      data = readEntryData(e, limits.maxTotalBytes);
    } catch {
      // Corrupt entry (bad CRC / truncated), a lying-small header (zlib ERR_BUFFER_TOO_LARGE), or a
      // lying size=0 entry that exceeds the cap — all must yield a clean 400, never an uncaught
      // decompression error (HTTP 500).
      throw new BadRequestException('Plugin package is corrupt or too large to extract');
    }
    // Aggregate actual-bytes bound: the declared-sum pre-check above uses header.size (which lying
    // size=0 entries contribute as 0), and the per-entry cap only bounds each entry individually. A
    // crafted archive with many lying-size=0 entries (each just under the per-entry cap) would pass
    // both and accumulate unbounded in `entries` before the function returns. Abort as soon as the
    // running total of decompressed bytes exceeds the cap.
    actualBytes += data.length;
    if (actualBytes > limits.maxTotalBytes) {
      throw new BadRequestException('Plugin package is too large to extract');
    }
    entries.push({ relPath: norm, data });
  }

  const mainRel = path.posix.normalize(manifest.main);
  if (!entries.some(en => en.relPath === mainRel)) {
    throw new BadRequestException(`The archive is missing its main file: ${manifest.main}`);
  }

  return { manifest, entries };
}