fix(whatsapp): resolve LID↔phone aliases in allowlist matching (#3830)

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 <fr@tecompanytea.com>
This commit is contained in:
Teknium
2026-03-29 18:21:50 -07:00
committed by GitHub
parent e4d575e563
commit 3e2c8c529b
5 changed files with 217 additions and 8 deletions

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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 });
}
});

View File

@@ -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`);
}

View File

@@ -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)