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 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('========================\n');

View File

@ -1,6 +1,6 @@
/**
* 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';
@ -33,13 +33,29 @@ export class BridgeServer {
private wa: WhatsAppClient | null = null;
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> {
if (!this.token.trim()) {
throw new Error('BRIDGE_TOKEN is required');
}
// 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}`);
if (this.token) console.log('🔒 Token authentication enabled');
console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
@ -51,27 +67,22 @@ export class BridgeServer {
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
if (this.token) {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
});
} else {
console.log('🔗 Python client connected');
this.setupClient(ws);
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
});
// Connect to WhatsApp

View File

@ -4,6 +4,7 @@ import asyncio
import json
import mimetypes
import os
import secrets
import shutil
import subprocess
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
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):
"""
WhatsApp channel that connects to a Node.js bridge.
@ -51,6 +75,18 @@ class WhatsAppChannel(BaseChannel):
self._ws = None
self._connected = False
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:
"""
@ -60,8 +96,6 @@ class WhatsAppChannel(BaseChannel):
authentication flow. The process blocks until the user scans the QR code
or interrupts with Ctrl+C.
"""
from nanobot.config.paths import get_runtime_subdir
try:
bridge_dir = _ensure_bridge_setup()
except RuntimeError as e:
@ -69,9 +103,8 @@ class WhatsAppChannel(BaseChannel):
return False
env = {**os.environ}
if self.config.bridge_token:
env["BRIDGE_TOKEN"] = self.config.bridge_token
env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth"))
env["BRIDGE_TOKEN"] = self._effective_bridge_token()
env["AUTH_DIR"] = str(_bridge_token_path().parent)
logger.info("Starting WhatsApp bridge for QR login...")
try:
@ -97,11 +130,9 @@ class WhatsAppChannel(BaseChannel):
try:
async with websockets.connect(bridge_url) as ws:
self._ws = ws
# Send auth token if configured
if self.config.bridge_token:
await ws.send(
json.dumps({"type": "auth", "token": self.config.bridge_token})
)
await ws.send(
json.dumps({"type": "auth", "token": self._effective_bridge_token()})
)
self._connected = True
logger.info("Connected to WhatsApp bridge")

View File

@ -1,12 +1,18 @@
"""Tests for WhatsApp channel outbound media support."""
import json
import os
import sys
import types
from unittest.mock import AsyncMock, MagicMock
import pytest
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:
@ -155,3 +161,96 @@ async def test_group_policy_mention_accepts_mentioned_group_message():
kwargs = ch._handle_message.await_args.kwargs
assert kwargs["chat_id"] == "12345@g.us"
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")})
]