mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-03 00:05:55 +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 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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
|
||||||
|
|||||||
@ -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")})
|
||||||
|
]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user