mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-04-05 02:42:41 +00:00
fix: secure whatsapp bridge with automatic local auth token
This commit is contained in:
parent
cf56d15bdf
commit
1c1eee523d
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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")})
|
||||
]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user