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