"""Pydantic schemas for Webhook endpoints.""" import ipaddress import socket import uuid from datetime import datetime from typing import Any from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, field_validator # RFC-5735 / RFC-1918 / RFC-3927 — ranges that must never be webhook targets _BLOCKED_NETWORKS = [ ipaddress.ip_network("10.0.0.0/8"), ipaddress.ip_network("172.16.0.0/12"), ipaddress.ip_network("192.168.0.0/16"), ipaddress.ip_network("169.254.0.0/16"), # link-local / AWS IMDS ipaddress.ip_network("127.0.0.0/8"), # loopback ipaddress.ip_network("::1/128"), # IPv6 loopback ipaddress.ip_network("fc00::/7"), # IPv6 ULA ] def _validate_webhook_url(url: str) -> str: """Reject URLs that point to private/reserved addresses (SSRF prevention).""" parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise ValueError("Webhook URL must use http or https") hostname = parsed.hostname if not hostname: raise ValueError("Webhook URL must include a hostname") # Resolve hostname to IP(s) and reject any private/reserved address try: infos = socket.getaddrinfo(hostname, None) for info in infos: raw_ip = info[4][0] try: ip_obj = ipaddress.ip_address(raw_ip) except ValueError: continue for network in _BLOCKED_NETWORKS: if ip_obj in network: raise ValueError( f"Webhook URL resolves to a private/reserved address ({raw_ip}) " "and cannot be used" ) except OSError: # DNS resolution failure — allow (will fail at dispatch time) pass return url class WebhookConfigCreate(BaseModel): name: str url: str secret: str | None = None events: list[str] = [] is_active: bool = True @field_validator("url") @classmethod def url_must_be_external(cls, v: str) -> str: return _validate_webhook_url(v) class WebhookConfigUpdate(BaseModel): name: str | None = None url: str | None = None secret: str | None = None events: list[str] | None = None is_active: bool | None = None @field_validator("url") @classmethod def url_must_be_external(cls, v: str | None) -> str | None: if v is None: return v return _validate_webhook_url(v) class WebhookConfigOut(BaseModel): id: uuid.UUID name: str url: str secret: str | None = None # masked on read events: list[str] is_active: bool created_by: uuid.UUID | None = None last_triggered_at: datetime | None = None failure_count: int created_at: datetime | None = None model_config = ConfigDict(from_attributes=True)