62 lines
1.9 KiB
Python
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
|