From 3e2c8c529bfeb6ac530bcff1884ce5dc11162e2d Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:21:50 -0700 Subject: [PATCH] =?UTF-8?q?fix(whatsapp):=20resolve=20LID=E2=86=94phone=20?= =?UTF-8?q?aliases=20in=20allowlist=20matching=20(#3830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp DMs can arrive with LID sender IDs even when WHATSAPP_ALLOWED_USERS is configured with phone numbers. The allowlist check now reads bridge session mapping files (lid-mapping-*.json) to resolve phone↔LID aliases, matching users regardless of which identifier format the message uses. Both the Python gateway (_is_user_authorized) and the Node bridge (allowlist.js) now share the same mapping-file-based resolution logic. Co-authored-by: Frederico Ribeiro --- gateway/run.py | 58 +++++++++++++- scripts/whatsapp-bridge/allowlist.js | 79 +++++++++++++++++++ scripts/whatsapp-bridge/allowlist.test.mjs | 47 +++++++++++ scripts/whatsapp-bridge/bridge.js | 14 ++-- .../gateway/test_unauthorized_dm_behavior.py | 27 +++++++ 5 files changed, 217 insertions(+), 8 deletions(-) create mode 100644 scripts/whatsapp-bridge/allowlist.js create mode 100644 scripts/whatsapp-bridge/allowlist.test.mjs diff --git a/gateway/run.py b/gateway/run.py index 403463e64..ff4f93284 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -225,6 +225,49 @@ from gateway.session import ( from gateway.delivery import DeliveryRouter from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType + +def _normalize_whatsapp_identifier(value: str) -> str: + """Strip WhatsApp JID/LID syntax down to its stable numeric identifier.""" + return ( + str(value or "") + .strip() + .replace("+", "", 1) + .split(":", 1)[0] + .split("@", 1)[0] + ) + + +def _expand_whatsapp_auth_aliases(identifier: str) -> set: + """Resolve WhatsApp phone/LID aliases using bridge session mapping files.""" + normalized = _normalize_whatsapp_identifier(identifier) + if not normalized: + return set() + + session_dir = _hermes_home / "whatsapp" / "session" + resolved = set() + queue = [normalized] + + while queue: + current = queue.pop(0) + if not current or current in resolved: + continue + + resolved.add(current) + for suffix in ("", "_reverse"): + mapping_path = session_dir / f"lid-mapping-{current}{suffix}.json" + if not mapping_path.exists(): + continue + try: + mapped = _normalize_whatsapp_identifier( + json.loads(mapping_path.read_text(encoding="utf-8")) + ) + except Exception: + continue + if mapped and mapped not in resolved: + queue.append(mapped) + + return resolved + logger = logging.getLogger(__name__) # Sentinel placed into _running_agents immediately when a session starts @@ -1550,10 +1593,23 @@ class GatewayRunner: if global_allowlist: allowed_ids.update(uid.strip() for uid in global_allowlist.split(",") if uid.strip()) - # WhatsApp JIDs have @s.whatsapp.net suffix — strip it for comparison check_ids = {user_id} if "@" in user_id: check_ids.add(user_id.split("@")[0]) + + # WhatsApp: resolve phone↔LID aliases from bridge session mapping files + if source.platform == Platform.WHATSAPP: + normalized_allowed_ids = set() + for allowed_id in allowed_ids: + normalized_allowed_ids.update(_expand_whatsapp_auth_aliases(allowed_id)) + if normalized_allowed_ids: + allowed_ids = normalized_allowed_ids + + check_ids.update(_expand_whatsapp_auth_aliases(user_id)) + normalized_user_id = _normalize_whatsapp_identifier(user_id) + if normalized_user_id: + check_ids.add(normalized_user_id) + return bool(check_ids & allowed_ids) def _get_unauthorized_dm_behavior(self, platform: Optional[Platform]) -> str: diff --git a/scripts/whatsapp-bridge/allowlist.js b/scripts/whatsapp-bridge/allowlist.js new file mode 100644 index 000000000..760e413f2 --- /dev/null +++ b/scripts/whatsapp-bridge/allowlist.js @@ -0,0 +1,79 @@ +import path from 'path'; +import { existsSync, readFileSync } from 'fs'; + +export function normalizeWhatsAppIdentifier(value) { + return String(value || '') + .trim() + .replace(/:.*@/, '@') + .replace(/@.*/, '') + .replace(/^\+/, ''); +} + +export function parseAllowedUsers(rawValue) { + return new Set( + String(rawValue || '') + .split(',') + .map((value) => normalizeWhatsAppIdentifier(value)) + .filter(Boolean) + ); +} + +function readMappingFile(sessionDir, identifier, suffix = '') { + const filePath = path.join(sessionDir, `lid-mapping-${identifier}${suffix}.json`); + if (!existsSync(filePath)) { + return null; + } + + try { + const parsed = JSON.parse(readFileSync(filePath, 'utf8')); + const normalized = normalizeWhatsAppIdentifier(parsed); + return normalized || null; + } catch { + return null; + } +} + +export function expandWhatsAppIdentifiers(identifier, sessionDir) { + const normalized = normalizeWhatsAppIdentifier(identifier); + if (!normalized) { + return new Set(); + } + + // Walk both phone->LID and LID->phone mapping files so allowlists can use + // either form transparently in bot mode. + const resolved = new Set(); + const queue = [normalized]; + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || resolved.has(current)) { + continue; + } + + resolved.add(current); + + for (const suffix of ['', '_reverse']) { + const mapped = readMappingFile(sessionDir, current, suffix); + if (mapped && !resolved.has(mapped)) { + queue.push(mapped); + } + } + } + + return resolved; +} + +export function matchesAllowedUser(senderId, allowedUsers, sessionDir) { + if (!allowedUsers || allowedUsers.size === 0) { + return true; + } + + const aliases = expandWhatsAppIdentifiers(senderId, sessionDir); + for (const alias of aliases) { + if (allowedUsers.has(alias)) { + return true; + } + } + + return false; +} diff --git a/scripts/whatsapp-bridge/allowlist.test.mjs b/scripts/whatsapp-bridge/allowlist.test.mjs new file mode 100644 index 000000000..7eea7399c --- /dev/null +++ b/scripts/whatsapp-bridge/allowlist.test.mjs @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import os from 'node:os'; +import path from 'node:path'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; + +import { + expandWhatsAppIdentifiers, + matchesAllowedUser, + normalizeWhatsAppIdentifier, + parseAllowedUsers, +} from './allowlist.js'; + +test('normalizeWhatsAppIdentifier strips jid syntax and plus prefix', () => { + assert.equal(normalizeWhatsAppIdentifier('+19175395595@s.whatsapp.net'), '19175395595'); + assert.equal(normalizeWhatsAppIdentifier('267383306489914@lid'), '267383306489914'); + assert.equal(normalizeWhatsAppIdentifier('19175395595:12@s.whatsapp.net'), '19175395595'); +}); + +test('expandWhatsAppIdentifiers resolves phone and lid aliases from session files', () => { + const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-')); + + try { + writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914')); + writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595')); + + const aliases = expandWhatsAppIdentifiers('267383306489914@lid', sessionDir); + assert.deepEqual([...aliases].sort(), ['19175395595', '267383306489914']); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } +}); + +test('matchesAllowedUser accepts mapped lid sender when allowlist only contains phone number', () => { + const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-')); + + try { + writeFileSync(path.join(sessionDir, 'lid-mapping-19175395595.json'), JSON.stringify('267383306489914')); + writeFileSync(path.join(sessionDir, 'lid-mapping-267383306489914_reverse.json'), JSON.stringify('19175395595')); + + const allowedUsers = parseAllowedUsers('+19175395595'); + assert.equal(matchesAllowedUser('267383306489914@lid', allowedUsers, sessionDir), true); + assert.equal(matchesAllowedUser('188012763865257@lid', allowedUsers, sessionDir), false); + } finally { + rmSync(sessionDir, { recursive: true, force: true }); + } +}); diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 0dff8c2e2..46cc5c339 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -26,6 +26,7 @@ import path from 'path'; import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; import { randomBytes } from 'crypto'; import qrcode from 'qrcode-terminal'; +import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; // Parse CLI args const args = process.argv.slice(2); @@ -47,7 +48,7 @@ const DOCUMENT_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'docume const AUDIO_CACHE_DIR = path.join(process.env.HOME || '~', '.hermes', 'audio_cache'); const PAIR_ONLY = args.includes('--pair-only'); const WHATSAPP_MODE = getArg('mode', process.env.WHATSAPP_MODE || 'self-chat'); // "bot" or "self-chat" -const ALLOWED_USERS = (process.env.WHATSAPP_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean); +const ALLOWED_USERS = parseAllowedUsers(process.env.WHATSAPP_ALLOWED_USERS || ''); const DEFAULT_REPLY_PREFIX = '⚕ *Hermes Agent*\n────────────\n'; const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined ? DEFAULT_REPLY_PREFIX @@ -190,10 +191,9 @@ async function startSocket() { if (!isSelfChat) continue; } - // Check allowlist for messages from others (resolve LID → phone if needed) - if (!msg.key.fromMe && ALLOWED_USERS.length > 0) { - const resolvedNumber = lidToPhone[senderNumber] || senderNumber; - if (!ALLOWED_USERS.includes(resolvedNumber)) continue; + // Check allowlist for messages from others (resolve LID ↔ phone aliases) + if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) { + continue; } // Extract message body @@ -515,8 +515,8 @@ if (PAIR_ONLY) { app.listen(PORT, '127.0.0.1', () => { console.log(`🌉 WhatsApp bridge listening on port ${PORT} (mode: ${WHATSAPP_MODE})`); console.log(`📁 Session stored in: ${SESSION_DIR}`); - if (ALLOWED_USERS.length > 0) { - console.log(`🔒 Allowed users: ${ALLOWED_USERS.join(', ')}`); + if (ALLOWED_USERS.size > 0) { + console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`); } else { console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`); } diff --git a/tests/gateway/test_unauthorized_dm_behavior.py b/tests/gateway/test_unauthorized_dm_behavior.py index a0285e28a..6f4a9ff02 100644 --- a/tests/gateway/test_unauthorized_dm_behavior.py +++ b/tests/gateway/test_unauthorized_dm_behavior.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +import gateway.run as gateway_run from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.session import SessionSource @@ -62,6 +63,32 @@ def _make_runner(platform: Platform, config: GatewayConfig): return runner, adapter +def test_whatsapp_lid_user_matches_phone_allowlist_via_session_mapping(monkeypatch, tmp_path): + _clear_auth_env(monkeypatch) + monkeypatch.setenv("WHATSAPP_ALLOWED_USERS", "15550000001") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + + session_dir = tmp_path / "whatsapp" / "session" + session_dir.mkdir(parents=True) + (session_dir / "lid-mapping-15550000001.json").write_text('"900000000000001"', encoding="utf-8") + (session_dir / "lid-mapping-900000000000001_reverse.json").write_text('"15550000001"', encoding="utf-8") + + runner, _adapter = _make_runner( + Platform.WHATSAPP, + GatewayConfig(platforms={Platform.WHATSAPP: PlatformConfig(enabled=True)}), + ) + + source = SessionSource( + platform=Platform.WHATSAPP, + user_id="900000000000001@lid", + chat_id="900000000000001@lid", + user_name="tester", + chat_type="dm", + ) + + assert runner._is_user_authorized(source) is True + + @pytest.mark.asyncio async def test_unauthorized_dm_pairs_by_default(monkeypatch): _clear_auth_env(monkeypatch)