diff --git a/.github/workflows/update-proto.yml b/.github/workflows/update-proto.yml index 0101ed3bfc0..02e949d7f93 100644 --- a/.github/workflows/update-proto.yml +++ b/.github/workflows/update-proto.yml @@ -259,7 +259,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY WA_VERSION=$(printf '%s' "$WA_VERSION_RAW" | tr -cd '0-9.') - WA_JS_URL=$(printf '%s' "$WA_JS_URL_RAW" | tr -cd 'A-Za-z0-9:/?&=._-+~%#') + # The `-` MUST be the last char in the class, otherwise `_-+` + # is parsed as the (invalid) range from `_` (0x5F) to `+` (0x2B) + # and GNU `tr` aborts with "reverse collating sequence order", + # breaking every Summary step run. + WA_JS_URL=$(printf '%s' "$WA_JS_URL_RAW" | tr -cd 'A-Za-z0-9:/?&=._+~%#-') PR_NUMBER=$(printf '%s' "$PR_NUMBER_RAW" | tr -cd '0-9') echo "**Version:** \`v$WA_VERSION\`" >> $GITHUB_STEP_SUMMARY diff --git a/WAProto/WAProto.proto b/WAProto/WAProto.proto index 55f82c29d8b..b9d505e5f13 100644 --- a/WAProto/WAProto.proto +++ b/WAProto/WAProto.proto @@ -1,7 +1,7 @@ syntax = "proto3"; package proto; -/// WhatsApp Version: 2.3000.1041063798 +/// WhatsApp Version: 2.3000.1041433777 message ADVDeviceIdentity { optional uint32 rawId = 1; diff --git a/src/Defaults/baileys-version.json b/src/Defaults/baileys-version.json index 0a4e01d0152..a85cc031c88 100644 --- a/src/Defaults/baileys-version.json +++ b/src/Defaults/baileys-version.json @@ -1 +1 @@ -{"version":[2,3000,1041103517]} +{"version":[2,3000,1041437765]} diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 52f13b2daab..ac25577622c 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -297,6 +297,14 @@ function readVarint(buffer: Uint8Array, offset: number): { value: number; nextOf while (offset < buffer.length) { const byte = buffer[offset]! + // On byte 5 (shift === 28) only the low 4 bits can fit inside a + // 32-bit varint — the top 4 bits would land at positions 32–35 and + // silently fall off the uint32. `>>> 0` masks the overflow without + // signalling it, so a crafted payload could encode a value + // arbitrarily larger than 2³² – 1 and still parse cleanly. Reject + // it the same way the 6-byte case is rejected by the `shift >= 35` + // guard below. + if (shift === 28 && (byte & 0xf0) !== 0) return undefined result |= (byte & 0x7f) << shift offset++ diff --git a/src/Utils/env-utils.ts b/src/Utils/env-utils.ts index 5942635bcb3..fb87ac81703 100644 --- a/src/Utils/env-utils.ts +++ b/src/Utils/env-utils.ts @@ -17,18 +17,33 @@ */ /** - * Parse an integer env var with a fallback and lower bound. + * Parse an integer env var with a fallback and lower/upper bounds. * * @param raw - Raw env var value (`process.env.X`). * @param fallback - Returned if `raw` is missing, empty, non-numeric, or - * below `min`. + * outside `[min, max]`. * @param min - Minimum acceptable value (default `0`). Pass `1` for * durations / pool sizes that must be strictly positive. + * @param max - Maximum acceptable value (optional). Use for port numbers + * (65535), percentages (100), etc. so out-of-range values + * fall back to the safe default instead of reaching the + * underlying syscall with a meaningless number. */ -export const intFromEnv = (raw: string | undefined, fallback: number, min: number = 0): number => { - if (raw === undefined || raw === '') return fallback - const n = Number(raw) - return Number.isInteger(n) && n >= min ? n : fallback +export const intFromEnv = ( + raw: string | undefined, + fallback: number, + min: number = 0, + max: number = Number.MAX_SAFE_INTEGER +): number => { + if (raw === undefined) return fallback + // Trim before the emptiness check — env vars containing only whitespace + // (e.g. a sloppy `KEY= ` in a .env file) used to slip past `=== ''` and + // fall through to `Number(' ')` which returns 0, masquerading as a + // legitimate "zero" config value. + const trimmed = raw.trim() + if (trimmed === '') return fallback + const n = Number(trimmed) + return Number.isInteger(n) && n >= min && n <= max ? n : fallback } /** @@ -36,7 +51,9 @@ export const intFromEnv = (raw: string | undefined, fallback: number, min: numbe * where fractional values are valid. */ export const floatFromEnv = (raw: string | undefined, fallback: number): number => { - if (raw === undefined || raw === '') return fallback - const n = Number(raw) + if (raw === undefined) return fallback + const trimmed = raw.trim() + if (trimmed === '') return fallback + const n = Number(trimmed) return Number.isFinite(n) ? n : fallback } diff --git a/src/Utils/multi-db-sqlite/keys-with-jid-map.ts b/src/Utils/multi-db-sqlite/keys-with-jid-map.ts index 28005e1b971..45e834743f6 100644 --- a/src/Utils/multi-db-sqlite/keys-with-jid-map.ts +++ b/src/Utils/multi-db-sqlite/keys-with-jid-map.ts @@ -191,6 +191,16 @@ export function wrapKeysWithJidMap( // Reverse delete — keyed by LID directly. deletes.push(lidUser) innerDeleteReverse.push(key) // include the `_reverse` suffix + // Also clean up the FORWARD direction in the inner store. + // Reverse-only delete was leaving any legacy `pnUser → + // lidUser` entry intact in the inner store, so a later + // `inner.get('lid-mapping', [pnUser])` would resurrect the + // just-deleted LID via the fallback path. Resolve the PN + // from the typed backend (synchronous) and queue its + // forward delete too. + const resolvedPn = jidMap.getPnForLid(lidUser) + if (resolvedPn) innerDeleteForward.push(resolvedPn) + continue } @@ -231,10 +241,11 @@ export function wrapKeysWithJidMap( if (hasRest) await inner.set(rest) - // Now persist jid_map changes. Wrapped in best-effort try/catch - // so a transient SQLITE_BUSY on the reverse-only path doesn't - // crash the caller — the missing mapping rebuilds on the next - // observed event. (audit P2-SQDB-02) + // Now persist jid_map changes. Wrapped in try/catch so the catch + // arm below can distinguish SQLITE_BUSY (re-raise so the upstream + // `runSetWithBusyRetry` can drive a backoff) from anything else + // (let it propagate). NOT best-effort — every error path + // rethrows; the wrapper is purely about classifying. try { if (deletes.length > 0) { for (const lidUser of deletes) jidMap.deleteMapping(lidUser) diff --git a/src/Utils/multi-db-sqlite/lid-mapping-backend.ts b/src/Utils/multi-db-sqlite/lid-mapping-backend.ts index 200b974d1f7..cbbc5713eb2 100644 --- a/src/Utils/multi-db-sqlite/lid-mapping-backend.ts +++ b/src/Utils/multi-db-sqlite/lid-mapping-backend.ts @@ -34,11 +34,15 @@ export class JidMapBackend { upsertMap: SqliteStatementLike selectPnByLid: SqliteStatementLike selectLidByPn: SqliteStatementLike - // audit MDB-P1-A — cached so `deleteMapping` doesn't `db.prepare()` - // per call. Earlier it compiled a fresh native statement per loop - // iteration; under burst delete that leaked native memory until V8 - // collected the JS wrapper. + // Cached so `deleteMapping` doesn't `db.prepare()` per call. Earlier + // it compiled a fresh native statement per loop iteration; under + // burst delete that leaked native memory until V8 collected the JS + // wrapper. deleteMapByLidRowId: SqliteStatementLike + // Same reasoning for `getAllLidsForPn` — used on every delete to + // enumerate historical mappings; the inline `db.prepare()` paid the + // native compile cost every call. + selectAllLidsByPn: SqliteStatementLike } private readonly db: SqliteDbLike @@ -110,7 +114,14 @@ export class JidMapBackend { 'JOIN jid j_pn ON j_pn._id = m.jid_row_id ' + 'WHERE j_pn.raw_string = ? ORDER BY m.sort_id DESC LIMIT 1' ), - deleteMapByLidRowId: this.db.prepare('DELETE FROM jid_map WHERE lid_row_id = ?') + deleteMapByLidRowId: this.db.prepare('DELETE FROM jid_map WHERE lid_row_id = ?'), + selectAllLidsByPn: this.db.prepare( + `SELECT l.raw_string AS raw FROM jid_map jm + JOIN jid l ON l._id = jm.lid_row_id + JOIN jid p ON p._id = jm.jid_row_id + WHERE p.raw_string = ? + ORDER BY jm.sort_id DESC` + ) } // Window-function variant of the "most recent LID per PN" pick. @@ -239,16 +250,9 @@ export class JidMapBackend { // 2-table join: lid_row_id → jid (LID side), jid_row_id → jid (PN side). // Earlier version had a third `JOIN jid j ON j._id = l._id` and read // `j.raw_string` — `j` was just a re-alias of `l`, harmless but a - // pointless extra lookup. - const rows = this.db - .prepare( - `SELECT l.raw_string AS raw FROM jid_map jm - JOIN jid l ON l._id = jm.lid_row_id - JOIN jid p ON p._id = jm.jid_row_id - WHERE p.raw_string = ? - ORDER BY jm.sort_id DESC` - ) - .all(pnUser) as Array<{ raw: string }> + // pointless extra lookup. Uses the cached statement from `this.stmts` + // so this hot delete path doesn't pay the SQL-compile cost per call. + const rows = this.stmts.selectAllLidsByPn.all(pnUser) as Array<{ raw: string }> return rows.map(r => r.raw) } diff --git a/src/Utils/prometheus-metrics.ts b/src/Utils/prometheus-metrics.ts index 04bc8b022a7..24ef16cbb13 100644 --- a/src/Utils/prometheus-metrics.ts +++ b/src/Utils/prometheus-metrics.ts @@ -169,10 +169,13 @@ function parseLabelsFromEnv(envValue: string | undefined): Labels { export function loadMetricsConfig(): MetricsConfig { return { enabled: (process.env.BAILEYS_PROMETHEUS_ENABLED ?? process.env.METRICS_ENABLED) === 'true', - // audit ENV-02: was `parseInt(... || '9092', 10)` — NaN under malformed - // env vars would land as `server.listen(NaN)` producing an opaque - // EADDRINUSE/bind error. Clamp to valid TCP range (≥1024 unprivileged). - port: intFromEnv(process.env.BAILEYS_PROMETHEUS_PORT ?? process.env.METRICS_PORT, 9092, 1), + // Was `parseInt(... || '9092', 10)` — NaN under malformed env vars + // would land as `server.listen(NaN)` producing an opaque + // EADDRINUSE/bind error. `min=1` is just "not zero / not negative" + // (operators running as root may legitimately bind a privileged + // port). `max=65535` rejects values above the TCP range before + // they reach `server.listen()`. + port: intFromEnv(process.env.BAILEYS_PROMETHEUS_PORT ?? process.env.METRICS_PORT, 9092, 1, 65535), host: process.env.BAILEYS_PROMETHEUS_HOST || process.env.METRICS_HOST || '127.0.0.1', path: process.env.BAILEYS_PROMETHEUS_PATH || process.env.METRICS_PATH || '/metrics', prefix: process.env.BAILEYS_PROMETHEUS_PREFIX || process.env.METRICS_PREFIX || 'baileys', diff --git a/src/Voip/index.ts b/src/Voip/index.ts index 4a03a26b089..1c3776d1a8d 100644 --- a/src/Voip/index.ts +++ b/src/Voip/index.ts @@ -452,17 +452,26 @@ export class VoipClient extends EventEmitter { // Direct binary-node hooks used for incoming stanza processing. In embedded // mode the socket exposes `.ws` (the underlying ws.WebSocket); in standalone // mode it's the socket the client just built. Both expose the same handle. + // Refs are stored so `disconnect()` can detach them — otherwise a stanza + // arriving after teardown would run against `#engine = null`. if (this.#sock.ws?.on) { - this.#sock.ws.on('CB:call', (node: any) => { - this.#signaling!.processIncomingCall(node, this.#engine!, this.#activeCall?.callId ?? '') - }) - this.#sock.ws.on('CB:receipt', (node: any) => { + this.#cbCallHandler = (node: any) => { + this.#signaling?.processIncomingCall(node, this.#engine!, this.#activeCall?.callId ?? '') + } + + this.#cbReceiptHandler = (node: any) => { if (!isCallReceiptNode(node)) return - this.#signaling!.processIncomingReceipt(node, this.#engine!, this.#activeCall?.callId ?? '') - }) + this.#signaling?.processIncomingReceipt(node, this.#engine!, this.#activeCall?.callId ?? '') + } + + this.#sock.ws.on('CB:call', this.#cbCallHandler) + this.#sock.ws.on('CB:receipt', this.#cbReceiptHandler) } } + #cbCallHandler: ((node: any) => void) | null = null + #cbReceiptHandler: ((node: any) => void) | null = null + /** * Subscribe to the socket's `'call'` event. When an offer arrives that we * haven't already surfaced (dedupe by call-id), construct an @@ -762,7 +771,11 @@ export class VoipClient extends EventEmitter { // does that internally from the per-participant LID. const resolved: string[] = [] for (const p of participants) { - if (p.endsWith('@lid')) { + // Both `@lid` and `@hosted.lid` are already resolved — only bare + // phone numbers need the LID lookup. The earlier `endsWith('@lid')` + // missed hosted-LID accounts (device 99) and tried to re-resolve + // them as if they were PNs. + if (p.endsWith('@lid') || p.endsWith('@hosted.lid')) { resolved.push(p) } else if (p.endsWith('@s.whatsapp.net')) { const lid = await this.#signaling.resolveLid(p) @@ -814,6 +827,20 @@ export class VoipClient extends EventEmitter { disconnect = (): void => { this.#activeCall?._forceEnd('disconnect') this.#activeCall = null + // Detach the direct ws CB hooks BEFORE we null out the engine — + // otherwise a stanza in flight when destroy() lands could invoke + // the handler against a torn-down engine and throw into the host + // process. We captured the refs at attach time so `removeListener` + // targets the exact closure (a fresh arrow would not match). + const ws: any = this.#sock?.ws + const off = ws?.off ?? ws?.removeListener + if (off) { + if (this.#cbCallHandler) off.call(ws, 'CB:call', this.#cbCallHandler) + if (this.#cbReceiptHandler) off.call(ws, 'CB:receipt', this.#cbReceiptHandler) + } + + this.#cbCallHandler = null + this.#cbReceiptHandler = null this.#relay?.closeAll() this.#engine?.destroy() // Only close the socket if we created it. In embedded mode the caller diff --git a/src/Voip/signaling/bridge.ts b/src/Voip/signaling/bridge.ts index 758ea8be2fc..40be1d4dcbe 100644 --- a/src/Voip/signaling/bridge.ts +++ b/src/Voip/signaling/bridge.ts @@ -273,6 +273,7 @@ export class SignalingBridge { const encCount = parseCountAttr(rootEnc?.attrs.count) let includeDeviceIdentity = false + let encryptionFailed = false for (const destNode of destinations) { const targetJid = String(destNode.attrs.jid ?? '').trim() const destEnc = getBinaryNodeChild(destNode, 'enc') @@ -283,10 +284,18 @@ export class SignalingBridge { setNodeChildren(destNode, [encrypted.encNode]) } catch { for (const d of destinations) removeNodeChildrenByTag(d, 'enc') + encryptionFailed = true break } } + // If ANY destination failed to encrypt, we already stripped the + // `enc` children from every destination — pushing the stanza now + // would deliver a key-less offer that the peer can't decrypt, + // failing the call setup silently. Bail instead so the upstream + // caller can surface the failure. + if (encryptionFailed) return + if (includeDeviceIdentity) this.#appendDeviceIdentity(voipNode) await this.#sendCallStanza(this.#toBareJid(peerJid), voipNode, signalingTag, effectivePeerJid, peerJid) @@ -590,7 +599,11 @@ export class SignalingBridge { const { jidDecode, jidEncode } = this.#baileys const decoded = jidDecode(jid) if (!decoded?.user) return jid - const server = jid.endsWith('@lid') ? 'lid' : 's.whatsapp.net' + // `decoded.server` already captures the parsed domain — using it + // avoids the previous `endsWith('@lid')` check that misclassified + // hosted-LID accounts (`*.@hosted.lid`, device 99) as PNs and + // silently rewrote them to `@s.whatsapp.net`. + const server = decoded.server === 'lid' || decoded.server === 'hosted.lid' ? decoded.server : 's.whatsapp.net' return jidEncode(decoded.user, server) } @@ -598,7 +611,11 @@ export class SignalingBridge { const { jidDecode, jidEncode } = this.#baileys const decoded = jidDecode(jid) if (!decoded?.user) return jid - const server = jid.endsWith('@lid') ? 'lid' : 's.whatsapp.net' + // `decoded.server` already captures the parsed domain — using it + // avoids the previous `endsWith('@lid')` check that misclassified + // hosted-LID accounts (`*.@hosted.lid`, device 99) as PNs and + // silently rewrote them to `@s.whatsapp.net`. + const server = decoded.server === 'lid' || decoded.server === 'hosted.lid' ? decoded.server : 's.whatsapp.net' if (decoded.device == null) return jidEncode(decoded.user, server) return `${decoded.user}:${decoded.device}@${server}` } @@ -609,7 +626,11 @@ export class SignalingBridge { if (!decoded?.user) return undefined const device = decoded.device if (device == null || device === 0) return undefined - const server = jid.endsWith('@lid') ? 'lid' : 's.whatsapp.net' + // `decoded.server` already captures the parsed domain — using it + // avoids the previous `endsWith('@lid')` check that misclassified + // hosted-LID accounts (`*.@hosted.lid`, device 99) as PNs and + // silently rewrote them to `@s.whatsapp.net`. + const server = decoded.server === 'lid' || decoded.server === 'hosted.lid' ? decoded.server : 's.whatsapp.net' return jidEncode(decoded.user, server) } @@ -675,7 +696,11 @@ export class SignalingBridge { continue } - const server = jid.endsWith('@lid') ? 'lid' : 's.whatsapp.net' + // `decoded.server` already captures the parsed domain — using it + // avoids the previous `endsWith('@lid')` check that misclassified + // hosted-LID accounts (`*.@hosted.lid`, device 99) as PNs and + // silently rewrote them to `@s.whatsapp.net`. + const server = decoded.server === 'lid' || decoded.server === 'hosted.lid' ? decoded.server : 's.whatsapp.net' result.add(jidEncode(decoded.user, server)) if (decoded.device != null) { result.add(`${decoded.user}:${decoded.device}@${server}`) diff --git a/src/Voip/wasm-engine/instance.ts b/src/Voip/wasm-engine/instance.ts index 9757a8fb4f3..bd15b2fcda3 100644 --- a/src/Voip/wasm-engine/instance.ts +++ b/src/Voip/wasm-engine/instance.ts @@ -325,6 +325,7 @@ export class WasmEngine { #voipStackInitialized = false #voipStackInitPromise: Promise | null = null + #voipStackInitError: Error | null = null #voipReadyResolver: (() => void) | null = null #voipReadyPromise: Promise | null = null @@ -432,7 +433,13 @@ export class WasmEngine { throw new Error(`No compatible WASM loader found. Tried: ${loaderModuleNames.join(', ')}`) } - if (!WasmEngine.#globalCallbacksRegistered) this.#registerGlobalCallbacks() + // Always re-register: each `WasmEngine` instance carries its own + // closures (over `this.#config.callbacks`). After a disconnect→ + // reconnect cycle the static listener map still held closures over + // the destroyed instance, so events from the fresh engine were + // routed to dead handlers. `destroy()` now clears the map; init + // repopulates it for the live instance. + this.#registerGlobalCallbacks() await this.#initPThreadPool() const workersLoadingPromise = this.#loadWasmModuleToAllWorkers() @@ -472,12 +479,22 @@ export class WasmEngine { this.#wasmModule = null this.#wasmMemory = null this.#initialized = false + // Clear closures that captured `this.#config.callbacks` so a later + // re-init can register its own listeners against the live instance. + // Same scope as the listeners themselves (process-wide singleton); + // no other live engine to step on in practice. + WasmEngine.#globalCallbackListeners.clear() + WasmEngine.#globalCallbacksRegistered = false } initVoipStack = (selfJid: string, meUserJid: string, selfLid: string): void => { this.#ensureInitialized() if (this.#voipStackInitialized || this.#voipStackInitPromise) return + // Clear any error from a previous failed init so a fresh attempt + // can be observed cleanly. + this.#voipStackInitError = null + this.#voipStackInitPromise = new Promise(resolveInit => { this.#voipReadyPromise = new Promise(readyResolve => { this.#voipReadyResolver = () => { @@ -516,7 +533,13 @@ export class WasmEngine { this.#voipStackInitPromise = null resolveInit() }) - } catch { + } catch (initErr: any) { + // Stash the error so `waitForVoipStackReady` can re-throw it + // instead of pretending init succeeded. Earlier this swallowed + // the failure and `waitForVoipStackReady()` returned cleanly, + // leaving the stack in a "ready but actually broken" state — + // downstream call setup would then crash with cryptic errors. + this.#voipStackInitError = initErr instanceof Error ? initErr : new Error(String(initErr)) this.#voipReadyResolver = null this.#voipReadyPromise = null this.#voipStackInitPromise = null @@ -526,12 +549,18 @@ export class WasmEngine { } waitForVoipStackReady = async (): Promise => { - if (this.#voipStackInitialized) return + if (this.#voipStackInitialized) { + if (this.#voipStackInitError) throw this.#voipStackInitError + return + } + if (this.#voipStackInitPromise) { await this.#voipStackInitPromise } else { await new Promise(r => setTimeout(r, 100)) } + + if (this.#voipStackInitError) throw this.#voipStackInitError } isVoipStackReady = (): boolean => this.#voipStackInitialized @@ -1110,7 +1139,19 @@ export class WasmEngine { #loadWasmModuleToWorker = (worker: NodeWorkerMessagePort): Promise => new Promise((resolve, reject) => { + // Worker can crash before sending `loaded` (OOM, native segfault, + // `importScripts` failure). Without a timeout the outer engine + // init would hang indefinitely. 30 s is generous — a healthy + // load usually finishes in single-digit seconds. + const WORKER_LOAD_TIMEOUT_MS = 30_000 + let timer: NodeJS.Timeout | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + worker.removeMessageListener('cmd', loadedHandler) worker.removeMessageListener('cmd', errorHandler) } @@ -1134,6 +1175,11 @@ export class WasmEngine { } } + timer = setTimeout(() => { + cleanup() + reject(new Error(`VoIP worker WASM load timed out after ${WORKER_LOAD_TIMEOUT_MS}ms`)) + }, WORKER_LOAD_TIMEOUT_MS) + worker.addMessageListener('cmd', loadedHandler) worker.addMessageListener('cmd', errorHandler) worker.workerID = this.#nextWorkerID++