diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index a98f3a88..55d3a85b 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -17,7 +17,7 @@ import { Boom } from '@hapi/boom'; import qrcode from 'qrcode-terminal'; import pino from 'pino'; import { readFile, writeFile, mkdir } from 'fs/promises'; -import { join, basename } from 'path'; +import { join, basename, resolve, sep } from 'path'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -196,17 +196,18 @@ export class WhatsAppClient { let outFilename: string; if (fileName) { - // Documents have a filename — use it with a unique prefix to avoid collisions - const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`; - outFilename = prefix + fileName; + const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_'); + outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`; } else { const mime = mimetype || 'application/octet-stream'; - // Derive extension from mimetype subtype (e.g. "image/png" → ".png", "application/pdf" → ".pdf") const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin'); outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`; } - const filepath = join(mediaDir, outFilename); + const filepath = resolve(mediaDir, outFilename); + if (!filepath.startsWith(resolve(mediaDir) + sep)) { + throw new Error(`Path traversal blocked: ${outFilename}`); + } await writeFile(filepath, buffer); return filepath; diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index a7fd8265..e2485da7 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -1,6 +1,7 @@ """WhatsApp channel implementation using Node.js bridge.""" import asyncio +import hashlib import json import mimetypes import os @@ -316,13 +317,7 @@ def _ensure_bridge_setup() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - npm_path = shutil.which("npm") - if not npm_path: - raise RuntimeError("npm not found. Please install Node.js >= 18.") + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge current_file = Path(__file__) @@ -341,6 +336,33 @@ def _ensure_bridge_setup() -> Path: "Try reinstalling: pip install --force-reinstall nanobot" ) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + logger.info("WhatsApp bridge source changed; rebuilding bridge...") + + npm_path = shutil.which("npm") + if not npm_path: + raise RuntimeError("npm not found. Please install Node.js >= 18.") + logger.info("Setting up WhatsApp bridge...") user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): @@ -352,6 +374,7 @@ def _ensure_bridge_setup() -> Path: logger.info(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") logger.info("Bridge ready") return user_bridge diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2fe39746..903555b4 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1271,6 +1271,7 @@ def channels_status( def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" + import hashlib import shutil import subprocess @@ -1278,16 +1279,7 @@ def _get_bridge_dir() -> Path: from nanobot.config.paths import get_bridge_install_dir user_bridge = get_bridge_install_dir() - - # Check if already built - if (user_bridge / "dist" / "index.js").exists(): - return user_bridge - - # Check for npm - npm_path = shutil.which("npm") - if not npm_path: - console.print("[red]npm not found. Please install Node.js >= 18.[/red]") - raise typer.Exit(1) + stamp_file = user_bridge / ".nanobot-bridge-source-hash" # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) @@ -1304,6 +1296,36 @@ def _get_bridge_dir() -> Path: console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) + def source_hash(root: Path) -> str: + digest = hashlib.sha256() + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root) + if rel.parts and rel.parts[0] in {"node_modules", "dist"}: + continue + digest.update(rel.as_posix().encode("utf-8")) + digest.update(b"\0") + digest.update(path.read_bytes()) + digest.update(b"\0") + return digest.hexdigest() + + expected_hash = source_hash(source) + current_hash = stamp_file.read_text().strip() if stamp_file.exists() else None + + # Reuse only a bridge built from the currently installed source. + if (user_bridge / "dist" / "index.js").exists() and current_hash == expected_hash: + return user_bridge + + if (user_bridge / "dist" / "index.js").exists() and current_hash != expected_hash: + console.print(f"{__logo__} WhatsApp bridge source changed; rebuilding bridge...") + + # Check for npm + npm_path = shutil.which("npm") + if not npm_path: + console.print("[red]npm not found. Please install Node.js >= 18.[/red]") + raise typer.Exit(1) + console.print(f"{__logo__} Setting up bridge...") # Copy to user directory @@ -1319,6 +1341,7 @@ def _get_bridge_dir() -> Path: console.print(" Building...") subprocess.run([npm_path, "run", "build"], cwd=user_bridge, check=True, capture_output=True) + stamp_file.write_text(expected_hash + "\n") console.print("[green]✓[/green] Bridge ready\n") except subprocess.CalledProcessError as e: