Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
373a6f3
fix(memory): handle ECACHEFULL on userDevicesCache.set in device-noti…
rsalcara Jun 7, 2026
424260a
release: develop → master 2026-06-06 (v2) — #509 + #510 + #511 + #512…
rsalcara Jun 7, 2026
44b7c91
chore: update proto/version to v2.3000.1040989513 (#516)
github-actions[bot] Jun 7, 2026
b5d1bab
chore: update WhatsApp Web version to v2.3000.1040994085 (#517)
rsalcara Jun 7, 2026
bb61401
release: develop → master 2026-06-08 — #514 + #515 + #518 + #519 + #520
rsalcara Jun 8, 2026
7f5d723
fix(audit #521): 3 P2 + 2 P3 from chatgpt/copilot/coderabbit review
rsalcara Jun 8, 2026
4214847
fix(audit #521): close cubic threads 8 + 9 (Issues D, E)
rsalcara Jun 8, 2026
b2c5abf
fix(lint): satisfy eqeqeq + remove unused isJidGroup import
rsalcara Jun 8, 2026
2ec8ba3
fix(audit #521): close cubic thread 13 — narrow OrphanMsmsg guard
rsalcara Jun 8, 2026
285db59
release: develop → master 2026-06-08 (5 PRs)
rsalcara Jun 8, 2026
c4ea52a
chore: update proto/version to v2.3000.1041008395 (#524)
github-actions[bot] Jun 8, 2026
3d5a7bd
chore: update WhatsApp Web version to v2.3000.1041011968 (#525)
rsalcara Jun 8, 2026
6c882ba
chore: update proto/version to v2.3000.1041063798 (#527)
github-actions[bot] Jun 9, 2026
5290713
chore: update WhatsApp Web version to v2.3000.1041103517 (#528)
rsalcara Jun 9, 2026
97cd676
chore: update proto/version to v2.3000.1041168064 (#530)
github-actions[bot] Jun 10, 2026
1392734
chore: update WhatsApp Web version to v2.3000.1041187577 (#531)
rsalcara Jun 10, 2026
0c84960
chore: update proto/version to v2.3000.1041257863 (#539)
github-actions[bot] Jun 11, 2026
41b245c
chore: update WhatsApp Web version to v2.3000.1041271747 (#540)
rsalcara Jun 11, 2026
2fca0f5
chore: update proto/version to v2.3000.1041336118 (#541)
github-actions[bot] Jun 12, 2026
17f7941
chore: update WhatsApp Web version to v2.3000.1041353304 (#542)
rsalcara Jun 12, 2026
0d867b4
chore: update proto/version to v2.3000.1041410692 (#543)
github-actions[bot] Jun 13, 2026
bd38378
chore: update WhatsApp Web version to v2.3000.1041420778 (#544)
rsalcara Jun 13, 2026
7fdf189
chore: merge master version bumps into release branch
rsalcara Jun 14, 2026
8b0176b
fix(PR #546 audit): tr range BLOCKER + 5 VoIP P1s + JID-HOSTED-P2 + 4…
rsalcara Jun 14, 2026
92fd102
chore: update proto/version to v2.3000.1041433777 (#547)
github-actions[bot] Jun 14, 2026
f7b4127
chore: update WhatsApp Web version to v2.3000.1041437765 (#548)
rsalcara Jun 14, 2026
53cbf63
fix(audit r6): FU-04 varint 5-byte overflow + FU-06 port upper-bound …
rsalcara Jun 14, 2026
6303c2d
release: develop → master 2026-06-13 — VoIP + 6 audit batches + versi…
rsalcara Jun 14, 2026
91df359
sync: master → develop 2026-06-14 — pull #546 release + auto-version …
rsalcara Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/update-proto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion WAProto/WAProto.proto
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;

/// WhatsApp Version: 2.3000.1041063798
/// WhatsApp Version: 2.3000.1041433777

message ADVDeviceIdentity {
optional uint32 rawId = 1;
Expand Down
2 changes: 1 addition & 1 deletion src/Defaults/baileys-version.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"version":[2,3000,1041103517]}
{"version":[2,3000,1041437765]}
8 changes: 8 additions & 0 deletions src/Signal/libsignal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++
Expand Down
33 changes: 25 additions & 8 deletions src/Utils/env-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,43 @@
*/

/**
* 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
}

/**
* Parse a float env var with a fallback. Used for ratios / thresholds
* 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
}
19 changes: 15 additions & 4 deletions src/Utils/multi-db-sqlite/keys-with-jid-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't delete unrelated legacy PN mappings on reverse deletes

For a reverse-only delete of an old LID, this queues a null write for the PN key resolved from jid_map without checking what the legacy inner forward entry currently contains. If that PN has since rotated to a newer LID that only exists in the legacy fallback store, deleting the whole PN key drops the still-valid pn → newLid mapping; the forward delete should only be propagated when the inner forward value matches the LID being removed.

Useful? React with 👍 / 👎.


continue
}

Expand Down Expand Up @@ -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)
Expand Down
34 changes: 19 additions & 15 deletions src/Utils/multi-db-sqlite/lid-mapping-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}

Expand Down
11 changes: 7 additions & 4 deletions src/Utils/prometheus-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
41 changes: 34 additions & 7 deletions src/Voip/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
33 changes: 29 additions & 4 deletions src/Voip/signaling/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Early return on multi-destination encryption failure drops signaling without notifying WASM. This can leave call setup hanging instead of surfacing an immediate failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/Voip/signaling/bridge.ts, line 297:

<comment>Early return on multi-destination encryption failure drops signaling without notifying WASM. This can leave call setup hanging instead of surfacing an immediate failure.</comment>

<file context>
@@ -283,10 +284,18 @@ export class SignalingBridge {
+			// 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)
</file context>


if (includeDeviceIdentity) this.#appendDeviceIdentity(voipNode)

await this.#sendCallStanza(this.#toBareJid(peerJid), voipNode, signalingTag, effectivePeerJid, peerJid)
Expand Down Expand Up @@ -590,15 +599,23 @@ 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)
}

#toCallDeviceJid = (jid: string): string => {
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}`
}
Expand All @@ -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)
}

Expand Down Expand Up @@ -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}`)
Expand Down
Loading
Loading