"""Validate webhook destinations before every outbound request.""" from __future__ import annotations import asyncio import ipaddress import socket from urllib.parse import urlsplit class UnsafeWebhookUrl(ValueError): """Raised when a URL could reach local or otherwise non-public infrastructure.""" def __init__(self, message: str, *, retryable: bool = False): super().__init__(message) self.retryable = retryable def _public_ip(address: str) -> bool: try: return ipaddress.ip_address(address).is_global except ValueError: return False async def validate_webhook_url(url: str) -> str: """Require HTTPS and ensure every currently resolved address is public.""" value = url.strip() parsed = urlsplit(value) if parsed.scheme != "https" or not parsed.hostname: raise UnsafeWebhookUrl("Webhook URL 必须是完整的 HTTPS 地址") if parsed.username or parsed.password: raise UnsafeWebhookUrl("Webhook URL 不能包含用户名或密码") try: port = parsed.port or 443 except ValueError as exc: raise UnsafeWebhookUrl("Webhook URL 端口无效") from exc try: direct_ip = ipaddress.ip_address(parsed.hostname) except ValueError: direct_ip = None if direct_ip is not None: if not direct_ip.is_global: raise UnsafeWebhookUrl("Webhook URL 不能指向内网或本机地址") return value try: records = await asyncio.to_thread( socket.getaddrinfo, parsed.hostname, port, type=socket.SOCK_STREAM, ) except OSError as exc: raise UnsafeWebhookUrl("Webhook 域名暂时无法解析", retryable=True) from exc addresses = {str(record[4][0]) for record in records} if not addresses or any(not _public_ip(address) for address in addresses): raise UnsafeWebhookUrl("Webhook 域名解析到了内网或非公网地址") return value