From 28929a47f73b5e8ae4f0c70cb78cb3fcee6a8d19 Mon Sep 17 00:00:00 2001 From: Filipi Fuchter Date: Mon, 6 Oct 2025 16:16:59 -0300 Subject: [PATCH] Verifying WhatsApp signature to ensure the webhook request is from WhatsApp. --- env.example | 6 +++++ src/pipecat/runner/run.py | 16 +++++++++--- src/pipecat/transports/whatsapp/client.py | 31 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/env.example b/env.example index 9c9ee5d8c..22768494b 100644 --- a/env.example +++ b/env.example @@ -158,3 +158,9 @@ NVIDIA_API_KEY=... # Qwen QWEN_API_KEY=... + +# WhatsApp +WHATSAPP_TOKEN= +WHATSAPP_WEBHOOK_VERIFICATION_TOKEN= +WHATSAPP_PHONE_NUMBER_ID= +WHATSAPP_APP_SECRET= \ No newline at end of file diff --git a/src/pipecat/runner/run.py b/src/pipecat/runner/run.py index edcfbe9c4..1ab923314 100644 --- a/src/pipecat/runner/run.py +++ b/src/pipecat/runner/run.py @@ -84,7 +84,7 @@ from pipecat.runner.types import ( try: import uvicorn from dotenv import load_dotenv - from fastapi import BackgroundTasks, FastAPI, HTTPException, Request, WebSocket + from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, RedirectResponse except ImportError as e: @@ -275,6 +275,7 @@ def _setup_whatsapp_routes(app: FastAPI): WHATSAPP_TOKEN = os.getenv("WHATSAPP_TOKEN") WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID") WHATSAPP_WEBHOOK_VERIFICATION_TOKEN = os.getenv("WHATSAPP_WEBHOOK_VERIFICATION_TOKEN") + WHATSAPP_APP_SECRET = os.getenv("WHATSAPP_APP_SECRET") if not all( [ @@ -325,7 +326,12 @@ def _setup_whatsapp_routes(app: FastAPI): summary="Handle WhatsApp webhook events", description="Processes incoming WhatsApp messages and call events", ) - async def whatsapp_webhook(body: WhatsAppWebhookRequest, background_tasks: BackgroundTasks): + async def whatsapp_webhook( + body: WhatsAppWebhookRequest, + background_tasks: BackgroundTasks, + request: Request, + x_hub_signature_256: str = Header(None), + ): """Handle incoming WhatsApp webhook events. For call events, establishes WebRTC connections and spawns bot instances @@ -357,7 +363,10 @@ def _setup_whatsapp_routes(app: FastAPI): try: # Process the webhook request - result = await whatsapp_client.handle_webhook_request(body, connection_callback) + raw_body = await request.body() + result = await whatsapp_client.handle_webhook_request( + body, connection_callback, sha256_signature=x_hub_signature_256, raw_body=raw_body + ) logger.debug(f"Webhook processed successfully: {result}") return {"status": "success", "message": "Webhook processed successfully"} except ValueError as ve: @@ -376,6 +385,7 @@ def _setup_whatsapp_routes(app: FastAPI): async with aiohttp.ClientSession() as session: whatsapp_client = WhatsAppClient( whatsapp_token=WHATSAPP_TOKEN, + whatsapp_secret=WHATSAPP_APP_SECRET, phone_number_id=WHATSAPP_PHONE_NUMBER_ID, session=session, ) diff --git a/src/pipecat/transports/whatsapp/client.py b/src/pipecat/transports/whatsapp/client.py index 7c86ca2ce..f6fd83e7b 100644 --- a/src/pipecat/transports/whatsapp/client.py +++ b/src/pipecat/transports/whatsapp/client.py @@ -12,6 +12,8 @@ WhatsApp call events. """ import asyncio +import hashlib +import hmac from typing import Awaitable, Callable, Dict, List, Optional import aiohttp @@ -47,6 +49,7 @@ class WhatsAppClient: phone_number_id: str, session: aiohttp.ClientSession, ice_servers: Optional[List[IceServer]] = None, + whatsapp_secret: Optional[str] = None, ) -> None: """Initialize the WhatsApp client. @@ -56,10 +59,12 @@ class WhatsAppClient: session: aiohttp session for making HTTP requests ice_servers: List of ICE servers for WebRTC connections. If None, defaults to Google's public STUN server + whatsapp_secret: WhatsApp APP secret for validating that the webhook request came from WhatsApp. """ self._whatsapp_api = WhatsAppApi( whatsapp_token=whatsapp_token, phone_number_id=phone_number_id, session=session ) + self._whatsapp_secret = whatsapp_secret self._ongoing_calls_map: Dict[str, SmallWebRTCConnection] = {} # Set default ICE servers if none provided @@ -133,10 +138,32 @@ class WhatsAppClient: return int(challenge) + async def _validate_whatsapp_webhook_request(self, raw_body: bytes, sha256_signature: str): + """Common handler for both /start and /connect endpoints.""" + # Compute HMAC SHA256 using your App Secret + expected_signature = hmac.new( + key=self._whatsapp_secret.encode("utf-8"), + msg=raw_body, + digestmod=hashlib.sha256, + ).hexdigest() + + # Extract signature from header (strip 'sha256=' prefix) + if not sha256_signature: + raise Exception("Missing X-Hub-Signature-256 header") + received_signature = sha256_signature.split("sha256=")[-1] + + # Compare signatures securely + if not hmac.compare_digest(expected_signature, received_signature): + raise Exception("Invalid webhook signature") + + logger.debug(f"Webhook signature verified!") + async def handle_webhook_request( self, request: WhatsAppWebhookRequest, connection_callback: Optional[Callable[[SmallWebRTCConnection], Awaitable[None]]] = None, + raw_body: Optional[bytes] = None, + sha256_signature: Optional[str] = None, ) -> bool: """Handle a webhook request from WhatsApp. @@ -150,6 +177,8 @@ class WhatsAppClient: connection_callback: Optional callback function to invoke when a new WebRTC connection is established. The callback receives the SmallWebRTCConnection instance. + raw_body: Optional bytes containing the raw request body. + sha256_signature: Optional X-Hub-Signature-256 header value from the request. Returns: bool: True if the webhook request was handled successfully, False otherwise @@ -159,6 +188,8 @@ class WhatsAppClient: Exception: If connection establishment or API calls fail """ try: + if self._whatsapp_secret: + await self._validate_whatsapp_webhook_request(raw_body, sha256_signature) for entry in request.entry: for change in entry.changes: # Handle connect events