@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.
- package/lib/Utils/index.d.ts +1 -0
- package/lib/Utils/index.js +1 -0
- package/lib/Utils/link-preview.js +87 -35
- package/lib/Utils/messages-media.js +123 -20
- package/lib/Utils/messages.js +50 -8
- package/lib/Utils/safe-fetch.d.ts +57 -0
- package/lib/Utils/safe-fetch.js +466 -0
- package/package.json +1 -1
package/lib/Utils/index.d.ts
CHANGED
|
@@ -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
|
package/lib/Utils/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
...opts.fetchOpts,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
ffmpeg.on('
|
|
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
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
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
|
-
|
|
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:
|
|
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...'}`);
|
package/lib/Utils/messages.js
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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",
|