ai-memory-backend / tools /trigger_webhook.py
Baida07's picture
ab-p1-sync: ab-unlock-p1 (#8)
b2b3de9
Raw
History Blame
5.22 kB
"""
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]}