diff --git a/bridge/src/index.ts b/bridge/src/index.ts index e8f3db9b9..b821a4b3e 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -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'); diff --git a/bridge/src/server.ts b/bridge/src/server.ts index 4e50f4a61..a2860ec14 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -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 = 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 { + 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 diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 95bde46e9..a788dd727 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -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") diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index dea15d7b2..8223fdff3 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -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")}) + ]