""" backend/tools/trigger_webhook.py — P17-F4: Tool trigger_webhook generico. Invia una HTTP request (GET/POST/PUT/PATCH/DELETE) a qualsiasi URL esterno. Importato da registry.py tramite: from tools.trigger_webhook import trigger_webhook Allowlist: WEBHOOK_ALLOWED_HOSTS env (vuoto = tutti consentiti). """ from __future__ import annotations import json import logging import os import socket import ipaddress from typing import Any from urllib.parse import urlparse import httpx _logger = logging.getLogger("agente_ai.tools.trigger_webhook") _ALLOWED_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE"} _ALLOWED_HOSTS_RAW = os.getenv("WEBHOOK_ALLOWED_HOSTS", "") _ALLOWED_HOSTS: set[str] = {h.strip().lower() for h in _ALLOWED_HOSTS_RAW.split(",") if h.strip()} def _check_host(url: str) -> tuple[bool, str]: """ Verifica l'host dell'URL contro la allowlist e previene SSRF. Implementa risoluzione DNS e blocco IP privati/locali. """ try: parsed = urlparse(url) if parsed.scheme not in ("http", "https"): return False, f"Schema non supportato: {parsed.scheme}" host = parsed.hostname or "" # 1. Allowlist check (se configurata) if _ALLOWED_HOSTS: allowed_match = False if host.lower() in _ALLOWED_HOSTS: allowed_match = True else: for allowed in _ALLOWED_HOSTS: if allowed.startswith("*.") and host.lower().endswith(allowed[1:]): allowed_match = True break if not allowed_match: return False, f"Host '{host}' non nella allowlist WEBHOOK_ALLOWED_HOSTS" # 2. SSRF Protection (Risoluzione DNS + IP Check) try: # socket.gethostbyname() risolve l'host all'indirizzo IPv4 ip_str = socket.gethostbyname(host) ip = ipaddress.ip_address(ip_str) if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast: _logger.warning("[trigger_webhook] SSRF bloccato: %s -> %s", host, ip_str) return False, f"Accesso a indirizzi privati/locali ({ip_str}) non consentito per motivi di sicurezza." except socket.gaierror: # Host non risolvibile — httpx gestirà l'errore di connessione se procediamo pass except Exception as exc: return False, f"Errore validazione IP: {exc}" return True, "" except Exception as exc: return False, f"URL non valido: {exc}" async def trigger_webhook( url: str, payload: "dict[str, Any] | str | None" = None, method: str = "POST", headers: "dict[str, str] | None" = None, timeout: float = 10.0, ) -> dict: """ Invia una HTTP request a un URL esterno. Args: url: URL destinazione (obbligatorio). payload: Body JSON (dict) o stringa raw. method: GET|POST|PUT|PATCH|DELETE (default POST). headers: Header HTTP aggiuntivi. timeout: Max 15s. Returns: {ok, status_code, body, url, method} | {ok: False, error: str} """ method = method.upper().strip() if method not in _ALLOWED_METHODS: return {"ok": False, "error": f"Metodo non supportato: {method}. Usa: {sorted(_ALLOWED_METHODS)}"} ok_host, err_host = _check_host(url) if not ok_host: return {"ok": False, "error": err_host} _hdrs: dict[str, str] = {"User-Agent": "AgentAI-Webhook/1.0"} if headers: _hdrs.update(headers) body_bytes: bytes | None = None if payload is not None: if isinstance(payload, dict): body_bytes = json.dumps(payload).encode("utf-8") _hdrs.setdefault("Content-Type", "application/json") else: body_bytes = str(payload).encode("utf-8") _hdrs.setdefault("Content-Type", "text/plain; charset=utf-8") _timeout = min(float(timeout), 15.0) try: # follow_redirects=False per prevenire bypass SSRF via redirect verso IP interni async with httpx.AsyncClient(timeout=_timeout, follow_redirects=False) as client: resp = await client.request( method, url, headers=_hdrs, content=body_bytes if method != "GET" else None, params=payload if method == "GET" and isinstance(payload, dict) else None, ) resp_body: Any ct = resp.headers.get("content-type", "") try: resp_body = resp.json() if "application/json" in ct else resp.text[:4000] except Exception: resp_body = resp.text[:4000] _logger.info("[trigger_webhook] %s %s -> HTTP %d", method, url, resp.status_code) return {"ok": resp.is_success, "status_code": resp.status_code, "body": resp_body, "url": url, "method": method, "error": None} except httpx.TimeoutException: return {"ok": False, "error": f"Timeout {_timeout}s — URL non raggiungibile: {url}"} except httpx.ConnectError as exc: return {"ok": False, "error": f"Connessione fallita a {url}: {exc}"} except Exception as exc: _logger.warning("[trigger_webhook] errore: %s", exc) return {"ok": False, "error": str(exc)[:300]}