Spaces:
Runtime error
Runtime error
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 };
}
|