@aunucore/baileys 1.0.75 โ†’ 1.0.77

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/README.md CHANGED
@@ -128,9 +128,9 @@ Everything else is the upstream core. The patch layer stays small and deliberate
128
128
 
129
129
  ### ๐Ÿ“ฐ Recent releases
130
130
 
131
+ - **1.0.76 โ€” Hunt round 3: the decoder + trust-boundary batch.** Every claim in an external deep-audit was verified against the tree (with repros) before anything was patched. The WABinary decoder gets a 512-level nesting cap (a ~100 KB stanza previously recursed 20,000 deep into a raw stack-overflow RangeError) and a 128 MiB zlib output cap (a max-size frame could inflate ~1000ร— toward OOM). Media downloads now verify the WhatsApp HMAC on full downloads โ€” tampered or truncated media errors out instead of decrypting silently โ€” and download hosts are allowlisted to WhatsApp/Facebook CDN domains, closing a sender-driven SSRF (foreign hosts fall back to `mmg.whatsapp.net`). Auth files are written `0600` (they hold private keys; old `0644` files self-heal on next save), GraphQL error bodies are drained instead of pinning keep-alive sockets, `link_code_companion_reg` key material is length-validated before any pairing secret is derived, the session watchdog also hears raw WebSocket frames (buffered-event delays no longer read as a false deaf session), and a dead `taskTimeout` in the mutex factory is removed. Deliberately untouched, with recorded reasoning: `isTrustedIdentity` (upstream TOFU), unbounded host store arrays, and the app-state transaction design.
131
132
  - **1.0.75 โ€” Partial-config safety + watchdog cleanup.** A partial `transactionOpts` in the socket config can no longer kill every key-store transaction: the config is deep-merged over the defaults and the transaction layer falls back per-field (`maxCommitRetries` 10, `delayBetweenTriesMs` 3000, retries clamped to at least 1 so an explicit `0` means exactly one attempt) โ€” previously the shallow config spread replaced the whole default object, `maxCommitRetries` came out `undefined`, and every transaction threw `failed to commit transaction mutations` without a single commit attempt. The session watchdog's interval is now stopped when the socket ends (its `stop()` existed but was never called anywhere), closing a one-timer-and-socket-graph leak per reconnect cycle.
132
133
  - **1.0.74 โ€” Fix-all batch.** The entire 1.0.74 heartbeat queue, closed in one version: `resyncAppState` can no longer spin forever holding the key-store transaction mutex (server-omitted collections finish with a warning, plus a 25-round cap), `startByte`-only media downloads no longer silently return an empty stream, an all-failed sticker pack throws Boom 400 instead of uploading empty, key-material guards now check TYPES (Buffer/string only) at six sites so wrong-typed creds fail descriptively instead of as raw TypeErrors, the noise handshake guards a missing `noiseKey` up front, the pre-login `creds.me` dereference family is closed (acks for `<unavailable>` stanzas survive fresh pairing), and two per-entity maps (`RateLimiter.hits`, bug-guard `recentBySender`) evict stale keys with a lazy sweep.
133
- - **1.0.73 โ€” Heartbeat queue + a new README.** Three verified heartbeat fixes: a corrupt frame header can no longer wedge `decodeFrame` (zero-length headers are skipped, frames over 10 MiB fail loudly instead of buffering forever), `botRecentSecretsByChat` is capped at 1,000 chats FIFO, and bare key-store writes are serialized through the transaction mutex so a `migrateSession` commit can't overwrite a fresher session record. Plus a fully rewritten README and 46 curated npm keywords.
134
134
  Full changelog for every version since 1.0.2: **[docs](https://docsjs.aunucore.eu.cc)** ยท **[GitHub Releases](https://github.com/AunuCore/AunuBaileys-lib/releases)**.
135
135
 
136
136
  ### ๐Ÿ™ Credits
@@ -68,7 +68,12 @@ export const executeWWWGraphQL = async (docId, variables, accessToken, dataPath
68
68
  body
69
69
  });
70
70
  if (!res.ok) {
71
- throw new Boom(`GraphQL HTTP error ${res.status}`, { statusCode: res.status });
71
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” drain the error body before
72
+ // throwing. An unconsumed fetch body pins its keep-alive socket
73
+ // out of the undici pool until GC; repeated GraphQL errors would
74
+ // steadily starve the pool. The body text also improves the error.
75
+ const errBody = await res.text().catch(() => '');
76
+ throw new Boom(`GraphQL HTTP error ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ''}`, { statusCode: res.status });
72
77
  }
73
78
  const json = await res.json();
74
79
  return parseGraphQLResponse(json, dataPath);
@@ -104,7 +109,9 @@ export const executeWamoGraphQL = async (docId, variables, wamoAuth = {}, dataPa
104
109
  body: params.toString()
105
110
  });
106
111
  if (!res.ok) {
107
- throw new Boom(`Wamo GraphQL HTTP error ${res.status}`, { statusCode: res.status });
112
+ // AunuBaileys (1.0.76): drain the error body (see executeWWWGraphQL).
113
+ const errBody = await res.text().catch(() => '');
114
+ throw new Boom(`Wamo GraphQL HTTP error ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ''}`, { statusCode: res.status });
108
115
  }
109
116
  const json = await res.json();
110
117
  return parseGraphQLResponse(json, dataPath);
@@ -523,6 +523,19 @@ export const makeMessagesRecvSocket = (config) => {
523
523
  const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_ref'));
524
524
  const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub'));
525
525
  const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_wrapped_primary_ephemeral_pub'));
526
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” toRequiredBuffer only
527
+ // checks PRESENCE. The wrapped ephemeral key has a fixed
528
+ // wire shape (32B salt + 16B IV + 32B encrypted pubkey =
529
+ // 80) and an identity pubkey is 32B raw / 33B prefixed;
530
+ // anything else is a malformed stanza. Most bad lengths
531
+ // already died inside libsignal BEFORE advSecretKey was
532
+ // assigned, but 31/32-byte inputs slipped through and
533
+ // derived a garbage advSecretKey that then got persisted.
534
+ // Refuse up front instead of deriving creds from junk.
535
+ if (primaryEphemeralPublicKeyWrapped.length < 80 ||
536
+ (primaryIdentityPublicKey.length !== 32 && primaryIdentityPublicKey.length !== 33)) {
537
+ throw new Boom('link_code_companion_reg: malformed key material in server stanza โ€” refusing to derive pairing secrets from it', { statusCode: 400 });
538
+ }
526
539
  const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped);
527
540
  const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey);
528
541
  const random = randomBytes(32);
@@ -334,12 +334,23 @@ export const makeMessagesSocket = (config) => {
334
334
  // the transport to reopen, bounded by connectTimeoutMs; a connection
335
335
  // that closes for good still fails fast via waitForSocketOpen itself.
336
336
  const waitMs = config.connectTimeoutMs ?? 60000;
337
- await Promise.race([
338
- waitForSocketOpen(),
339
- delay(waitMs).then(() => {
340
- throw new Boom(`Timed out waiting ${waitMs}ms for the connection to open before sending`, { statusCode: 408 }); // 408 = DisconnectReason.timedOut
341
- })
342
- ]);
337
+ const openWait = waitForSocketOpen();
338
+ try {
339
+ await Promise.race([
340
+ openWait,
341
+ delay(waitMs).then(() => {
342
+ throw new Boom(`Timed out waiting ${waitMs}ms for the connection to open before sending`, { statusCode: 408 }); // 408 = DisconnectReason.timedOut
343
+ })
344
+ ]);
345
+ }
346
+ catch (err) {
347
+ // AunuBaileys (1.0.77, HB-167): when the timeout wins,
348
+ // the socket-open waiter is still pending โ€” detach its
349
+ // ws listeners now instead of leaving three behind per
350
+ // failed send until the socket settles on its own.
351
+ openWait.cancel?.();
352
+ throw err;
353
+ }
343
354
  }
344
355
  // AunuBaileys (1.0.68): fail with a clear Boom instead of a raw
345
356
  // TypeError when sending before pairing completes or with a
@@ -92,6 +92,15 @@ export const makeSocket = (config) => {
92
92
  let epoch = 1;
93
93
  let keepAliveReq;
94
94
  let qrTimer;
95
+ // AunuBaileys (1.0.77, HB-243): true while a pair-device QR chain
96
+ // is cycling โ€” a duplicate pair-device IQ must not start a second
97
+ // chain on the shared qrTimer slot.
98
+ let qrChainActive = false;
99
+ // AunuBaileys (1.0.77, HB-213): resolved when the Noise handshake
100
+ // completes (finishInit in validateConnection), so the pairing
101
+ // request can wait for an encrypted channel.
102
+ let resolveHandshakeComplete;
103
+ const handshakeComplete = new Promise((resolve) => { resolveHandshakeComplete = resolve; });
95
104
  let closed = false;
96
105
  let lastDisconnectError;
97
106
  const uqTagId = generateMdTagPrefix();
@@ -226,6 +235,7 @@ export const makeSocket = (config) => {
226
235
  }
227
236
  }).finish());
228
237
  noise.finishInit();
238
+ resolveHandshakeComplete?.();
229
239
  startKeepAliveRequest();
230
240
  };
231
241
  const getAvailablePreKeysOnServer = async () => {
@@ -278,12 +288,17 @@ export const makeSocket = (config) => {
278
288
  await uploadPreKeys();
279
289
  }
280
290
  };
291
+ // AunuBaileys (1.0.77, HB-156): decodeFrame invocations must never
292
+ // overlap โ€” the noise handler slices ONE shared inBytes buffer, so
293
+ // two concurrent calls interleave and deliver stanzas out of wire
294
+ // order. Every WS message is chained through a single queue.
295
+ let frameDecodeChain = Promise.resolve();
281
296
  const onMessageReceived = (data) => {
282
297
  // AunuBaileys (1.0.67): decodeFrame is async โ€” a malformed frame
283
298
  // (bad GCM tag, truncated binary node) or a throwing frame
284
299
  // listener must not become an unhandled rejection that kills
285
300
  // the process; route failures to onUnexpectedError instead.
286
- Promise.resolve(noise.decodeFrame(data, frame => {
301
+ frameDecodeChain = frameDecodeChain.then(() => noise.decodeFrame(data, frame => {
287
302
  // reset ping timeout
288
303
  lastDateRecv = new Date();
289
304
  let anyTriggered = false;
@@ -311,7 +326,20 @@ export const makeSocket = (config) => {
311
326
  logger.debug({ unhandled: true, msgId, fromMe: false, frame }, 'communication recv');
312
327
  }
313
328
  }
314
- })).catch(err => onUnexpectedError(err, 'onMessageReceived'));
329
+ })).catch(err => {
330
+ // AunuBaileys (1.0.77, HB-185/HB-207): a frame-layer failure
331
+ // (oversize header, GCM decrypt failure) leaves the noise
332
+ // stream unrecoverable โ€” the header is never consumed, or
333
+ // the read counter desyncs so every later frame fails too.
334
+ // End the connection so the host reconnects at once instead
335
+ // of wedging until the keep-alive watchdog notices (~65s).
336
+ if (err?.isFrameFatal) {
337
+ end(err);
338
+ }
339
+ else {
340
+ onUnexpectedError(err, 'onMessageReceived');
341
+ }
342
+ });
315
343
  };
316
344
  // AunuBaileys: socket end handlers (pola v7) + fetch Mex (dari @itsliaaa/baileys)
317
345
  const socketEndHandlers = [];
@@ -329,6 +357,7 @@ export const makeSocket = (config) => {
329
357
  logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
330
358
  clearInterval(keepAliveReq);
331
359
  clearTimeout(qrTimer);
360
+ qrChainActive = false;
332
361
  ws.removeAllListeners('close');
333
362
  ws.removeAllListeners('open');
334
363
  ws.removeAllListeners('message');
@@ -354,16 +383,20 @@ export const makeSocket = (config) => {
354
383
  ev.removeAllListeners('connection.update');
355
384
  ev.destroy?.();
356
385
  };
357
- const waitForSocketOpen = async () => {
386
+ const waitForSocketOpen = () => {
358
387
  if (ws.isOpen) {
359
- return;
388
+ const resolved = Promise.resolve();
389
+ resolved.cancel = () => { };
390
+ return resolved;
360
391
  }
361
392
  if (ws.isClosed || ws.isClosing) {
362
- throw new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed });
393
+ const rejected = Promise.reject(new Boom('Connection Closed', { statusCode: DisconnectReason.connectionClosed }));
394
+ rejected.cancel = () => { };
395
+ return rejected;
363
396
  }
364
397
  let onOpen;
365
398
  let onClose;
366
- await new Promise((resolve, reject) => {
399
+ const promise = new Promise((resolve, reject) => {
367
400
  onOpen = () => resolve(undefined);
368
401
  onClose = mapWebSocketError(reject);
369
402
  ws.on('open', onOpen);
@@ -374,6 +407,16 @@ export const makeSocket = (config) => {
374
407
  ws.off('close', onClose);
375
408
  ws.off('error', onClose);
376
409
  });
410
+ // AunuBaileys (1.0.77, HB-167/HB-179): a caller that stops
411
+ // waiting (its own timeout won the race) can detach these
412
+ // listeners immediately instead of leaving them on the ws
413
+ // emitter until the socket happens to open/close/error.
414
+ promise.cancel = () => {
415
+ ws.off('open', onOpen);
416
+ ws.off('close', onClose);
417
+ ws.off('error', onClose);
418
+ };
419
+ return promise;
377
420
  };
378
421
  const keepAliveTick = () => {
379
422
  if (!lastDateRecv) {
@@ -501,15 +544,55 @@ export const makeSocket = (config) => {
501
544
  reject(new Boom(`requestPairingCode: socket did not open within ${PAIRING_READY_TIMEOUT_MS / 1000}s (last disconnect: ${describeLastDisconnect()})`, { statusCode: DisconnectReason.timedOut }));
502
545
  }, PAIRING_READY_TIMEOUT_MS);
503
546
  });
547
+ const rawOpenWait = waitForSocketOpen();
504
548
  try {
505
549
  await Promise.race([
506
- waitForSocketOpen().catch(() => {
550
+ rawOpenWait.catch(() => {
507
551
  throw new Boom(`requestPairingCode: connection closed while waiting for the socket to open (last disconnect: ${describeLastDisconnect()})`, { statusCode: DisconnectReason.connectionClosed });
508
552
  }),
509
553
  closedWhileWaiting,
510
554
  timedOut
511
555
  ]);
512
556
  }
557
+ finally {
558
+ clearTimeout(timer);
559
+ // AunuBaileys (1.0.77, HB-179): if the timeout or the close
560
+ // guard won the race, the socket-open waiter is still
561
+ // pending โ€” detach its ws listeners now instead of leaving
562
+ // them behind until the socket settles on its own.
563
+ rawOpenWait.cancel?.();
564
+ if (onUpdate) {
565
+ try {
566
+ ev.off('connection.update', onUpdate);
567
+ }
568
+ catch { }
569
+ }
570
+ }
571
+ };
572
+ // AunuBaileys (1.0.77, HB-213): the pairing IQ must not leave
573
+ // before the Noise handshake completes โ€” encodeFrame encrypts
574
+ // only once the handshake is finished, so an early IQ travels as
575
+ // a plaintext binary node; a strict server kills the connection
576
+ // over it while requestPairingCode still resolves with the code.
577
+ const waitForHandshakeComplete = async () => {
578
+ let onUpdate;
579
+ const closedWhileWaiting = new Promise((_, reject) => {
580
+ onUpdate = (update) => {
581
+ if (update.connection === 'close') {
582
+ reject(new Boom(`requestPairingCode: connection closed while waiting for the handshake to complete (last disconnect: ${describeLastDisconnect()})`, { statusCode: DisconnectReason.connectionClosed }));
583
+ }
584
+ };
585
+ ev.on('connection.update', onUpdate);
586
+ });
587
+ let timer;
588
+ const timedOut = new Promise((_, reject) => {
589
+ timer = setTimeout(() => {
590
+ reject(new Boom(`requestPairingCode: handshake did not complete within ${PAIRING_READY_TIMEOUT_MS / 1000}s (last disconnect: ${describeLastDisconnect()})`, { statusCode: DisconnectReason.timedOut }));
591
+ }, PAIRING_READY_TIMEOUT_MS);
592
+ });
593
+ try {
594
+ await Promise.race([handshakeComplete, closedWhileWaiting, timedOut]);
595
+ }
513
596
  finally {
514
597
  clearTimeout(timer);
515
598
  if (onUpdate) {
@@ -534,13 +617,20 @@ export const makeSocket = (config) => {
534
617
  pairingCodeRequestInFlight = true;
535
618
  try {
536
619
  await waitForPairingReady();
620
+ await waitForHandshakeComplete();
537
621
  authState.creds.pairingCode = pairingCode;
538
622
  authState.creds.me = {
539
623
  id: jidEncode(phoneNumber, 's.whatsapp.net'),
540
624
  name: '~'
541
625
  };
542
626
  ev.emit('creds.update', authState.creds);
543
- await sendNode({
627
+ // AunuBaileys (1.0.77, HB-191): query(), not sendNode() โ€” the
628
+ // companion_hello result must be awaited and error-checked.
629
+ // sendNode resolved when the bytes hit the wire, so a server
630
+ // rejection left the optimistic creds.me/pairingCode persisted
631
+ // and sent the next boot into a 401 loop; a rejection now
632
+ // lands in the catch below, which rolls the creds back.
633
+ await query({
544
634
  tag: 'iq',
545
635
  attrs: {
546
636
  to: S_WHATSAPP_NET,
@@ -644,6 +734,16 @@ export const makeSocket = (config) => {
644
734
  }
645
735
  });
646
736
  ws.on('error', mapWebSocketError(end));
737
+ // AunuBaileys (1.0.77, HB-189): the ws client forwards
738
+ // 'unexpected-response', which suppresses ws's own handshake
739
+ // abort โ€” without a listener here a non-101 response (403/502,
740
+ // ISP intercept page) wedges the socket in 'connecting' forever:
741
+ // no open, no error, no close, and the host never reconnects.
742
+ ws.on('unexpected-response', (_req, res) => {
743
+ const code = res?.statusCode;
744
+ const statusCode = Number.isFinite(code) && code >= 400 && code <= 599 ? code : 500;
745
+ end(new Boom(`Unexpected server response: ${code}`, { statusCode }));
746
+ });
647
747
  ws.on('close', () => end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })));
648
748
  // the server terminated the connection
649
749
  ws.on('CB:xmlstreamend', () => end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed })));
@@ -662,6 +762,16 @@ export const makeSocket = (config) => {
662
762
  }
663
763
  };
664
764
  await sendNode(iq);
765
+ // AunuBaileys (1.0.77, HB-243): a duplicate pair-device IQ
766
+ // (server retry) must not start a parallel QR chain โ€” both
767
+ // chains share the single qrTimer slot, so the orphaned first
768
+ // chain keeps cycling after login and its ref exhaustion ends
769
+ // the healthy, freshly logged-in connection.
770
+ if (qrChainActive) {
771
+ logger.warn('ignoring duplicate pair-device IQ: a QR chain is already running');
772
+ return;
773
+ }
774
+ qrChainActive = true;
665
775
  const pairDeviceNode = getBinaryNodeChild(stanza, 'pair-device');
666
776
  const refNodes = getBinaryNodeChildren(pairDeviceNode, 'ref');
667
777
  const noiseKeyB64 = Buffer.from(creds.noiseKey.public).toString('base64');
@@ -670,6 +780,7 @@ export const makeSocket = (config) => {
670
780
  let qrMs = qrTimeout || 60000; // time to let a QR live
671
781
  const genPairQR = () => {
672
782
  if (!ws.isOpen) {
783
+ qrChainActive = false;
673
784
  return;
674
785
  }
675
786
  const refNode = refNodes.shift();
@@ -726,6 +837,7 @@ export const makeSocket = (config) => {
726
837
  await sendPassiveIq('active');
727
838
  logger.info('opened connection to WA');
728
839
  clearTimeout(qrTimer); // will never happen in all likelyhood -- but just in case WA sends success on first try
840
+ qrChainActive = false;
729
841
  ev.emit('creds.update', { me: { ...authState.creds.me, lid: node.attrs.lid } });
730
842
  ev.emit('connection.update', { connection: 'open' });
731
843
  }
@@ -737,9 +849,9 @@ export const makeSocket = (config) => {
737
849
  // AunuBaileys (1.0.71): 405 client_too_old โ€” fetch the live WA Web
738
850
  // build so the caller's reconnect uses it (donor: violetix
739
851
  // lib/Socket/socket.js CB:failure handler). Only for static versions;
740
- // a resolver function re-resolves on the next connect anyway. The
741
- // connection.update signal is best-effort: end() may already have
742
- // torn the listeners down by the time the fetch lands.
852
+ // a resolver function re-resolves on the next connect anyway. Both
853
+ // 405 paths (CB:failure and CB:stream:error) await this before
854
+ // end(), so the clientTooOld signal reaches the host first.
743
855
  const handleClientTooOld = async () => {
744
856
  if (typeof config.version === 'function') {
745
857
  return;
@@ -759,11 +871,16 @@ export const makeSocket = (config) => {
759
871
  logger.warn({ err }, 'failed to fetch the latest WA Web version after a 405');
760
872
  }
761
873
  };
762
- ws.on('CB:stream:error', (node) => {
874
+ ws.on('CB:stream:error', async (node) => {
763
875
  logger.error({ node }, 'stream errored out');
764
876
  const { reason, statusCode } = getErrorCodeFromStreamError(node);
765
877
  if (statusCode === 405) {
766
- void handleClientTooOld();
878
+ // AunuBaileys (1.0.77, HB-148): awaited like the CB:failure
879
+ // path (1.0.72) โ€” firing it void let end() tear the
880
+ // connection.update listeners down before the version
881
+ // fetch landed, so the clientTooOld signal never reached
882
+ // the host and a 405 looked like any other disconnect.
883
+ await handleClientTooOld();
767
884
  }
768
885
  end(new Boom(`Stream Errored (${reason})`, { statusCode, data: node }));
769
886
  });
@@ -802,7 +919,7 @@ export const makeSocket = (config) => {
802
919
  if (offlineDrainBatches >= OFFLINE_DRAIN_MAX_BATCHES) {
803
920
  stopOfflineDrain();
804
921
  logger.warn({ batches: offlineDrainBatches }, 'offline drain hit the batch cap; remaining backlog waits for the next reconnect');
805
- return;
922
+ return false;
806
923
  }
807
924
  offlineDrainBatches += 1;
808
925
  // AunuBaileys (1.0.70): a floating sendNode here could reject
@@ -812,12 +929,19 @@ export const makeSocket = (config) => {
812
929
  attrs: {},
813
930
  content: [{ tag: 'offline_batch', attrs: { count: '100' } }]
814
931
  }).catch(err => logger.warn({ err }, 'failed to request offline batch'));
932
+ return true;
815
933
  };
816
934
  const scheduleOfflineDrain = () => {
817
935
  stopOfflineDrain();
818
936
  offlineDrainTimer = setTimeout(() => {
819
- requestOfflineBatch();
820
- scheduleOfflineDrain();
937
+ // AunuBaileys (1.0.77, HB-109): stop re-arming at the batch
938
+ // cap โ€” the unconditional re-schedule used to undo
939
+ // requestOfflineBatch's stopOfflineDrain(), so when the
940
+ // terminal offline bulletin never arrived the cap warning
941
+ // repeated every 500ms for the life of the socket.
942
+ if (requestOfflineBatch()) {
943
+ scheduleOfflineDrain();
944
+ }
821
945
  }, OFFLINE_DRAIN_INTERVAL_MS);
822
946
  };
823
947
  ws.on('CB:ib,,offline_preview', (node) => {
@@ -374,6 +374,16 @@ export const getCallStatusFromNode = ({ tag, attrs }) => {
374
374
  };
375
375
  const UNEXPECTED_SERVER_CODE_TEXT = 'Unexpected server response: ';
376
376
  export const getCodeFromWSError = (error) => {
377
+ // AunuBaileys (1.0.77, HB-133): an error that already carries a
378
+ // valid Boom status code keeps it. Re-deriving the code from the
379
+ // message text lost real codes โ€” promiseTimeout's Boom
380
+ // 'Timed Out' (408) was re-wrapped as a 500 by mapWebSocketError
381
+ // because the textual timeout check below is case-sensitive
382
+ // ('Timed Out' vs 'timed out').
383
+ const existingCode = error?.output?.statusCode;
384
+ if (Number.isFinite(existingCode) && existingCode >= 400 && existingCode <= 599) {
385
+ return existingCode;
386
+ }
377
387
  let statusCode = 500;
378
388
  if (error?.message?.includes(UNEXPECTED_SERVER_CODE_TEXT)) {
379
389
  const code = +error?.message.slice(UNEXPECTED_SERVER_CODE_TEXT.length);
@@ -385,7 +395,7 @@ export const getCodeFromWSError = (error) => {
385
395
  // AunuBaileys (1.0.67): error.code can be numeric (e.g. WS close
386
396
  // 1006) โ€” only call startsWith on strings.
387
397
  (typeof error?.code === 'string' && error.code.startsWith('E')) ||
388
- error?.message?.includes('timed out')) {
398
+ error?.message?.toLowerCase().includes('timed out')) {
389
399
  // handle ETIMEOUT, ENOTFOUND etc
390
400
  statusCode = 408;
391
401
  }
@@ -1,7 +1,13 @@
1
1
  export const makeMutex = () => {
2
2
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3
3
  let task = Promise.resolve();
4
- let taskTimeout;
4
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” the old `taskTimeout` was dead
5
+ // code (declared, cleared in a finally, NEVER assigned โ€” there is no
6
+ // hidden timeout safety net, and inventing one that breaks the chain
7
+ // would corrupt the serialization this mutex exists for). Removed.
8
+ // Note: a task that never settles blocks later tasks by design โ€” that
9
+ // is what a serial mutex means (upstream parity); callers must not
10
+ // await unbounded work inside mutex sections.
5
11
  return {
6
12
  mutex(code) {
7
13
  task = (async () => {
@@ -11,14 +17,8 @@ export const makeMutex = () => {
11
17
  await task;
12
18
  }
13
19
  catch { }
14
- try {
15
- // execute the current task
16
- const result = await code();
17
- return result;
18
- }
19
- finally {
20
- clearTimeout(taskTimeout);
21
- }
20
+ // execute the current task
21
+ return await code();
22
22
  })();
23
23
  // we replace the existing task, appending the new piece of execution to it
24
24
  // so the next task will have to wait for this one to finish
@@ -601,11 +601,24 @@ const toSmallestChunkSize = (num) => {
601
601
  return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE;
602
602
  };
603
603
  export const getUrlFromDirectPath = (directPath, fallbackHost) => `https://${fallbackHost || DEF_HOST}${directPath}`;
604
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” WhatsApp media is only ever served
605
+ // from WhatsApp/Facebook CDN hosts. A sender-supplied URL whose host is
606
+ // anything else is not a media URL, it is an SSRF attempt (the bot would
607
+ // happily GET an internal https host with the sender's directPath glued
608
+ // on). Non-WA hosts are never fetched: with a directPath we fall back to
609
+ // the canonical media host (the path is globally valid there); without
610
+ // one the download is refused.
611
+ const WA_MEDIA_HOST_SUFFIXES = ['.whatsapp.net', '.fbcdn.net'];
612
+ const isWAMediaHost = (hostname) => !!hostname &&
613
+ (hostname === 'whatsapp.net' || hostname === 'fbcdn.net' ||
614
+ WA_MEDIA_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)));
604
615
  export const downloadContentFromMessage = async ({ mediaKey, directPath, url }, type, opts = {}) => {
605
616
  // AunuBaileys (1.0.69): the sender-supplied URL used to be trusted
606
617
  // verbatim (host + path) โ€” a remote peer could trigger a GET to an
607
618
  // arbitrary https host. Like upstream v7, only the host is taken
608
619
  // from the URL; the path is rebuilt from our own directPath.
620
+ // AunuBaileys (1.0.76): the host is now ALSO allowlisted (above) โ€”
621
+ // a non-WA host falls back to mmg.whatsapp.net instead of being fetched.
609
622
  let downloadUrl;
610
623
  if (directPath) {
611
624
  let host;
@@ -613,10 +626,18 @@ export const downloadContentFromMessage = async ({ mediaKey, directPath, url },
613
626
  host = new URL(url || '').hostname || undefined;
614
627
  }
615
628
  catch { }
629
+ if (!isWAMediaHost(host)) {
630
+ host = undefined;
631
+ }
616
632
  downloadUrl = getUrlFromDirectPath(directPath, host);
617
633
  }
618
634
  else {
619
- downloadUrl = url?.startsWith('https://') ? url : undefined;
635
+ let parsed;
636
+ try {
637
+ parsed = new URL(url || '');
638
+ }
639
+ catch { }
640
+ downloadUrl = parsed?.protocol === 'https:' && isWAMediaHost(parsed.hostname) ? url : undefined;
620
641
  }
621
642
  if (!downloadUrl) {
622
643
  throw new Boom('No valid media URL or directPath present in message', { statusCode: 400 });
@@ -628,7 +649,7 @@ export const downloadContentFromMessage = async ({ mediaKey, directPath, url },
628
649
  * Decrypts and downloads an AES256-CBC encrypted file given the keys.
629
650
  * Assumes the SHA256 of the plaintext is appended to the end of the ciphertext
630
651
  * */
631
- export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, { startByte, endByte, options } = {}) => {
652
+ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv, macKey }, { startByte, endByte, options } = {}) => {
632
653
  let bytesFetched = 0;
633
654
  let startChunk = 0;
634
655
  let firstBlockIsIV = false;
@@ -661,6 +682,35 @@ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, {
661
682
  });
662
683
  let remainingBytes = Buffer.from([]);
663
684
  let aes;
685
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” the downloaded ciphertext used
686
+ // to be decrypted with NO integrity check: the 10-byte HMAC-SHA256
687
+ // (macKey, iv || ciphertext) that WhatsApp appends to every media
688
+ // file was silently dropped with the trailing partial block, so a
689
+ // tampering server/CDN could alter media undetected (AES-CBC padding
690
+ // only catches damage to the final block). For FULL downloads (no
691
+ // byte range) with a macKey available, verify it: HMAC every
692
+ // ciphertext byte except a held-back final 10, compare in final().
693
+ // Ranged downloads fetch a slice that excludes the MAC โ€” they skip
694
+ // verification, exactly like before. Consumers of the stream still
695
+ // receive plaintext progressively, but a failed check errors the
696
+ // stream in final(), so buffered consumers (downloadMediaMessage
697
+ // 'buffer') discard the content wholesale.
698
+ const verifyMac = !!macKey && !startByte && !endByte;
699
+ const macHmac = verifyMac ? Crypto.createHmac('sha256', macKey).update(iv) : null;
700
+ let macTail = Buffer.alloc(0);
701
+ const feedMac = (chunk) => {
702
+ if (!macHmac) {
703
+ return;
704
+ }
705
+ const joined = macTail.length ? Buffer.concat([macTail, chunk]) : chunk;
706
+ if (joined.length > 10) {
707
+ macHmac.update(joined.subarray(0, joined.length - 10));
708
+ macTail = joined.subarray(joined.length - 10);
709
+ }
710
+ else {
711
+ macTail = joined;
712
+ }
713
+ };
664
714
  const pushBytes = (bytes, push) => {
665
715
  if (startByte || endByte) {
666
716
  const start = bytesFetched >= startByte ? undefined : Math.max(startByte - bytesFetched, 0);
@@ -680,6 +730,7 @@ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, {
680
730
  };
681
731
  const output = new Transform({
682
732
  transform(chunk, _, callback) {
733
+ feedMac(chunk);
683
734
  let data = Buffer.concat([remainingBytes, chunk]);
684
735
  const decryptLength = toSmallestChunkSize(data.length);
685
736
  remainingBytes = data.slice(decryptLength);
@@ -715,11 +766,19 @@ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, {
715
766
  }
716
767
  try {
717
768
  pushBytes(aes.final(), b => this.push(b));
718
- callback();
719
769
  }
720
770
  catch (error) {
721
771
  callback(error);
772
+ return;
773
+ }
774
+ if (macHmac) {
775
+ const expectedMac = macHmac.digest().subarray(0, 10);
776
+ if (macTail.length !== 10 || !Crypto.timingSafeEqual(Buffer.from(macTail), expectedMac)) {
777
+ callback(new Boom('Downloaded media failed HMAC integrity verification โ€” the file was tampered with or truncated in transit; refusing it', { statusCode: 400 }));
778
+ return;
779
+ }
722
780
  }
781
+ callback();
723
782
  }
724
783
  });
725
784
  // AunuBaileys (1.0.71): stream.pipe() does NOT forward source
@@ -15,8 +15,8 @@ const generateIV = (counter) => {
15
15
  // corrupt header either wedges the stream forever (size 0: the header
16
16
  // is never consumed, so every later call re-reads it) or buffers
17
17
  // without bound waiting for megabytes that never arrive. Fail loudly
18
- // instead: the socket ends (the caller routes the throw to
19
- // onUnexpectedError) and the host reconnects.
18
+ // instead: the throw is marked frame-fatal (1.0.77, HB-185) and the
19
+ // caller ends the socket, so the host reconnects at once.
20
20
  const MAX_NOISE_FRAME_SIZE = 10 * 1024 * 1024;
21
21
  export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publicKey }, NOISE_HEADER, logger, routingInfo }) => {
22
22
  logger = logger.child({ class: 'ns' });
@@ -161,23 +161,55 @@ export const makeNoiseHandler = ({ keyPair: { private: privateKey, public: publi
161
161
  }
162
162
  if (size > MAX_NOISE_FRAME_SIZE) {
163
163
  // AunuBaileys (1.0.73, HB-91): never wait for a
164
- // frame that cannot exist โ€” throw so the socket
165
- // ends instead of buffering without bound until
166
- // the keep-alive watchdog kills the connection
167
- // ~65s later.
168
- throw new Boom(`noise frame too large: ${size} bytes`, { statusCode: 400 });
164
+ // frame that cannot exist. (1.0.77, HB-185): the
165
+ // throw alone did NOT end the socket โ€” the caller
166
+ // only logged it, the header was never consumed,
167
+ // and the stream wedged until the keep-alive
168
+ // watchdog fired ~65s later. Marked frame-fatal:
169
+ // the caller now ends the connection on it.
170
+ const err = new Boom(`noise frame too large: ${size} bytes`, { statusCode: 400 });
171
+ err.isFrameFatal = true;
172
+ throw err;
169
173
  }
170
174
  if (inBytes.length < size + 3) {
171
175
  break;
172
176
  }
173
177
  let frame = inBytes.slice(3, size + 3);
174
178
  inBytes = inBytes.slice(size + 3);
179
+ let decoded = true;
175
180
  if (isFinished) {
176
- const result = decrypt(frame);
177
- frame = await decodeBinaryNode(result);
181
+ let result;
182
+ try {
183
+ result = decrypt(frame);
184
+ }
185
+ catch (err) {
186
+ // AunuBaileys (1.0.77, HB-207): a GCM failure
187
+ // leaves readCounter behind the server's
188
+ // counter, so every later frame decrypts with
189
+ // the wrong IV and fails too โ€” the stream can
190
+ // never resync. Frame-fatal: the caller ends
191
+ // the connection instead of losing every
192
+ // inbound message until the watchdog fires.
193
+ err.isFrameFatal = true;
194
+ throw err;
195
+ }
196
+ try {
197
+ frame = await decodeBinaryNode(result);
198
+ }
199
+ catch (err) {
200
+ // AunuBaileys (1.0.77): stanza-level corruption
201
+ // is NOT fatal โ€” the frame was consumed and the
202
+ // counter advanced, so the stream is still in
203
+ // sync. Log and skip the frame instead of
204
+ // aborting the rest of the buffered frames.
205
+ logger.error({ err }, 'failed to decode a stanza; skipping the frame');
206
+ decoded = false;
207
+ }
208
+ }
209
+ if (decoded) {
210
+ logger.trace({ msg: frame?.attrs?.id }, 'recv frame');
211
+ onFrame(frame);
178
212
  }
179
- logger.trace({ msg: frame?.attrs?.id }, 'recv frame');
180
- onFrame(frame);
181
213
  size = getBytesSize();
182
214
  }
183
215
  }
@@ -8,8 +8,10 @@
8
8
  // - 'connection.update' { sessionWatchdog: { quietForMs, lastInboundAt, at } }
9
9
  // once per quiet episode (re-armed by the next inbound message).
10
10
  // Inbound traffic is observed on the event buffer ('messages.upsert' /
11
- // 'messages.update'); hosts that see raw frames can also call
12
- // watchdog.markInbound() directly. Wiring is opt-in: lib/Socket/index.js
11
+ // 'messages.update') AND โ€” since 1.0.76 โ€” on the raw WebSocket frame
12
+ // stream when sock.ws is reachable (buffered delivery is deferred during
13
+ // long syncs, which used to read as a false deaf session); hosts can
14
+ // also call watchdog.markInbound() directly. Wiring is opt-in: lib/Socket/index.js
13
15
  // attaches it only when config.sessionWatchdog is set (object = options,
14
16
  // true = defaults). The interval timer is unref'd so it never keeps a
15
17
  // process alive on its own.
@@ -84,6 +86,12 @@ export const makeSessionWatchdog = (sock, options = {}) => {
84
86
  ev.off?.('messages.upsert', markInbound);
85
87
  ev.off?.('messages.update', markInbound);
86
88
  ev.off?.('connection.update', onConnectionUpdate);
89
+ if (rawWs) {
90
+ rawWs.off?.('message', markInbound);
91
+ if (!rawWs.off && typeof rawWs.removeListener === 'function') {
92
+ rawWs.removeListener('message', markInbound);
93
+ }
94
+ }
87
95
  return watchdog;
88
96
  };
89
97
  const getState = () => ({
@@ -95,6 +103,19 @@ export const makeSessionWatchdog = (sock, options = {}) => {
95
103
  ev.on('messages.upsert', markInbound);
96
104
  ev.on('messages.update', markInbound);
97
105
  ev.on('connection.update', onConnectionUpdate);
106
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” ALSO observe the raw WebSocket
107
+ // frame stream when available. messages.upsert/update travel through
108
+ // the event buffer, which DEFERS delivery to listeners while a
109
+ // buffered function runs (app-state resync, history processing): real
110
+ // inbound messages then fail to re-arm the watchdog for the whole
111
+ // buffering window and a long sync reads as a deaf session โ€” a false
112
+ // alert. Raw frames are never buffered, and for a deaf-session
113
+ // detector any inbound frame (message, receipt, IQ, keep-alive reply)
114
+ // is the truer liveness signal anyway. Detached again in stop().
115
+ const rawWs = sock.ws && typeof sock.ws.on === 'function' ? sock.ws : null;
116
+ if (rawWs) {
117
+ rawWs.on('message', markInbound);
118
+ }
98
119
  const watchdog = {
99
120
  markInbound,
100
121
  noteInbound: markInbound,
@@ -45,7 +45,14 @@ export const useMultiFileAuthState = async (folder, logger) => {
45
45
  try {
46
46
  // AunuBaileys: atomic write (tmp + rename) anti-korupsi sesi โ€” terinspirasi @itsliaaa/baileys
47
47
  const tmpPath = `${filePath}.tmp-${process.pid}`;
48
- await writeFile(tmpPath, JSON.stringify(data, BufferJSON.replacer));
48
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” these files hold
49
+ // private keys (creds.json, session/sender-key records).
50
+ // They used to be created with default permissions (0644
51
+ // on a typical umask) โ€” readable by every local user on a
52
+ // shared host. Write the tmp file 0600; the rename carries
53
+ // the mode onto the target, so pre-existing 0644 files
54
+ // also self-heal to 0600 on their next save.
55
+ await writeFile(tmpPath, JSON.stringify(data, BufferJSON.replacer), { mode: 0o600 });
49
56
  await rename(tmpPath, filePath);
50
57
  }
51
58
  catch (err) {
@@ -3,9 +3,24 @@ import { inflate } from 'zlib';
3
3
  import * as constants from './constants.js';
4
4
  import { jidEncode } from './jid-utils.js';
5
5
  const inflatePromise = promisify(inflate);
6
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” inflate used to run with NO output
7
+ // cap: zlib expands up to ~1000x, so a max-size wire frame (10 MiB, HB-91)
8
+ // could inflate to ~10 GB and OOM the process. 128 MiB is far above any
9
+ // legitimate stanza (wire stanzas are messages / IQ results / app-state
10
+ // patches โ€” big payloads travel as media downloads, not stanzas) while
11
+ // still stopping a bomb cold. Exceeding it is a clean decode failure.
12
+ const MAX_DECOMPRESSED_SIZE = 128 * 1024 * 1024;
6
13
  export const decompressingIfRequired = async (buffer) => {
7
14
  if (2 & buffer.readUInt8()) {
8
- buffer = await inflatePromise(buffer.slice(1));
15
+ try {
16
+ buffer = await inflatePromise(buffer.slice(1), { maxOutputLength: MAX_DECOMPRESSED_SIZE });
17
+ }
18
+ catch (err) {
19
+ if (err?.code === 'ERR_BUFFER_TOO_LARGE') {
20
+ throw new Error(`AunuBaileys: compressed stanza exceeds the ${MAX_DECOMPRESSED_SIZE / (1024 * 1024)} MiB decompressed-size cap โ€” refusing to inflate (possible zlib bomb)`);
21
+ }
22
+ throw err;
23
+ }
9
24
  }
10
25
  else {
11
26
  // nodes with no compression have a 0x00 prefix, we remove that
@@ -13,7 +28,18 @@ export const decompressingIfRequired = async (buffer) => {
13
28
  }
14
29
  return buffer;
15
30
  };
16
- export const decodeDecompressedBinaryNode = (buffer, opts, indexRef = { index: 0 }) => {
31
+ // AunuBaileys (1.0.76): Hunt-ronde-3 โ€” the decoder recursed with NO depth
32
+ // bound: ~5 wire bytes buy one nesting level, so a ~100 KB stanza reached
33
+ // depth 20,000 and died as an uncontrolled RangeError (stack overflow).
34
+ // Legit stanzas nest < ~20 deep; 512 is generous headroom, and crossing it
35
+ // is a descriptive decode error instead of a V8 crash. (Total node count
36
+ // needs no separate cap: every node consumes >= 1 input byte, so count is
37
+ // bounded by the โ€” now also capped โ€” buffer length.)
38
+ const MAX_NODE_DEPTH = 512;
39
+ export const decodeDecompressedBinaryNode = (buffer, opts, indexRef = { index: 0 }, depth = 0) => {
40
+ if (depth > MAX_NODE_DEPTH) {
41
+ throw new Error(`AunuBaileys: binary node nesting exceeds the ${MAX_NODE_DEPTH}-level safety cap โ€” refusing to decode (possible depth bomb)`);
42
+ }
17
43
  const { DOUBLE_BYTE_TOKENS, SINGLE_BYTE_TOKENS, TAGS } = opts;
18
44
  const checkEOS = (length) => {
19
45
  if (indexRef.index + length > buffer.length) {
@@ -186,7 +212,7 @@ export const decodeDecompressedBinaryNode = (buffer, opts, indexRef = { index: 0
186
212
  const items = [];
187
213
  const size = readListSize(tag);
188
214
  for (let i = 0; i < size; i++) {
189
- items.push(decodeDecompressedBinaryNode(buffer, opts, indexRef));
215
+ items.push(decodeDecompressedBinaryNode(buffer, opts, indexRef, depth + 1));
190
216
  }
191
217
  return items;
192
218
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aunucore/baileys",
3
3
  "type": "module",
4
- "version": "1.0.75",
4
+ "version": "1.0.77",
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",