fix(whatsapp): reject strangers by default, never respond in self-chat (#8389) (#21291)

Self-chat mode (default) previously replied to ANY incoming DM with a
Python-side pairing-code message. Two compounding defaults:

1. allowlist.js::matchesAllowedUser returned true for an empty
   allowlist — so WHATSAPP_ALLOWED_USERS unset → everyone passes the JS
   bridge gate → messages reach Python gateway → _is_user_authorized
   returns False but _get_unauthorized_dm_behavior falls back to
   'pair' → stranger gets a pairing code reply.
2. bridge.js had no mode check on !fromMe messages, so self-chat mode
   (where the operator only wants to talk to themselves) forwarded
   everything anyway.

Fix:
- allowlist.js: empty allowlist now returns false. Operators who want
  an open bot must set WHATSAPP_ALLOWED_USERS=* explicitly (the
  existing wildcard behaviour, consistent with SIGNAL_GROUP_ALLOWED_USERS).
- bridge.js: self-chat mode hard-rejects all !fromMe messages at the
  bridge, before they ever reach the Python gateway. Bot mode still
  enforces the allowlist.
- Startup log message updated to reflect the new per-mode behaviour
  (was '⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be
  processed', which was both inaccurate post-fix and a bad default
  signal pre-fix).
- allowlist.test.mjs: new regression test pinning the empty-rejects
  contract, + null/undefined defensive cases.

Behaviour delta for existing users:
- self-chat mode, no allowlist: strangers got pairing codes, now
  silently dropped. Strictly better.
- bot mode, no allowlist: strangers got pairing codes via the
  Python-side pairing flow, now silently dropped at the JS bridge.
  Operators who genuinely want an open bot set
  WHATSAPP_ALLOWED_USERS=*.
This commit is contained in:
Teknium 2026-05-07 06:53:04 -07:00 committed by GitHub
parent 76d2dcdc8e
commit 6a4ecc0a9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 59 additions and 13 deletions

View file

@ -64,8 +64,12 @@ export function expandWhatsAppIdentifiers(identifier, sessionDir) {
}
export function matchesAllowedUser(senderId, allowedUsers, sessionDir) {
// Empty allowlist = NO ONE allowed (secure default, #8389). Operators
// who want an open bot must set ``WHATSAPP_ALLOWED_USERS=*`` explicitly.
// Previous behaviour (empty → return true) let any stranger DM the
// bridge and trigger a Python-side pairing-code reply.
if (!allowedUsers || allowedUsers.size === 0) {
return true;
return false;
}
// "*" means allow everyone (consistent with SIGNAL_GROUP_ALLOWED_USERS)

View file

@ -57,3 +57,24 @@ test('matchesAllowedUser treats * as allow-all wildcard', () => {
rmSync(sessionDir, { recursive: true, force: true });
}
});
test('matchesAllowedUser rejects everyone when allowlist is empty (#8389)', () => {
// Regression guard: empty allowlist used to return true (allow-everyone),
// which let any stranger DM the bridge and trigger a Python-side
// pairing-code reply. Secure default is now "reject unless explicitly
// configured"; operators who want an open bot must set `*`.
const sessionDir = mkdtempSync(path.join(os.tmpdir(), 'hermes-wa-allowlist-'));
try {
const empty = parseAllowedUsers('');
assert.equal(empty.size, 0);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', empty, sessionDir), false);
assert.equal(matchesAllowedUser('267383306489914@lid', empty, sessionDir), false);
// Null/undefined allowlist (defensive) also rejects.
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', null, sessionDir), false);
assert.equal(matchesAllowedUser('19175395595@s.whatsapp.net', undefined, sessionDir), false);
} finally {
rmSync(sessionDir, { recursive: true, force: true });
}
});

View file

@ -267,17 +267,34 @@ async function startSocket() {
if (!isSelfChat) continue;
}
// Check allowlist for messages from others (resolve LID ↔ phone aliases)
if (!msg.key.fromMe && !matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'allowlist_mismatch',
chatId,
senderId,
}));
} catch {}
continue;
// Handle !fromMe messages (from other people) based on mode.
// Self-chat mode only responds to the user's own messages to
// themselves — stranger DMs / group pings must never reach the
// Python gateway, otherwise a pairing-code reply fires in response
// to arbitrary incoming messages (#8389).
if (!msg.key.fromMe) {
if (WHATSAPP_MODE === 'self-chat') {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'self_chat_mode_rejects_non_self',
chatId,
senderId,
}));
} catch {}
continue;
}
if (!matchesAllowedUser(senderId, ALLOWED_USERS, SESSION_DIR)) {
try {
console.log(JSON.stringify({
event: 'ignored',
reason: 'allowlist_mismatch',
chatId,
senderId,
}));
} catch {}
continue;
}
}
const messageContent = getMessageContent(msg);
@ -676,8 +693,12 @@ if (PAIR_ONLY) {
console.log(`📁 Session stored in: ${SESSION_DIR}`);
if (ALLOWED_USERS.size > 0) {
console.log(`🔒 Allowed users: ${Array.from(ALLOWED_USERS).join(', ')}`);
} else if (WHATSAPP_MODE === 'self-chat') {
console.log(`🔒 Self-chat mode — only your own messages to yourself are processed.`);
} else {
console.log(`⚠️ No WHATSAPP_ALLOWED_USERS set — all messages will be processed`);
console.log(`🔒 No WHATSAPP_ALLOWED_USERS set — incoming messages are rejected.`);
console.log(` Set WHATSAPP_ALLOWED_USERS=<phone> to authorize specific users,`);
console.log(` or WHATSAPP_ALLOWED_USERS=* for an explicit open bot.`);
}
console.log();
startSocket();