fix: secure whatsapp bridge with automatic local auth token

This commit is contained in:
Xubin Ren 2026-04-04 14:16:46 +00:00
parent cf56d15bdf
commit 1c1eee523d
4 changed files with 182 additions and 36 deletions

View File

@ -25,7 +25,12 @@ import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10); const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth'); const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN || undefined; const TOKEN = process.env.BRIDGE_TOKEN?.trim();
if (!TOKEN) {
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
process.exit(1);
}
console.log('🐈 nanobot WhatsApp Bridge'); console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n'); console.log('========================\n');

View File

@ -1,6 +1,6 @@
/** /**
* WebSocket server for Python-Node.js bridge communication. * WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth. * Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
*/ */
import { WebSocketServer, WebSocket } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
@ -33,13 +33,29 @@ export class BridgeServer {
private wa: WhatsAppClient | null = null; private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set(); private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token?: string) {} constructor(private port: number, private authDir: string, private token: string) {}
async start(): Promise<void> { async start(): Promise<void> {
if (!this.token.trim()) {
throw new Error('BRIDGE_TOKEN is required');
}
// Bind to localhost only — never expose to external network // Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port }); this.wss = new WebSocketServer({
host: '127.0.0.1',
port: this.port,
verifyClient: (info, done) => {
const origin = info.origin || info.req.headers.origin;
if (origin) {
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
return;
}
done(true);
},
});
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`); console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
if (this.token) console.log('🔒 Token authentication enabled'); console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client // Initialize WhatsApp client
this.wa = new WhatsAppClient({ this.wa = new WhatsAppClient({
@ -51,27 +67,22 @@ export class BridgeServer {
// Handle WebSocket connections // Handle WebSocket connections
this.wss.on('connection', (ws) => { this.wss.on('connection', (ws) => {
if (this.token) { // Require auth handshake as first message
// Require auth handshake as first message const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000); ws.once('message', (data) => {
ws.once('message', (data) => { clearTimeout(timeout);
clearTimeout(timeout); try {
try { const msg = JSON.parse(data.toString());
const msg = JSON.parse(data.toString()); if (msg.type === 'auth' && msg.token === this.token) {
if (msg.type === 'auth' && msg.token === this.token) { console.log('🔗 Python client authenticated');
console.log('🔗 Python client authenticated'); this.setupClient(ws);
this.setupClient(ws); } else {
} else { ws.close(4003, 'Invalid token');
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
} }
}); } catch {
} else { ws.close(4003, 'Invalid auth message');
console.log('🔗 Python client connected'); }
this.setupClient(ws); });
}
}); });
// Connect to WhatsApp // Connect to WhatsApp

View File

@ -4,6 +4,7 @@ import asyncio
import json import json
import mimetypes import mimetypes
import os import os
import secrets
import shutil import shutil
import subprocess import subprocess
from collections import OrderedDict from collections import OrderedDict
@ -29,6 +30,29 @@ class WhatsAppConfig(Base):
group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned
def _bridge_token_path() -> Path:
from nanobot.config.paths import get_runtime_subdir
return get_runtime_subdir("whatsapp-auth") / "bridge-token"
def _load_or_create_bridge_token(path: Path) -> str:
"""Load a persisted bridge token or create one on first use."""
if path.exists():
token = path.read_text(encoding="utf-8").strip()
if token:
return token
path.parent.mkdir(parents=True, exist_ok=True)
token = secrets.token_urlsafe(32)
path.write_text(token, encoding="utf-8")
try:
path.chmod(0o600)
except OSError:
pass
return token
class WhatsAppChannel(BaseChannel): class WhatsAppChannel(BaseChannel):
""" """
WhatsApp channel that connects to a Node.js bridge. WhatsApp channel that connects to a Node.js bridge.
@ -51,6 +75,18 @@ class WhatsAppChannel(BaseChannel):
self._ws = None self._ws = None
self._connected = False self._connected = False
self._processed_message_ids: OrderedDict[str, None] = OrderedDict() self._processed_message_ids: OrderedDict[str, None] = OrderedDict()
self._bridge_token: str | None = None
def _effective_bridge_token(self) -> str:
"""Resolve the bridge token, generating a local secret when needed."""
if self._bridge_token is not None:
return self._bridge_token
configured = self.config.bridge_token.strip()
if configured:
self._bridge_token = configured
else:
self._bridge_token = _load_or_create_bridge_token(_bridge_token_path())
return self._bridge_token
async def login(self, force: bool = False) -> bool: async def login(self, force: bool = False) -> bool:
""" """
@ -60,8 +96,6 @@ class WhatsAppChannel(BaseChannel):
authentication flow. The process blocks until the user scans the QR code authentication flow. The process blocks until the user scans the QR code
or interrupts with Ctrl+C. or interrupts with Ctrl+C.
""" """
from nanobot.config.paths import get_runtime_subdir
try: try:
bridge_dir = _ensure_bridge_setup() bridge_dir = _ensure_bridge_setup()
except RuntimeError as e: except RuntimeError as e:
@ -69,9 +103,8 @@ class WhatsAppChannel(BaseChannel):
return False return False
env = {**os.environ} env = {**os.environ}
if self.config.bridge_token: env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["BRIDGE_TOKEN"] = self.config.bridge_token env["AUTH_DIR"] = str(_bridge_token_path().parent)
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
logger.info("Starting WhatsApp bridge for QR login...") logger.info("Starting WhatsApp bridge for QR login...")
try: try:
@ -97,11 +130,9 @@ class WhatsAppChannel(BaseChannel):
try: try:
async with websockets.connect(bridge_url) as ws: async with websockets.connect(bridge_url) as ws:
self._ws = ws self._ws = ws
# Send auth token if configured await ws.send(
if self.config.bridge_token: json.dumps({"type": "auth", "token": self._effective_bridge_token()})
await ws.send( )
json.dumps({"type": "auth", "token": self.config.bridge_token})
)
self._connected = True self._connected = True
logger.info("Connected to WhatsApp bridge") logger.info("Connected to WhatsApp bridge")

View File

@ -1,12 +1,18 @@
"""Tests for WhatsApp channel outbound media support.""" """Tests for WhatsApp channel outbound media support."""
import json import json
import os
import sys
import types
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.channels.whatsapp import WhatsAppChannel from nanobot.channels.whatsapp import (
WhatsAppChannel,
_load_or_create_bridge_token,
)
def _make_channel() -> WhatsAppChannel: def _make_channel() -> WhatsAppChannel:
@ -155,3 +161,96 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
kwargs = ch._handle_message.await_args.kwargs kwargs = ch._handle_message.await_args.kwargs
assert kwargs["chat_id"] == "12345@g.us" assert kwargs["chat_id"] == "12345@g.us"
assert kwargs["sender_id"] == "user" assert kwargs["sender_id"] == "user"
def test_load_or_create_bridge_token_persists_generated_secret(tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
first = _load_or_create_bridge_token(token_path)
second = _load_or_create_bridge_token(token_path)
assert first == second
assert token_path.read_text(encoding="utf-8") == first
assert len(first) >= 32
if os.name != "nt":
assert token_path.stat().st_mode & 0o777 == 0o600
def test_configured_bridge_token_skips_local_token_file(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
ch = WhatsAppChannel({"enabled": True, "bridgeToken": "manual-secret"}, MagicMock())
assert ch._effective_bridge_token() == "manual-secret"
assert not token_path.exists()
@pytest.mark.asyncio
async def test_login_exports_effective_bridge_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
calls = []
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setattr("nanobot.channels.whatsapp._ensure_bridge_setup", lambda: bridge_dir)
monkeypatch.setattr("nanobot.channels.whatsapp.shutil.which", lambda _: "/usr/bin/npm")
def fake_run(*args, **kwargs):
calls.append((args, kwargs))
return MagicMock()
monkeypatch.setattr("nanobot.channels.whatsapp.subprocess.run", fake_run)
ch = WhatsAppChannel({"enabled": True}, MagicMock())
assert await ch.login() is True
assert len(calls) == 1
_, kwargs = calls[0]
assert kwargs["cwd"] == bridge_dir
assert kwargs["env"]["AUTH_DIR"] == str(token_path.parent)
assert kwargs["env"]["BRIDGE_TOKEN"] == token_path.read_text(encoding="utf-8")
@pytest.mark.asyncio
async def test_start_sends_auth_message_with_generated_token(monkeypatch, tmp_path):
token_path = tmp_path / "whatsapp-auth" / "bridge-token"
sent_messages: list[str] = []
class FakeWS:
def __init__(self) -> None:
self.close = AsyncMock()
async def send(self, message: str) -> None:
sent_messages.append(message)
ch._running = False
def __aiter__(self):
return self
async def __anext__(self):
raise StopAsyncIteration
class FakeConnect:
def __init__(self, ws):
self.ws = ws
async def __aenter__(self):
return self.ws
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr("nanobot.channels.whatsapp._bridge_token_path", lambda: token_path)
monkeypatch.setitem(
sys.modules,
"websockets",
types.SimpleNamespace(connect=lambda url: FakeConnect(FakeWS())),
)
ch = WhatsAppChannel({"enabled": True, "bridgeUrl": "ws://localhost:3001"}, MagicMock())
await ch.start()
assert sent_messages == [
json.dumps({"type": "auth", "token": token_path.read_text(encoding="utf-8")})
]