@aunucore/baileys 1.0.84 → 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.
@@ -13,6 +13,7 @@ export * from './auth-utils.js';
13
13
  export * from './baileys-event-stream.js';
14
14
  export * from './use-multi-file-auth-state.js';
15
15
  export * from './link-preview.js';
16
+ export * from './safe-fetch.js';
16
17
  export * from './event-buffer.js';
17
18
  export * from './process-message.js';
18
19
  //# sourceMappingURL=index.d.ts.map
@@ -31,6 +31,7 @@ export * from './auth-utils.js';
31
31
  export * from './baileys-event-stream.js';
32
32
  export * from './use-multi-file-auth-state.js';
33
33
  export * from './link-preview.js';
34
+ export * from './safe-fetch.js';
34
35
  export * from './event-buffer.js';
35
36
  export * from './process-message.js';
36
37
  //# sourceMappingURL=index.js.map
@@ -1,15 +1,71 @@
1
1
  import { prepareWAMessageMedia } from './messages.js';
2
- import { extractImageThumb, getHttpStream } from './messages-media.js';
2
+ import { extractImageThumb } from './messages-media.js';
3
+ import { readImageDimensions, safeHttpGet } from './safe-fetch.js';
3
4
  const THUMBNAIL_WIDTH_PX = 192;
5
+ // AunuBaileys (1.0.85): everything in this module is fetched on behalf
6
+ // of a REMOTE PAGE — the URL comes from message text and the og:image
7
+ // from the fetched page itself — so all of it now goes through the
8
+ // SSRF-safe fetch (lib/Utils/safe-fetch.js): non-public addresses are
9
+ // refused at connect time (peer-supplied URLs used to be able to make
10
+ // the host GET loopback / RFC1918 / cloud-metadata addresses), and
11
+ // every response is size- and time-bounded. link-preview-js is used
12
+ // for HTML parsing ONLY (getPreviewFromContent); it no longer
13
+ // performs any network access, so the protections do not depend on
14
+ // the installed link-preview-js version. This also closes HB-205:
15
+ // the HTML body used to be read in full with no cap and no overall
16
+ // timeout (the library's abort timer covered only time-to-headers).
17
+ const MAX_HTML_BYTES = 1024 * 1024; // og: tags live in <head>; the rest is cut off
18
+ const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024;
19
+ const MAX_THUMBNAIL_PIXELS = 40000000; // refuse decompression bombs before the image library sees them
20
+ const MAX_PREVIEW_TIMEOUT_MS = 30000;
21
+ const HTMLISH = /^(text\/html|application\/xhtml\+xml|text\/xml|application\/xml)\b/i;
22
+ /** Only these keys of the caller's fetchOpts are honoured now. */
23
+ const transportOpts = (fetchOpts = {}) => ({
24
+ // Sanitised at the boundary: a finite positive timeout is honored
25
+ // (clamped to a sane preview ceiling), anything else gets the
26
+ // historical 3000 ms default.
27
+ timeout: typeof fetchOpts.timeout === 'number' && Number.isFinite(fetchOpts.timeout) && fetchOpts.timeout > 0
28
+ ? Math.min(fetchOpts.timeout, MAX_PREVIEW_TIMEOUT_MS)
29
+ : 3000,
30
+ headers: fetchOpts.headers,
31
+ // Explicit, host-controlled hooks only — never derived from
32
+ // message content: an opt-in for deployments that really want
33
+ // previews of intranet links, and resolver/policy overrides.
34
+ allowPrivateNetworks: fetchOpts.allowPrivateNetworks === true,
35
+ isBlocked: typeof fetchOpts.isBlockedAddress === 'function' ? fetchOpts.isBlockedAddress : undefined,
36
+ lookup: typeof fetchOpts.lookup === 'function' ? fetchOpts.lookup : undefined
37
+ });
38
+ const decodeBody = (body, contentType) => {
39
+ const charset = /charset=["']?([\w-]+)/i.exec(contentType || '')?.[1];
40
+ try {
41
+ return new TextDecoder(charset || 'utf-8').decode(body);
42
+ }
43
+ catch {
44
+ return new TextDecoder('utf-8').decode(body);
45
+ }
46
+ };
47
+ /** Fetches an og:image through the safe fetch, with byte + pixel bounds. */
48
+ const fetchThumbnailBuffer = async (imageUrl, fetchOpts) => {
49
+ const res = await safeHttpGet(imageUrl, {
50
+ ...transportOpts(fetchOpts),
51
+ maxBytes: MAX_THUMBNAIL_BYTES,
52
+ onLimit: 'throw'
53
+ });
54
+ const dims = readImageDimensions(res.body);
55
+ if (dims && dims.width * dims.height > MAX_THUMBNAIL_PIXELS) {
56
+ throw new Error(`thumbnail dimensions ${dims.width}x${dims.height} exceed the safety limit`);
57
+ }
58
+ return res.body;
59
+ };
4
60
  /** Fetches an image and generates a thumbnail for it */
5
- // AunuBaileys (1.0.72): an og:image only ever becomes a small JPEG —
6
- // cap the source download instead of buffering a hostile endless
7
- // stream in full (HB-89). getUrlInfo catches thumbnail errors and
8
- // degrades to no thumbnail.
9
- const MAX_THUMBNAIL_SOURCE_BYTES = 8 * 1024 * 1024;
10
61
  const getCompressedJpegThumbnail = async (url, { thumbnailWidth, fetchOpts }) => {
11
- const stream = await getHttpStream(url, fetchOpts);
12
- const result = await extractImageThumb(stream, thumbnailWidth, MAX_THUMBNAIL_SOURCE_BYTES);
62
+ // AunuBaileys (1.0.72): an og:image only ever becomes a small
63
+ // JPEG — cap the source download instead of buffering a hostile
64
+ // endless stream in full (HB-89). (1.0.85): the cap is now
65
+ // enforced by the safe fetch (5 MiB, throw) before the bytes
66
+ // reach the image library, and pixel bombs are refused up front.
67
+ const buffer = await fetchThumbnailBuffer(url, fetchOpts);
68
+ const result = await extractImageThumb(buffer, thumbnailWidth);
13
69
  return result;
14
70
  };
15
71
  /**
@@ -23,34 +79,22 @@ export const getUrlInfo = async (text, opts = {
23
79
  fetchOpts: { timeout: 3000 }
24
80
  }) => {
25
81
  try {
26
- // retries
27
- const retries = 0;
28
- const maxRetry = 5;
29
- const { getLinkPreview } = await import('link-preview-js');
82
+ const { getPreviewFromContent } = await import('link-preview-js');
30
83
  let previewLink = text;
31
84
  if (!text.startsWith('https://') && !text.startsWith('http://')) {
32
85
  previewLink = 'https://' + previewLink;
33
86
  }
34
- const info = await getLinkPreview(previewLink, {
35
- ...opts.fetchOpts,
36
- followRedirects: 'follow',
37
- handleRedirects: (baseURL, forwardedURL) => {
38
- const urlObj = new URL(baseURL);
39
- const forwardedURLObj = new URL(forwardedURL);
40
- if (retries >= maxRetry) {
41
- return false;
42
- }
43
- if (forwardedURLObj.hostname === urlObj.hostname ||
44
- forwardedURLObj.hostname === 'www.' + urlObj.hostname ||
45
- 'www.' + forwardedURLObj.hostname === urlObj.hostname) {
46
- retries + 1;
47
- return true;
48
- }
49
- else {
50
- return false;
51
- }
52
- },
53
- headers: opts.fetchOpts
87
+ const res = await safeHttpGet(previewLink, {
88
+ ...transportOpts(opts.fetchOpts),
89
+ maxBytes: MAX_HTML_BYTES,
90
+ onLimit: 'truncate',
91
+ shouldReadBody: (_status, headers) => !headers['content-type'] || HTMLISH.test(headers['content-type'])
92
+ });
93
+ const contentType = res.headers['content-type'];
94
+ const info = await getPreviewFromContent({
95
+ url: res.url,
96
+ data: decodeBody(res.body, contentType),
97
+ headers: contentType ? { 'content-type': contentType } : {}
54
98
  });
55
99
  if (info && 'title' in info && info.title) {
56
100
  const [image] = info.images;
@@ -61,8 +105,16 @@ export const getUrlInfo = async (text, opts = {
61
105
  description: info.description,
62
106
  originalThumbnailUrl: image
63
107
  };
64
- if (opts.uploadImage) {
65
- const { imageMessage } = await prepareWAMessageMedia({ image: { url: image } }, {
108
+ if (opts.uploadImage && image) {
109
+ // AunuBaileys (1.0.85): the high-quality thumbnail is
110
+ // fetched through the safe fetch as a Buffer instead
111
+ // of handing the page-supplied URL to the generic
112
+ // media pipeline. The `&& image` guard also closes
113
+ // HB-150: a page without an og:image used to explode
114
+ // here with a raw TypeError and lose the whole
115
+ // preview.
116
+ const thumb = await fetchThumbnailBuffer(image, opts.fetchOpts);
117
+ const { imageMessage } = await prepareWAMessageMedia({ image: thumb }, {
66
118
  upload: opts.uploadImage,
67
119
  mediaTypeOverride: 'thumbnail-link',
68
120
  options: opts.fetchOpts
@@ -87,4 +139,4 @@ export const getUrlInfo = async (text, opts = {
87
139
  }
88
140
  }
89
141
  };
90
- //# sourceMappingURL=link-preview.js.map
142
+ //# sourceMappingURL=link-preview.js.map
@@ -97,6 +97,15 @@ export async function getMediaKeys(buffer, mediaType) {
97
97
  };
98
98
  }
99
99
  /** Extracts video thumb using FFMPEG */
100
+ // AunuBaileys (1.0.85, HB-128): ffmpeg used to run with NO timeout
101
+ // and NO kill, accumulating stderr without bound — a wedged ffmpeg
102
+ // hung generateThumbnail, and with it the Promise.all inside
103
+ // prepareWAMessageMedia, so the video send never settled; a chatty
104
+ // ffmpeg could also grow stderr in memory without limit. Bound
105
+ // both: SIGKILL after the timeout, and keep only the first 64 KiB
106
+ // of stderr (plenty for the error message).
107
+ const FFMPEG_THUMB_TIMEOUT_MS = 30000;
108
+ const FFMPEG_STDERR_CAP_BYTES = 64 * 1024;
100
109
  const extractVideoThumb = async (path, destPath, time, size) => new Promise((resolve, reject) => {
101
110
  // AunuBaileys: spawn dengan array argumen, tanpa shell (dari @itsliaaa/baileys) —
102
111
  // path/waktu/ukuran tidak lagi diinterpolasi ke string perintah shell
@@ -110,12 +119,33 @@ const extractVideoThumb = async (path, destPath, time, size) => new Promise((res
110
119
  destPath
111
120
  ], { stdio: ['ignore', 'ignore', 'pipe'] });
112
121
  const stderrChunks = [];
113
- ffmpeg.stderr.on('data', (chunk) => stderrChunks.push(chunk));
114
- ffmpeg.on('error', reject);
122
+ let stderrBytes = 0;
123
+ ffmpeg.stderr.on('data', (chunk) => {
124
+ if (stderrBytes >= FFMPEG_STDERR_CAP_BYTES) {
125
+ return;
126
+ }
127
+ const keep = chunk.subarray(0, FFMPEG_STDERR_CAP_BYTES - stderrBytes);
128
+ stderrChunks.push(keep);
129
+ stderrBytes += keep.length;
130
+ });
131
+ let timedOut = false;
132
+ const timer = setTimeout(() => {
133
+ timedOut = true;
134
+ ffmpeg.kill('SIGKILL');
135
+ }, FFMPEG_THUMB_TIMEOUT_MS);
136
+ timer.unref?.();
137
+ ffmpeg.on('error', (err) => {
138
+ clearTimeout(timer);
139
+ reject(err);
140
+ });
115
141
  ffmpeg.on('close', (code) => {
142
+ clearTimeout(timer);
116
143
  if (code === 0) {
117
144
  resolve();
118
145
  }
146
+ else if (timedOut) {
147
+ reject(new Error(`ffmpeg timed out after ${FFMPEG_THUMB_TIMEOUT_MS}ms while extracting a video thumbnail`));
148
+ }
119
149
  else {
120
150
  reject(new Error(`ffmpeg exited with code ${code}: ${Buffer.concat(stderrChunks).toString('utf8').trim()}`));
121
151
  }
@@ -179,6 +209,10 @@ export const extractImageThumb = async (bufferOrFilePath, width = 32, maxBytes)
179
209
  }
180
210
  };
181
211
  export const encodeBase64EncodedStringForUpload = (b64) => encodeURIComponent(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, ''));
212
+ // AunuBaileys (1.0.85, HB-219): bounds for the remote fetch in
213
+ // generateProfilePicture (16 MiB matches the product-image cap).
214
+ const MAX_PROFILE_PICTURE_SOURCE_BYTES = 16 * 1024 * 1024;
215
+ const PROFILE_PICTURE_FETCH_TIMEOUT_MS = 30000;
182
216
  export const generateProfilePicture = async (mediaUpload, dimensions) => {
183
217
  let buffer;
184
218
  const { width: w = 640, height: h = 640 } = dimensions || {};
@@ -187,9 +221,16 @@ export const generateProfilePicture = async (mediaUpload, dimensions) => {
187
221
  }
188
222
  else {
189
223
  // Use getStream to handle all WAMediaUpload types (Buffer, Stream, URL)
190
- const { stream } = await getStream(mediaUpload);
191
- // Convert the resulting stream to a buffer
192
- buffer = await toBuffer(stream);
224
+ // AunuBaileys (1.0.85, HB-219): a remote URL used to be
225
+ // buffered with NO size cap and NO timeout — a huge or
226
+ // never-ending response was accumulated in full before the
227
+ // image decode finally rejected it (verified: 12 MB of junk
228
+ // buffered whole, RSS +53 MB), and a stalled server hung
229
+ // updateProfilePicture forever. Bound both, like the
230
+ // sticker-pack and product-image paths already do.
231
+ const { stream } = await getStream(mediaUpload, { timeout: PROFILE_PICTURE_FETCH_TIMEOUT_MS });
232
+ // Convert the resulting stream to a buffer, with a hard cap
233
+ buffer = await toBufferWithLimit(stream, MAX_PROFILE_PICTURE_SOURCE_BYTES);
193
234
  }
194
235
  const lib = await getImageProcessingLibrary();
195
236
  let img;
@@ -323,9 +364,20 @@ export const getStream = async (item, opts) => {
323
364
  if (Buffer.isBuffer(item)) {
324
365
  return { stream: toReadable(item), type: 'buffer' };
325
366
  }
367
+ // AunuBaileys (1.0.85, HB-208): unusable media used to explode
368
+ // here as raw TypeErrors — 'stream' in undefined/null for a
369
+ // missing media value, a toString deref for an object with no
370
+ // usable url, a createReadStream type error for a numeric url.
371
+ // Reject all of them with a clear Boom 400 instead.
372
+ if (!item || typeof item !== 'object') {
373
+ throw new Boom('Invalid media: expected a Buffer, a stream, or an object with a url', { statusCode: 400 });
374
+ }
326
375
  if ('stream' in item) {
327
376
  return { stream: item.stream, type: 'readable' };
328
377
  }
378
+ if (typeof item.url !== 'string' && !(item.url instanceof URL)) {
379
+ throw new Boom('Invalid media: expected a Buffer, a stream, or an object with a url', { statusCode: 400 });
380
+ }
329
381
  const urlStr = item.url.toString();
330
382
  if (urlStr.startsWith('data:')) {
331
383
  const comma = urlStr.indexOf(',');
@@ -533,7 +585,17 @@ export const encryptedStream = async (media, mediaType, { logger, saveOriginalFi
533
585
  }
534
586
  }
535
587
  if (streamableVerdict === null) {
536
- head = Buffer.concat([head, data]);
588
+ // AunuBaileys (1.0.85, HB-220): the scan only ever
589
+ // reads the leading ~1 KiB (it decides from box
590
+ // headers/sizes in that prefix and forces a verdict
591
+ // past AUNU_SIDECAR_SCAN_MAX_BYTES), but the WHOLE
592
+ // chunk used to be concatenated into `head` and held
593
+ // for the entire call — and for Buffer input the
594
+ // whole media arrives as ONE chunk, so `head` was a
595
+ // full second copy of the media (measured: ~+88 MB
596
+ // peak RSS on a 48 MB video). Retain only the prefix
597
+ // the scan can consume; its verdicts are unchanged.
598
+ head = Buffer.concat([head, data]).subarray(0, AUNU_SIDECAR_SCAN_MAX_BYTES + 32);
537
599
  streamableVerdict = aunuScanStreamable(head);
538
600
  if (streamableVerdict === null && head.length > AUNU_SIDECAR_SCAN_MAX_BYTES) {
539
601
  streamableVerdict = true;
@@ -735,20 +797,37 @@ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv, mac
735
797
  const decryptLength = toSmallestChunkSize(data.length);
736
798
  remainingBytes = data.slice(decryptLength);
737
799
  data = data.slice(0, decryptLength);
738
- if (!aes) {
739
- let ivValue = iv;
740
- if (firstBlockIsIV) {
741
- ivValue = data.slice(0, AES_CHUNK_SIZE);
742
- data = data.slice(AES_CHUNK_SIZE);
743
- }
744
- aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue);
745
- // if an end byte that is not EOF is specified
746
- // stop auto padding (PKCS7) -- otherwise throws an error for decryption
747
- if (endByte) {
748
- aes.setAutoPadding(false);
749
- }
750
- }
751
800
  try {
801
+ if (!aes) {
802
+ // AunuBaileys (1.0.85, HB-114/HB-158): on the
803
+ // ranged path the IV is carved out of the first
804
+ // 16 bytes of the body — when the first chunk(s)
805
+ // total less than that, there is nothing to build
806
+ // a decipher from yet. createDecipheriv used to
807
+ // sit OUTSIDE this try and threw a bare
808
+ // "Invalid initialization vector" TypeError for
809
+ // exactly that download. Wait for more bytes
810
+ // instead (a <16-byte slice means decryptLength
811
+ // was 0, so everything is still in
812
+ // remainingBytes), and keep the construction
813
+ // inside the try so any failure surfaces as a
814
+ // proper stream error with context.
815
+ if (firstBlockIsIV && data.length < AES_CHUNK_SIZE) {
816
+ callback();
817
+ return;
818
+ }
819
+ let ivValue = iv;
820
+ if (firstBlockIsIV) {
821
+ ivValue = data.slice(0, AES_CHUNK_SIZE);
822
+ data = data.slice(AES_CHUNK_SIZE);
823
+ }
824
+ aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue);
825
+ // if an end byte that is not EOF is specified
826
+ // stop auto padding (PKCS7) -- otherwise throws an error for decryption
827
+ if (endByte) {
828
+ aes.setAutoPadding(false);
829
+ }
830
+ }
752
831
  pushBytes(aes.update(data), b => this.push(b));
753
832
  callback();
754
833
  }
@@ -807,8 +886,18 @@ export function extensionForMediaMessage(message) {
807
886
  }
808
887
  return extension;
809
888
  }
889
+ // AunuBaileys (1.0.85, HB-127): default ceiling for one media-upload POST.
890
+ const MEDIA_UPLOAD_DEFAULT_TIMEOUT_MS = 120000;
810
891
  export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, options }, refreshMediaConn) => {
811
892
  return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => {
893
+ // AunuBaileys (1.0.85, HB-127): timeoutMs used to be a pure
894
+ // pass-through of the host's mediaUploadTimeoutMs, with no
895
+ // default anywhere — an upload host that black-holes the
896
+ // connection (accepts TCP, never speaks) left the POST, and
897
+ // with it prepareWAMessageMedia -> sendMessage, pending
898
+ // FOREVER. Sanitise at the boundary: a finite positive
899
+ // value is honored, anything else gets the default.
900
+ const effectiveTimeoutMs = typeof timeoutMs === 'number' && Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : MEDIA_UPLOAD_DEFAULT_TIMEOUT_MS;
812
901
  // send a query JSON to obtain the url & auth token to upload our media
813
902
  let uploadInfo = await refreshMediaConn(false);
814
903
  let urls;
@@ -837,7 +926,7 @@ export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, opt
837
926
  Origin: DEFAULT_ORIGIN
838
927
  },
839
928
  httpsAgent: fetchAgent,
840
- timeout: timeoutMs,
929
+ timeout: effectiveTimeoutMs,
841
930
  responseType: 'json',
842
931
  maxBodyLength: Infinity,
843
932
  maxContentLength: Infinity
@@ -858,6 +947,20 @@ export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, opt
858
947
  catch (error) {
859
948
  if (axios.isAxiosError(error)) {
860
949
  result = error.response?.data;
950
+ // AunuBaileys (1.0.85, HB-137): a 401/403 means
951
+ // the media auth itself is stale. The no-URL
952
+ // branch above already force-refreshes in that
953
+ // situation, but this error branch kept retrying
954
+ // the remaining hosts with the SAME dead auth —
955
+ // and every later send reused the cached conn
956
+ // until its TTL expired by the local clock, so
957
+ // all media sends kept failing. Refresh
958
+ // (best-effort: a failed refresh keeps the old
959
+ // info) so the next host — and the next send —
960
+ // uses fresh auth.
961
+ if (error.response?.status === 401 || error.response?.status === 403) {
962
+ uploadInfo = await refreshMediaConn(true).catch(() => uploadInfo);
963
+ }
861
964
  }
862
965
  const isLast = hostname === hosts[uploadInfo.hosts.length - 1]?.hostname;
863
966
  logger.warn({ trace: error.stack, uploadResult: result }, `Error in uploading to ${hostname} ${isLast ? '' : ', retrying...'}`);
@@ -94,6 +94,17 @@ export const prepareWAMessageMedia = async (message, options) => {
94
94
  media: message[mediaType]
95
95
  };
96
96
  delete uploadData[mediaType];
97
+ // AunuBaileys (1.0.85, HB-208 + addendum): reject an unusable
98
+ // media value HERE, before the cacheable-key computation below
99
+ // derefs it — a null media used to explode in that computation
100
+ // itself ('url' in null), and undefined / non-object media as a
101
+ // raw TypeError further down in getStream. Hosts get a clear
102
+ // Boom 400 at the boundary instead. ({} and { url: undefined }
103
+ // pass this check and are rejected by the matching getStream
104
+ // guard.)
105
+ if (typeof uploadData.media !== 'object' || uploadData.media === null) {
106
+ throw new Boom(`Invalid media for "${mediaType}": expected a Buffer, a stream, or an object with a url`, { statusCode: 400 });
107
+ }
97
108
  // check if cacheable + generate cache key
98
109
  const cacheableKey = typeof uploadData.media === 'object' &&
99
110
  'url' in uploadData.media &&
@@ -421,15 +432,25 @@ async function prepareStickerPackMessage(stickerPack, options) {
421
432
  // AunuBaileys (1.0.71): only load the image library when
422
433
  // a non-WebP sticker actually needs converting — pure
423
434
  // WebP packs used to pay the import cost for nothing.
424
- const { sharp } = await loadLib();
425
- if (sharp) {
435
+ const imgLib = await loadLib();
436
+ if ('sharp' in imgLib && imgLib.sharp) {
426
437
  // Convert to WebP, preserving metadata
427
- webpBuffer = await sharp.default(buffer).webp().toBuffer();
438
+ webpBuffer = await imgLib.sharp.default(buffer).webp().toBuffer();
428
439
  // Non-WebP inputs converted to WebP are not animated
429
440
  isAnimated = false;
430
441
  }
442
+ else if ('image' in imgLib && imgLib.image?.Transformer) {
443
+ // AunuBaileys (1.0.85, HB-164): the @napi-rs/image
444
+ // backend converts to WebP fine (extractImageThumb
445
+ // already uses it) — the pack path used to ignore
446
+ // it, so a host with @napi-rs/image but no sharp
447
+ // failed EVERY non-WebP sticker (and an all-PNG
448
+ // pack failed outright via HB-97).
449
+ webpBuffer = await new imgLib.image.Transformer(buffer).webp();
450
+ isAnimated = false;
451
+ }
431
452
  else {
432
- throw new Boom('No image processing library (sharp) available for converting sticker to WebP. Either install sharp or provide stickers in WebP format.');
453
+ throw new Boom('No image processing library (sharp or @napi-rs/image) available for converting sticker to WebP. Either install sharp or @napi-rs/image, or provide stickers in WebP format.');
433
454
  }
434
455
  }
435
456
  if (webpBuffer.length > 1024 * 1024) {
@@ -474,12 +495,17 @@ async function prepareStickerPackMessage(stickerPack, options) {
474
495
  coverWebpBuffer = coverBuffer;
475
496
  }
476
497
  else {
477
- const { sharp } = await loadLib();
478
- if (sharp) {
479
- coverWebpBuffer = await sharp.default(coverBuffer).webp().toBuffer();
498
+ const coverLib = await loadLib();
499
+ if ('sharp' in coverLib && coverLib.sharp) {
500
+ coverWebpBuffer = await coverLib.sharp.default(coverBuffer).webp().toBuffer();
501
+ }
502
+ else if ('image' in coverLib && coverLib.image?.Transformer) {
503
+ // AunuBaileys (1.0.85, HB-164): @napi-rs/image backend,
504
+ // same gap as the sticker conversion above.
505
+ coverWebpBuffer = await new coverLib.image.Transformer(coverBuffer).webp();
480
506
  }
481
507
  else {
482
- throw new Boom('No image processing library (sharp) available for converting cover to WebP. Either install sharp or provide cover in WebP format.');
508
+ throw new Boom('No image processing library (sharp or @napi-rs/image) available for converting cover to WebP. Either install sharp or @napi-rs/image, or provide cover in WebP format.');
483
509
  }
484
510
  }
485
511
  // Add cover to ZIP data
@@ -539,6 +565,13 @@ async function prepareStickerPackMessage(stickerPack, options) {
539
565
  if ('sharp' in lib && lib.sharp) {
540
566
  thumbnailBuffer = await lib.sharp.default(coverBuffer).resize(252, 252).jpeg().toBuffer();
541
567
  }
568
+ else if ('image' in lib && lib.image?.Transformer) {
569
+ // AunuBaileys (1.0.85, HB-164): @napi-rs/image backend —
570
+ // the thumbnail used to fall through to a throw (and a
571
+ // silently dropped pack thumbnail) on hosts whose only
572
+ // image library is @napi-rs/image.
573
+ thumbnailBuffer = await new lib.image.Transformer(coverBuffer).resize(252, 252).jpeg(50);
574
+ }
542
575
  else if ('jimp' in lib && lib.jimp) {
543
576
  const jimpImage = await lib.jimp.Jimp.read(coverBuffer);
544
577
  thumbnailBuffer = await jimpImage.resize({ w: 252, h: 252 }).getBuffer('image/jpeg');
@@ -1603,6 +1636,15 @@ export const downloadMediaMessage = async (message, type, options, ctx) => {
1603
1636
  }
1604
1637
  const contentType = getContentType(mContent);
1605
1638
  let mediaType = contentType?.replace('Message', '');
1639
+ // AunuBaileys (1.0.85, HB-252): the content type derives
1640
+ // 'stickerPack' from stickerPackMessage, but the HKDF info
1641
+ // mapping only knows the upload-side name 'sticker-pack' —
1642
+ // downloads derived their keys from 'WhatsApp undefined
1643
+ // Keys' and ALWAYS failed with a misleading bad-decrypt
1644
+ // error. Normalise to the name the pack was encrypted with.
1645
+ if (mediaType === 'stickerPack') {
1646
+ mediaType = 'sticker-pack';
1647
+ }
1606
1648
  const media = mContent[contentType];
1607
1649
  if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) {
1608
1650
  throw new Boom(`"${contentType}" message is not a media message`);
@@ -0,0 +1,57 @@
1
+ import type { LookupFunction } from 'net';
2
+ export declare class SafeFetchError extends Error {
3
+ code: string;
4
+ host?: string;
5
+ address?: string;
6
+ statusCode?: number;
7
+ limit?: number;
8
+ constructor(code: string, message: string, extra?: Record<string, unknown>);
9
+ }
10
+ export declare const SAFE_FETCH_DEFAULTS: Readonly<{
11
+ timeout: 3000;
12
+ maxRedirects: 5;
13
+ maxBytes: number;
14
+ }>;
15
+ /** true when `address` must never be contacted. Unparseable input is blocked (fail closed). */
16
+ export declare const isBlockedAddress: (address: string) => boolean;
17
+ export declare const createGuardedLookup: (opts?: {
18
+ lookup?: LookupFunction;
19
+ isBlocked?: (address: string) => boolean;
20
+ }) => LookupFunction;
21
+ export type SafeHttpGetOptions = {
22
+ /** idle/connect timeout per hop in ms (default 3000) */
23
+ timeout?: number;
24
+ /** hard deadline for the whole call incl. redirects (default 3 x timeout) */
25
+ totalTimeout?: number;
26
+ /** response cap in bytes, applied after decompression (default 1 MiB) */
27
+ maxBytes?: number;
28
+ /** 'throw' (default) or 'truncate' when the cap is hit */
29
+ onLimit?: 'throw' | 'truncate';
30
+ /** default 5 */
31
+ maxRedirects?: number;
32
+ /** 'any' (default) | 'same-host' | 'none'. Every hop is address-checked either way. */
33
+ redirects?: 'any' | 'same-host' | 'none';
34
+ headers?: Record<string, string>;
35
+ /** return false to skip downloading the body */
36
+ shouldReadBody?: (status: number, headers: Record<string, string>) => boolean;
37
+ /** explicit opt-out of the address policy (default false) */
38
+ allowPrivateNetworks?: boolean;
39
+ /** injection points (host hooks, tests) */
40
+ lookup?: LookupFunction;
41
+ isBlocked?: (address: string) => boolean;
42
+ };
43
+ export type SafeHttpGetResult = {
44
+ url: string;
45
+ status: number;
46
+ headers: Record<string, string>;
47
+ body: Buffer;
48
+ truncated: boolean;
49
+ redirects: number;
50
+ };
51
+ export declare function safeHttpGet(input: string | URL, options?: SafeHttpGetOptions): Promise<SafeHttpGetResult>;
52
+ /** Reads width/height from PNG, GIF, JPEG and WebP headers without decoding. null if unknown. */
53
+ export declare const readImageDimensions: (buf: Buffer) => {
54
+ width: number;
55
+ height: number;
56
+ } | null;
57
+ //# sourceMappingURL=safe-fetch.d.ts.map
@@ -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.84",
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",