Files
ai-video-fullstack/backend/services/webhooks/security.py
2026-08-07 14:08:49 +08:00

62 lines
1.9 KiB
Python

"""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