File size: 5,215 Bytes
28a08e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
"""
    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]}