Spaces:
Running
Running
| """ | |
| 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]} | |