Files
antigravity-skills-reference/skills/whatsapp-cloud-api/assets/boilerplate/python/webhook_handler.py
sickn33 3b6ef3add8 fix(security): Remediate scanning and dependency alerts
Harden template and example code paths, redact sensitive output, and pin safe transitive npm packages. Consolidate the todo backend on better-sqlite3 so the example no longer pulls the vulnerable sqlite3 chain and still passes build and CRUD smoke checks.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-18 18:05:45 +01:00

140 lines
4.3 KiB
Python

"""WhatsApp Webhook Handler with HMAC-SHA256 validation."""
import hashlib
import hmac
import os
import re
from functools import wraps
from typing import Any
from flask import Response, abort, request
_SAFE_CHALLENGE_RE = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
def validate_hmac_signature(app_secret: str | None = None):
"""
Flask decorator to validate HMAC-SHA256 signature on webhook requests.
A Meta assina cada request com o App Secret no header X-Hub-Signature-256.
Validar esta assinatura previne requests falsificados.
Usa hmac.compare_digest para comparacao constant-time (previne timing attacks).
"""
secret = app_secret or os.environ["APP_SECRET"]
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
signature = request.headers.get("X-Hub-Signature-256", "")
if not signature:
abort(401, "Missing signature header")
raw_body = request.get_data()
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(signature, expected):
abort(401, "Invalid signature")
return f(*args, **kwargs)
return decorated_function
return decorator
def verify_webhook(verify_token: str | None = None):
"""
Handle webhook verification (GET request from Meta).
Returns the challenge to confirm the webhook endpoint.
"""
token = verify_token or os.environ["VERIFY_TOKEN"]
mode = request.args.get("hub.mode")
req_token = request.args.get("hub.verify_token")
challenge = request.args.get("hub.challenge")
if mode != "subscribe" or req_token != token:
abort(403, "Verification failed")
if not challenge or not _SAFE_CHALLENGE_RE.fullmatch(challenge):
abort(400, "Invalid challenge")
return Response(challenge, status=200, mimetype="text/plain")
def parse_webhook_payload(data: dict[str, Any]) -> dict[str, list]:
"""
Parse webhook payload and extract messages and status updates.
Returns dict with 'messages' and 'statuses' lists.
"""
messages: list[dict] = []
statuses: list[dict] = []
for entry in data.get("entry", []):
for change in entry.get("changes", []):
value = change.get("value", {})
if "messages" in value:
messages.extend(value["messages"])
if "statuses" in value:
statuses.extend(value["statuses"])
return {"messages": messages, "statuses": statuses}
def extract_message_content(message: dict[str, Any]) -> dict[str, Any]:
"""
Extract readable content from an incoming message.
Returns dict with 'type' and relevant fields (text, button_id, media_id, etc.).
"""
msg_type = message.get("type", "unknown")
if msg_type == "text":
return {"type": "text", "text": message.get("text", {}).get("body")}
if msg_type == "interactive":
interactive = message.get("interactive", {})
int_type = interactive.get("type")
if int_type == "button_reply":
reply = interactive.get("button_reply", {})
return {"type": "button", "button_id": reply.get("id"), "text": reply.get("title")}
if int_type == "list_reply":
reply = interactive.get("list_reply", {})
return {"type": "list", "list_id": reply.get("id"), "text": reply.get("title")}
if int_type == "nfm_reply":
return {
"type": "flow",
"text": interactive.get("nfm_reply", {}).get("response_json"),
}
return {"type": "interactive"}
if msg_type in ("image", "document", "video", "audio"):
media_data = message.get(msg_type, {})
return {"type": msg_type, "media_id": media_data.get("id")}
if msg_type == "location":
loc = message.get("location", {})
return {
"type": "location",
"latitude": loc.get("latitude"),
"longitude": loc.get("longitude"),
}
if msg_type == "reaction":
reaction = message.get("reaction", {})
return {
"type": "reaction",
"emoji": reaction.get("emoji"),
"message_id": reaction.get("message_id"),
}
return {"type": msg_type}