Spaces:
Runtime error
Runtime error
| import AdmZip from 'adm-zip'; | |
| import { BadRequestException } from '@nestjs/common'; | |
| import { parsePluginPackage } from './plugin-installer'; | |
| const validManifest = { id: 'my-plg', name: 'My Plugin', version: '1.0.0', type: 'extension', main: 'index.js' }; | |
| function zipOf(files: Record<string, string>): Buffer { | |
| const z = new AdmZip(); | |
| for (const [name, content] of Object.entries(files)) z.addFile(name, Buffer.from(content)); | |
| return z.toBuffer(); | |
| } | |
| /** | |
| * Build a valid plugin zip, then corrupt one entry's compressed bytes so `getData()` throws a | |
| * decompression/CRC error (BAD_CRC / zlib invalid-code) — simulating a truncated or damaged archive. | |
| */ | |
| function zipWithCorruptEntry(target: string): Buffer { | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest))); | |
| z.addFile('index.js', Buffer.from('module.exports=class{}')); | |
| z.addFile(target, Buffer.from('A'.repeat(100))); | |
| const buf = Buffer.from(z.toBuffer()); // writable copy | |
| const locsig = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // PK\x03\x04 local file header signature | |
| let off = buf.indexOf(locsig); | |
| while (off !== -1) { | |
| const nameLen = buf.readUInt16LE(off + 26); | |
| const extraLen = buf.readUInt16LE(off + 28); | |
| const name = buf.slice(off + 30, off + 30 + nameLen).toString(); | |
| const dataStart = off + 30 + nameLen + extraLen; | |
| if (name === target) { | |
| buf[dataStart] ^= 0xff; // flip the first compressed byte → bad CRC / invalid deflate stream | |
| break; | |
| } | |
| off = buf.indexOf(locsig, off + 1); | |
| } | |
| return buf; | |
| } | |
| /** | |
| * Build a plugin zip whose `target` entry declares uncompressed size = 0 in BOTH the central | |
| * directory and the local header, while the entry still carries real (deflated) content. adm-zip | |
| * only sets zlib `maxOutputLength` when the declared size is > 0, so without a bound this entry | |
| * inflates with no cap — the zip-bomb residual gap (a lying-header entry). | |
| */ | |
| function zipWithLyingZeroSizeEntries(targets: Record<string, string>): Buffer { | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest))); | |
| z.addFile('index.js', Buffer.from('module.exports=class{}')); | |
| for (const [name, content] of Object.entries(targets)) z.addFile(name, Buffer.from(content)); | |
| const buf = Buffer.from(z.toBuffer()); // writable copy | |
| const censig = Buffer.from([0x50, 0x4b, 0x01, 0x02]); // PK\x01\x02 central directory header signature | |
| let off = buf.indexOf(censig); | |
| while (off !== -1) { | |
| const nameLen = buf.readUInt16LE(off + 28); | |
| const name = buf.slice(off + 46, off + 46 + nameLen).toString(); | |
| if (name in targets) { | |
| const locoff = buf.readUInt32LE(off + 42); // CENOFF — local header offset | |
| buf.writeUInt32LE(0, off + 24); // CENLEN = 0 (declared uncompressed size, central) | |
| buf.writeUInt32LE(0, locoff + 22); // LOCLEN = 0 (declared uncompressed size, local) | |
| } | |
| off = buf.indexOf(censig, off + 1); | |
| } | |
| return buf; | |
| } | |
| function zipWithLyingZeroSizeEntry(target: string, content: string): Buffer { | |
| return zipWithLyingZeroSizeEntries({ [target]: content }); | |
| } | |
| describe('parsePluginPackage', () => { | |
| it('parses a flat package (manifest + files at the root)', () => { | |
| const out = parsePluginPackage( | |
| zipOf({ 'manifest.json': JSON.stringify(validManifest), 'index.js': 'module.exports=class{}' }), | |
| ); | |
| expect(out.manifest.id).toBe('my-plg'); | |
| expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']); | |
| }); | |
| it('strips a single wrapping folder (a zipped plugin directory)', () => { | |
| const out = parsePluginPackage( | |
| zipOf({ 'my-plg/manifest.json': JSON.stringify(validManifest), 'my-plg/index.js': 'x' }), | |
| ); | |
| expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']); | |
| }); | |
| it('ignores cruft outside the package root (e.g. __MACOSX)', () => { | |
| const out = parsePluginPackage( | |
| zipOf({ 'my-plg/manifest.json': JSON.stringify(validManifest), 'my-plg/index.js': 'x', '__MACOSX/._x': 'junk' }), | |
| ); | |
| expect(out.entries.map(e => e.relPath).sort()).toEqual(['index.js', 'manifest.json']); | |
| }); | |
| it('rejects an archive with no manifest.json', () => { | |
| expect(() => parsePluginPackage(zipOf({ 'index.js': 'x' }))).toThrow(/no manifest/i); | |
| }); | |
| it('rejects a manifest missing a required field', () => { | |
| const bad = { ...validManifest, main: undefined }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /required field: main/i, | |
| ); | |
| }); | |
| it('rejects a non-string required field (numeric main) with a clean 400, not a TypeError/500', () => { | |
| // A non-string `main` is truthy, so a bare falsy check would pass it through and then crash | |
| // path.posix.normalize with an uncaught TypeError (HTTP 500). It must be rejected as a 400. | |
| const bad = { ...validManifest, main: 123 }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /invalid required field/i, | |
| ); | |
| }); | |
| it('rejects a non-object manifest (null / array / scalar) with a 400, not a TypeError/500', () => { | |
| // JSON.parse("null") is null (no throw); accessing manifest['id'] on it then throws an uncaught | |
| // TypeError (HTTP 500). null, an array, and a bare scalar must all be rejected as a clean 400. | |
| for (const body of ['null', '[]', '"x"', '5', 'true']) { | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': body, 'index.js': 'x' }))).toThrow( | |
| /must be a JSON object/i, | |
| ); | |
| } | |
| }); | |
| it('rejects an unsafe plugin id', () => { | |
| const bad = { ...validManifest, id: '../evil' }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /invalid plugin id/i, | |
| ); | |
| }); | |
| it('rejects an id reserved by a built-in', () => { | |
| const bad = { ...validManifest, id: 'baileys' }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /reserved/i, | |
| ); | |
| }); | |
| it('rejects a case-variant of a reserved id (the id check is case-insensitive)', () => { | |
| // SAFE_ID accepts mixed case, so a case-variant must still hit the reservation — else `Auto-Reply` | |
| // installs as a distinct plugin that shadows the reserved `auto-reply`. | |
| const bad = { ...validManifest, id: 'Auto-Reply' }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /reserved/i, | |
| ); | |
| }); | |
| it('rejects an engine-type package (engines are built-in, not user-installable)', () => { | |
| const bad = { ...validManifest, id: 'my-engine', type: 'engine' }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow( | |
| /extension/i, | |
| ); | |
| }); | |
| it('rejects an unknown plugin type', () => { | |
| const bad = { ...validManifest, type: 'wormhole' }; | |
| expect(() => parsePluginPackage(zipOf({ 'manifest.json': JSON.stringify(bad), 'index.js': 'x' }))).toThrow(/type/i); | |
| }); | |
| it('rejects a zip-slip path escaping the package root', () => { | |
| // adm-zip sanitizes names on add, so forge the malicious entry name directly to simulate a | |
| // hand-crafted archive. | |
| const z = new AdmZip(); | |
| z.addFile('manifest.json', Buffer.from(JSON.stringify(validManifest))); | |
| z.addFile('index.js', Buffer.from('x')); | |
| z.addFile('evil.js', Buffer.from('pwned')); | |
| z.getEntries().find(e => e.entryName === 'evil.js')!.entryName = '../evil.js'; | |
| expect(() => parsePluginPackage(z.toBuffer())).toThrow(/unsafe path/i); | |
| }); | |
| it('rejects a package missing its declared main file', () => { | |
| const buf = zipOf({ 'manifest.json': JSON.stringify(validManifest), 'other.js': 'x' }); | |
| expect(() => parsePluginPackage(buf)).toThrow(/missing its main file/i); | |
| }); | |
| it('rejects too many files', () => { | |
| const files: Record<string, string> = { 'manifest.json': JSON.stringify(validManifest), 'index.js': 'x' }; | |
| for (let i = 0; i < 5; i++) files[`f${i}.txt`] = 'x'; | |
| expect(() => parsePluginPackage(zipOf(files), { maxEntries: 3, maxTotalBytes: 1e9 })).toThrow(/too many/i); | |
| }); | |
| it('rejects an archive that exceeds the size limit (before decompressing)', () => { | |
| const buf = zipOf({ 'manifest.json': JSON.stringify(validManifest), 'index.js': 'a'.repeat(100) }); | |
| expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 10 })).toThrow(/size limit/i); | |
| }); | |
| it('rejects a corrupt entry with a clean 400, not an uncaught decompression error / 500', () => { | |
| // A truncated/damaged entry throws BAD_CRC / a zlib error from getData(); without the try/catch | |
| // that escapes as an HTTP 500. It must surface as a BadRequestException (clean 400). | |
| const buf = zipWithCorruptEntry('data.bin'); | |
| expect(() => parsePluginPackage(buf)).toThrow(BadRequestException); | |
| expect(() => parsePluginPackage(buf)).toThrow(/corrupt or too large/i); | |
| }); | |
| it('rejects a lying size=0 entry whose actual content exceeds the cap (no unbounded inflation)', () => { | |
| // adm-zip skips zlib `maxOutputLength` when declared size is 0, so this entry would inflate with | |
| // NO cap. The declared aggregate (0 + tiny manifest + index) passes the pre-check; only the | |
| // actual-bytes bound stops the lying entry. Actual content (500B) exceeds the per-entry cap (200B) | |
| // → ERR_BUFFER_TOO_LARGE → BadRequestException. | |
| const buf = zipWithLyingZeroSizeEntry('big.bin', 'A'.repeat(500)); | |
| expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 200 })).toThrow(BadRequestException); | |
| expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 200 })).toThrow(/corrupt or too large/i); | |
| }); | |
| it('rejects many lying size=0 entries whose aggregate exceeds the cap (multi-entry zip bomb)', () => { | |
| // Each lying entry is individually under the per-entry cap (150B < 300B), and the declared sum | |
| // is tiny (lying sizes are 0 + manifest + index), so both the declared pre-check and the per-entry | |
| // bound pass. Without the running actual-bytes counter, these accumulate unbounded in `entries` | |
| // before the function returns → OOM. The aggregate bound aborts as soon as the running total | |
| // crosses the cap. | |
| const buf = zipWithLyingZeroSizeEntries({ | |
| 'big1.bin': 'A'.repeat(150), | |
| 'big2.bin': 'A'.repeat(150), | |
| 'big3.bin': 'A'.repeat(150), | |
| }); | |
| expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 300 })).toThrow(BadRequestException); | |
| expect(() => parsePluginPackage(buf, { maxEntries: 100, maxTotalBytes: 300 })).toThrow(/too large/i); | |
| }); | |
| it('happy path unchanged: a normal small plugin installs identically (no regression)', () => { | |
| const buf = zipOf({ | |
| 'manifest.json': JSON.stringify(validManifest), | |
| 'index.js': 'module.exports=class{}', | |
| 'extra.txt': 'some extra bytes', | |
| }); | |
| const out = parsePluginPackage(buf); | |
| expect(out.manifest.id).toBe('my-plg'); | |
| expect(out.entries.map(e => e.relPath).sort()).toEqual(['extra.txt', 'index.js', 'manifest.json']); | |
| expect(out.entries.find(e => e.relPath === 'extra.txt')?.data.toString()).toBe('some extra bytes'); | |
| }); | |
| }); | |