Spaces:
Runtime error
Runtime error
File size: 11,319 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 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | 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');
});
});
|