@aunucore/baileys 1.0.83 → 1.0.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,466 @@
1
+ // AunuBaileys (1.0.85): hardened HTTP(S) GET for everything the library
2
+ // fetches on behalf of a REMOTE PAGE — link previews and their
3
+ // thumbnails. The URL (and every redirect target) is attacker-
4
+ // controlled there: a chat peer can put any URL in a message, and the
5
+ // socket generates a preview for outgoing text automatically. The old
6
+ // path handed the fetch to link-preview-js, which resolved and
7
+ // connected wherever the URL pointed — loopback, RFC1918 and the
8
+ // 169.254 cloud-metadata address included (SSRF; the link-preview-js
9
+ // advisories GHSA-4gp8-rjrq-ch6q / GHSA-cpjf-6666-r8fx are the same
10
+ // class), and read the body with no size bound and no overall
11
+ // timeout (HB-205). This module therefore:
12
+ // * resolves DNS itself and rejects non-public addresses AT
13
+ // CONNECT TIME, via a guarded lookup: the address that was
14
+ // validated is the address the socket connects to, so DNS
15
+ // rebinding (the answer changing between "check" and "use") is
16
+ // impossible by construction;
17
+ // * checks IP-literal hosts (IPv4 in any notation the URL parser
18
+ // produces, IPv6, IPv4-mapped / NAT64 / 6to4 embeddings);
19
+ // * follows redirects manually, re-validating every hop, with a
20
+ // hop limit, and always refuses an https -> http downgrade;
21
+ // * bounds the response size (after decompression), the total
22
+ // time and the per-hop idle time.
23
+ // Design follows the pattern proven by xdc-baileys' safe-fetch
24
+ // (reviewed statically in heartbeat run-0110); the redirect policy
25
+ // differs on purpose: redirects are followed across hosts ('any',
26
+ // max 5), because that is what this tree has always done via
27
+ // undici — the SSRF protection comes from validating every hop's
28
+ // address, not from pinning the host.
29
+ import dns from 'node:dns';
30
+ import http from 'node:http';
31
+ import https from 'node:https';
32
+ import net from 'node:net';
33
+ import zlib from 'node:zlib';
34
+ export class SafeFetchError extends Error {
35
+ constructor(code, message, extra = {}) {
36
+ super(message);
37
+ this.name = 'SafeFetchError';
38
+ this.code = code;
39
+ Object.assign(this, extra);
40
+ }
41
+ }
42
+ export const SAFE_FETCH_DEFAULTS = Object.freeze({
43
+ timeout: 3000,
44
+ maxRedirects: 5,
45
+ maxBytes: 1024 * 1024
46
+ });
47
+ // ------------------------------------------------------------------
48
+ // address policy
49
+ const parseIPv4 = (s) => {
50
+ if (typeof s !== 'string' || !net.isIPv4(s)) {
51
+ return null;
52
+ }
53
+ return s.split('.').map(Number);
54
+ };
55
+ const parseIPv6 = (input) => {
56
+ if (typeof input !== 'string') {
57
+ return null;
58
+ }
59
+ let s = input.trim().toLowerCase();
60
+ if (s.startsWith('[') && s.endsWith(']')) {
61
+ s = s.slice(1, -1);
62
+ }
63
+ s = s.split('%')[0];
64
+ if (!net.isIPv6(s)) {
65
+ return null;
66
+ }
67
+ if (s.includes('.')) {
68
+ const cut = s.lastIndexOf(':');
69
+ const v4 = parseIPv4(s.slice(cut + 1));
70
+ if (!v4) {
71
+ return null;
72
+ }
73
+ s = s.slice(0, cut + 1) + ((v4[0] << 8) | v4[1]).toString(16) + ':' + ((v4[2] << 8) | v4[3]).toString(16);
74
+ }
75
+ const parts = s.split('::');
76
+ if (parts.length > 2) {
77
+ return null;
78
+ }
79
+ const left = parts[0] ? parts[0].split(':') : [];
80
+ const right = parts.length === 2 && parts[1] ? parts[1].split(':') : [];
81
+ let groups;
82
+ if (parts.length === 2) {
83
+ const fill = 8 - left.length - right.length;
84
+ if (fill < 0) {
85
+ return null;
86
+ }
87
+ groups = [...left, ...Array(fill).fill('0'), ...right];
88
+ }
89
+ else {
90
+ groups = left;
91
+ }
92
+ if (groups.length !== 8) {
93
+ return null;
94
+ }
95
+ const bytes = [];
96
+ for (const g of groups) {
97
+ if (!/^[0-9a-f]{1,4}$/.test(g)) {
98
+ return null;
99
+ }
100
+ const n = parseInt(g, 16);
101
+ bytes.push(n >> 8, n & 255);
102
+ }
103
+ return bytes;
104
+ };
105
+ const isBlockedV4 = ([a, b, c]) => a === 0 || // 0.0.0.0/8 "this network"
106
+ a === 10 || // private
107
+ a === 127 || // loopback
108
+ (a === 100 && b >= 64 && b <= 127) || // CGNAT 100.64.0.0/10
109
+ (a === 169 && b === 254) || // link-local, cloud metadata
110
+ (a === 172 && b >= 16 && b <= 31) || // private
111
+ (a === 192 && b === 0 && c === 0) || // IETF protocol assignments
112
+ (a === 192 && b === 0 && c === 2) || // TEST-NET-1
113
+ (a === 192 && b === 88 && c === 99) || // 6to4 relay anycast
114
+ (a === 192 && b === 168) || // private
115
+ (a === 198 && (b === 18 || b === 19)) || // benchmarking
116
+ (a === 198 && b === 51 && c === 100) || // TEST-NET-2
117
+ (a === 203 && b === 0 && c === 113) || // TEST-NET-3
118
+ a >= 224; // multicast, reserved, broadcast
119
+ const isBlockedV6 = (x) => {
120
+ const zero = (from, to) => x.slice(from, to).every(v => v === 0);
121
+ if (zero(0, 10) && x[10] === 0xff && x[11] === 0xff) {
122
+ return isBlockedV4(x.slice(12)); // ::ffff:a.b.c.d
123
+ }
124
+ if (zero(0, 12)) {
125
+ return true; // ::, ::1, ::a.b.c.d
126
+ }
127
+ if (x[0] === 0x00 && x[1] === 0x64 && x[2] === 0xff && x[3] === 0x9b) {
128
+ if (zero(4, 12)) {
129
+ return isBlockedV4(x.slice(12)); // 64:ff9b::/96 NAT64
130
+ }
131
+ if (x[4] === 0x00 && x[5] === 0x01) {
132
+ return true; // 64:ff9b:1::/48 local-use
133
+ }
134
+ }
135
+ if (x[0] === 0x20 && x[1] === 0x02) {
136
+ return isBlockedV4(x.slice(2, 6)); // 2002::/16 6to4
137
+ }
138
+ if (x[0] === 0x20 && x[1] === 0x01 && (x[2] & 0xfe) === 0) {
139
+ return true; // 2001::/23 (Teredo, benchmarking, ORCHID)
140
+ }
141
+ if (x[0] === 0x20 && x[1] === 0x01 && x[2] === 0x0d && x[3] === 0xb8) {
142
+ return true; // 2001:db8::/32 documentation
143
+ }
144
+ if (x[0] === 0x3f && x[1] === 0xff && (x[2] & 0xf0) === 0) {
145
+ return true; // 3fff::/20 documentation
146
+ }
147
+ if (x[0] === 0x01 && zero(1, 8)) {
148
+ return true; // 100::/64 discard-only
149
+ }
150
+ if (x[0] === 0x5f) {
151
+ return true; // 5f00::/16 SRv6 SIDs
152
+ }
153
+ if ((x[0] & 0xfe) === 0xfc) {
154
+ return true; // fc00::/7 unique-local
155
+ }
156
+ if (x[0] === 0xfe && (x[1] & 0xc0) >= 0x80) {
157
+ return true; // fe80::/10 link-local, fec0::/10 site-local
158
+ }
159
+ if (x[0] === 0xff) {
160
+ return true; // multicast
161
+ }
162
+ return false;
163
+ };
164
+ /** true when `address` must never be contacted. Unparseable input is blocked (fail closed). */
165
+ export const isBlockedAddress = (address) => {
166
+ const v4 = parseIPv4(address);
167
+ if (v4) {
168
+ return isBlockedV4(v4);
169
+ }
170
+ const v6 = parseIPv6(address);
171
+ if (v6) {
172
+ return isBlockedV6(v6);
173
+ }
174
+ return true;
175
+ };
176
+ const blockedError = (host, address) => new SafeFetchError('ERR_SSRF_BLOCKED', `refusing to connect to non-public address (${host} -> ${address})`, { host, address });
177
+ /**
178
+ * dns.lookup-compatible function used as the socket's `lookup`. Fails
179
+ * closed: if ANY resolved address is non-public the whole lookup is
180
+ * rejected, so a mixed answer cannot smuggle one in.
181
+ */
182
+ export const createGuardedLookup = ({ lookup = dns.lookup, isBlocked = isBlockedAddress } = {}) => (hostname, options, callback) => {
183
+ if (typeof options === 'function') {
184
+ callback = options;
185
+ options = {};
186
+ }
187
+ const opts = typeof options === 'number' ? { family: options } : { ...(options || {}) };
188
+ lookup(hostname, { ...opts, all: true }, (err, addresses) => {
189
+ if (err) {
190
+ return callback(err);
191
+ }
192
+ const list = Array.isArray(addresses) ? addresses : [{ address: addresses, family: net.isIPv6(addresses) ? 6 : 4 }];
193
+ if (!list.length) {
194
+ return callback(Object.assign(new Error(`getaddrinfo ENOTFOUND ${hostname}`), { code: 'ENOTFOUND' }));
195
+ }
196
+ for (const { address } of list) {
197
+ if (isBlocked(address)) {
198
+ return callback(blockedError(hostname, address));
199
+ }
200
+ }
201
+ if (opts.all) {
202
+ return callback(null, list);
203
+ }
204
+ callback(null, list[0].address, list[0].family);
205
+ });
206
+ };
207
+ // ------------------------------------------------------------------
208
+ // helpers
209
+ const hostOf = (url) => url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname;
210
+ const sameSite = (a, b) => {
211
+ const x = a.hostname.toLowerCase();
212
+ const y = b.hostname.toLowerCase();
213
+ return x === y || x === 'www.' + y || 'www.' + x === y;
214
+ };
215
+ const SENSITIVE_HEADERS = ['authorization', 'cookie', 'proxy-authorization'];
216
+ const normalizeUrl = (input) => {
217
+ let url;
218
+ try {
219
+ url = input instanceof URL ? new URL(input.href) : new URL(String(input));
220
+ }
221
+ catch {
222
+ throw new SafeFetchError('ERR_INVALID_URL', 'invalid URL');
223
+ }
224
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
225
+ throw new SafeFetchError('ERR_INVALID_URL', `unsupported protocol ${url.protocol}`);
226
+ }
227
+ if (url.username || url.password) {
228
+ throw new SafeFetchError('ERR_INVALID_URL', 'URLs with embedded credentials are not allowed');
229
+ }
230
+ return url;
231
+ };
232
+ const DECODERS = {
233
+ gzip: zlib.createGunzip,
234
+ 'x-gzip': zlib.createGunzip,
235
+ deflate: zlib.createInflate,
236
+ br: zlib.createBrotliDecompress
237
+ };
238
+ const lowerHeaders = (h = {}) => {
239
+ const out = {};
240
+ for (const [k, v] of Object.entries(h)) {
241
+ if (v !== undefined && v !== null) {
242
+ out[k.toLowerCase()] = v;
243
+ }
244
+ }
245
+ return out;
246
+ };
247
+ // ------------------------------------------------------------------
248
+ // transport + body reader
249
+ const nodeTransport = ({ lookup, timeout }) => (url, headers, signal) => new Promise((resolve, reject) => {
250
+ const mod = url.protocol === 'https:' ? https : http;
251
+ const req = mod.request(url, {
252
+ method: 'GET',
253
+ headers,
254
+ lookup,
255
+ signal,
256
+ agent: false,
257
+ timeout,
258
+ maxHeaderSize: 16 * 1024
259
+ }, (res) => resolve({ status: res.statusCode, headers: res.headers, stream: res, decoded: false }));
260
+ req.on('timeout', () => req.destroy(new SafeFetchError('ERR_TIMEOUT', 'request timed out')));
261
+ req.on('error', reject);
262
+ req.end();
263
+ });
264
+ const readBody = async (hop, { maxBytes, onLimit, signal }) => {
265
+ const encoding = String(hop.headers['content-encoding'] || '').toLowerCase().trim();
266
+ let source = hop.stream;
267
+ let decoder;
268
+ if (!hop.decoded && encoding && encoding !== 'identity') {
269
+ const make = DECODERS[encoding];
270
+ if (!make) {
271
+ hop.stream.destroy();
272
+ throw new SafeFetchError('ERR_UNSUPPORTED_ENCODING', `unsupported content-encoding ${encoding}`);
273
+ }
274
+ decoder = make();
275
+ hop.stream.on('error', e => decoder.destroy(e));
276
+ source = hop.stream.pipe(decoder);
277
+ }
278
+ const declared = Number(hop.headers['content-length']);
279
+ if (onLimit === 'throw' && !encoding && Number.isFinite(declared) && declared > maxBytes) {
280
+ hop.stream.destroy();
281
+ throw new SafeFetchError('ERR_RESPONSE_TOO_LARGE', `response larger than ${maxBytes} bytes`, { limit: maxBytes });
282
+ }
283
+ const stop = () => {
284
+ decoder?.destroy();
285
+ hop.stream.destroy();
286
+ };
287
+ const onAbort = () => hop.stream.destroy(new SafeFetchError('ERR_TIMEOUT', 'request timed out'));
288
+ signal.addEventListener('abort', onAbort, { once: true });
289
+ const chunks = [];
290
+ let total = 0;
291
+ let truncated = false;
292
+ try {
293
+ for await (const chunk of source) {
294
+ total += chunk.length;
295
+ if (total > maxBytes) {
296
+ if (onLimit === 'truncate') {
297
+ chunks.push(chunk.subarray(0, chunk.length - (total - maxBytes)));
298
+ truncated = true;
299
+ break;
300
+ }
301
+ throw new SafeFetchError('ERR_RESPONSE_TOO_LARGE', `response larger than ${maxBytes} bytes`, { limit: maxBytes });
302
+ }
303
+ chunks.push(chunk);
304
+ }
305
+ }
306
+ finally {
307
+ signal.removeEventListener('abort', onAbort);
308
+ stop();
309
+ }
310
+ return { body: Buffer.concat(chunks), truncated };
311
+ };
312
+ // ------------------------------------------------------------------
313
+ // public API
314
+ /**
315
+ * GET `input` and return { url, status, headers, body, truncated, redirects }.
316
+ *
317
+ * options:
318
+ * timeout idle/connect timeout per hop in ms (default 3000)
319
+ * totalTimeout hard deadline for the whole call incl. redirects (default 3 x timeout)
320
+ * maxBytes response cap in bytes, applied after decompression (default 1 MiB)
321
+ * onLimit 'throw' (default) or 'truncate' when the cap is hit
322
+ * maxRedirects default 5
323
+ * redirects 'any' (default) | 'same-host' | 'none'. Every hop is
324
+ * address-checked either way. https -> http downgrades
325
+ * are always refused.
326
+ * headers request headers
327
+ * shouldReadBody (status, headers) => boolean; return false to skip the body
328
+ * allowPrivateNetworks explicit opt-out of the address policy (default false)
329
+ * lookup / isBlocked injection points (host hooks, tests)
330
+ */
331
+ export async function safeHttpGet(input, options = {}) {
332
+ const { timeout = SAFE_FETCH_DEFAULTS.timeout, maxBytes = SAFE_FETCH_DEFAULTS.maxBytes, onLimit = 'throw', maxRedirects = SAFE_FETCH_DEFAULTS.maxRedirects, redirects = 'any', headers: userHeaders, shouldReadBody, allowPrivateNetworks = false, lookup: customLookup } = options;
333
+ const baseLookup = customLookup ?? dns.lookup;
334
+ const totalTimeout = options.totalTimeout ?? timeout * 3;
335
+ const isBlocked = allowPrivateNetworks ? () => false : (options.isBlocked ?? isBlockedAddress);
336
+ const transport = nodeTransport({ lookup: createGuardedLookup({ lookup: baseLookup, isBlocked }), timeout });
337
+ const controller = new AbortController();
338
+ const deadline = setTimeout(() => controller.abort(), totalTimeout);
339
+ deadline.unref?.();
340
+ let url = normalizeUrl(input);
341
+ const origin = url;
342
+ let headers = { 'user-agent': 'node', accept: '*/*', 'accept-encoding': 'gzip, deflate, br', ...lowerHeaders(userHeaders) };
343
+ try {
344
+ for (let hops = 0;; hops++) {
345
+ const host = hostOf(url);
346
+ if (net.isIP(host) && isBlocked(host)) {
347
+ throw blockedError(host, host);
348
+ }
349
+ let hop;
350
+ try {
351
+ hop = await transport(url, headers, controller.signal);
352
+ }
353
+ catch (e) {
354
+ if (e instanceof SafeFetchError) {
355
+ throw e;
356
+ }
357
+ if (controller.signal.aborted || e?.name === 'AbortError') {
358
+ throw new SafeFetchError('ERR_TIMEOUT', 'request timed out');
359
+ }
360
+ // dns.lookup errors surface our SafeFetchError through
361
+ // the socket as the same object
362
+ throw e?.cause instanceof SafeFetchError ? e.cause : e;
363
+ }
364
+ const hopHeaders = lowerHeaders(hop.headers);
365
+ hop.headers = hopHeaders;
366
+ if (hop.status >= 300 && hop.status < 400 && hopHeaders.location) {
367
+ hop.stream.destroy();
368
+ if (redirects === 'none') {
369
+ throw new SafeFetchError('ERR_REDIRECT_NOT_ALLOWED', 'redirects are not allowed');
370
+ }
371
+ if (hops >= maxRedirects) {
372
+ throw new SafeFetchError('ERR_TOO_MANY_REDIRECTS', `more than ${maxRedirects} redirects`);
373
+ }
374
+ let next;
375
+ try {
376
+ next = normalizeUrl(new URL(String(hopHeaders.location), url));
377
+ }
378
+ catch (e) {
379
+ throw e instanceof SafeFetchError ? e : new SafeFetchError('ERR_INVALID_URL', 'invalid redirect target');
380
+ }
381
+ if (url.protocol === 'https:' && next.protocol === 'http:') {
382
+ throw new SafeFetchError('ERR_REDIRECT_NOT_ALLOWED', 'https to http downgrade refused');
383
+ }
384
+ if (redirects === 'same-host' && !sameSite(origin, next)) {
385
+ throw new SafeFetchError('ERR_REDIRECT_NOT_ALLOWED', 'redirect to a different host refused');
386
+ }
387
+ if (next.host !== url.host) {
388
+ headers = Object.fromEntries(Object.entries(headers).filter(([k]) => !SENSITIVE_HEADERS.includes(k)));
389
+ }
390
+ url = next;
391
+ continue;
392
+ }
393
+ if (hop.status < 200 || hop.status >= 300) {
394
+ hop.stream.destroy();
395
+ throw new SafeFetchError('ERR_HTTP_STATUS', `request failed with status ${hop.status}`, { statusCode: hop.status });
396
+ }
397
+ if (shouldReadBody && !shouldReadBody(hop.status, hopHeaders)) {
398
+ hop.stream.destroy();
399
+ return { url: url.href, status: hop.status, headers: hopHeaders, body: Buffer.alloc(0), truncated: false, redirects: hops };
400
+ }
401
+ const { body, truncated } = await readBody(hop, { maxBytes, onLimit, signal: controller.signal });
402
+ return { url: url.href, status: hop.status, headers: hopHeaders, body, truncated, redirects: hops };
403
+ }
404
+ }
405
+ catch (e) {
406
+ if (controller.signal.aborted && !(e instanceof SafeFetchError && e.code !== 'ERR_TIMEOUT')) {
407
+ throw new SafeFetchError('ERR_TIMEOUT', 'request timed out');
408
+ }
409
+ throw e;
410
+ }
411
+ finally {
412
+ clearTimeout(deadline);
413
+ }
414
+ }
415
+ // ------------------------------------------------------------------
416
+ // image sanity (pixel bombs)
417
+ /** Reads width/height from PNG, GIF, JPEG and WebP headers without decoding. null if unknown. */
418
+ export const readImageDimensions = (buf) => {
419
+ if (!Buffer.isBuffer(buf) || buf.length < 12) {
420
+ return null;
421
+ }
422
+ if (buf.readUInt32BE(0) === 0x89504e47 && buf.length >= 24) {
423
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
424
+ }
425
+ if (buf.toString('latin1', 0, 3) === 'GIF' && buf.length >= 10) {
426
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
427
+ }
428
+ if (buf.toString('latin1', 0, 4) === 'RIFF' && buf.toString('latin1', 8, 12) === 'WEBP' && buf.length >= 30) {
429
+ const kind = buf.toString('latin1', 12, 16);
430
+ if (kind === 'VP8X') {
431
+ return { width: 1 + buf.readUIntLE(24, 3), height: 1 + buf.readUIntLE(27, 3) };
432
+ }
433
+ if (kind === 'VP8L') {
434
+ const b = buf.readUInt32LE(21);
435
+ return { width: 1 + (b & 0x3fff), height: 1 + ((b >> 14) & 0x3fff) };
436
+ }
437
+ if (kind === 'VP8 ') {
438
+ return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
439
+ }
440
+ return null;
441
+ }
442
+ if (buf[0] === 0xff && buf[1] === 0xd8) {
443
+ let i = 2;
444
+ while (i + 9 < buf.length) {
445
+ if (buf[i] !== 0xff) {
446
+ i++;
447
+ continue;
448
+ }
449
+ const marker = buf[i + 1];
450
+ if (marker === 0xff) {
451
+ i++;
452
+ continue;
453
+ }
454
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
455
+ return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
456
+ }
457
+ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
458
+ i += 2;
459
+ continue;
460
+ }
461
+ i += 2 + buf.readUInt16BE(i + 2);
462
+ }
463
+ }
464
+ return null;
465
+ };
466
+ //# sourceMappingURL=safe-fetch.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aunucore/baileys",
3
3
  "type": "module",
4
- "version": "1.0.83",
4
+ "version": "1.0.85",
5
5
  "description": "AunuBaileys — the self-healing WhatsApp Web API for Node.js. Bad MAC and \"2000 messages into the future\" errors repair themselves, LID and phone-number identities stay in sync, anti-ban is built in, pairing codes are custom — plus 29 experimental features no other Baileys fork ships: invisible watermarks, per-person tag-all cards, side-profile text, ghost mentions and more. ESM, CJS and full TypeScript types.",
6
6
  "keywords": [
7
7
  "whatsapp",