mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-28 07:37:56 +09:00
Event subscriptions, the webhook gateway's forward_url and the Discord/Slack/generic alert hooks posted to whatever URL a user typed, from inside the network: the panel's own API on loopback, LAN hosts, cloud metadata. Subscriptions followed redirects and stored the reply, and the gateway forward can be triggered without logging in. safe_http.post refuses non-public addresses, connects to the address it checked so DNS can't swap it afterwards, never follows redirects and caps the body it keeps. SERVERKIT_ALLOW_PRIVATE_WEBHOOKS=1 restores internal targets for operators who need them.
103 lines
3.9 KiB
Python
103 lines
3.9 KiB
Python
"""Outbound HTTP to URLs that users configure (webhooks, notification hooks).
|
|
|
|
A URL a developer types in is a request the panel makes from inside the
|
|
network: to its own API on loopback, to other hosts on the LAN, or to the
|
|
cloud metadata service. :func:`post` refuses those targets, connects to the
|
|
exact address it checked (so a DNS answer can't change between the check
|
|
and the connect), never follows redirects, and caps the response body it
|
|
keeps. ``SERVERKIT_ALLOW_PRIVATE_WEBHOOKS=1`` lifts the address rule for
|
|
operators who deliberately forward to internal hosts.
|
|
"""
|
|
import ipaddress
|
|
import socket
|
|
from urllib.parse import urlsplit, urlunsplit
|
|
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
from app.utils.env import env_bool
|
|
|
|
ALLOW_PRIVATE_ENV = 'SERVERKIT_ALLOW_PRIVATE_WEBHOOKS'
|
|
MAX_RESPONSE_BYTES = 64 * 1024
|
|
|
|
|
|
class UnsafeURLError(ValueError):
|
|
"""The URL points somewhere the panel must not send requests."""
|
|
|
|
|
|
def _is_public(addr):
|
|
if addr.version == 6 and addr.ipv4_mapped:
|
|
addr = addr.ipv4_mapped
|
|
return addr.is_global and not addr.is_multicast
|
|
|
|
|
|
def resolve_public(url):
|
|
"""Validate *url* and return ``(parts, ip)`` — the address to connect to.
|
|
|
|
Raises :class:`UnsafeURLError` for a non-http(s) URL, an unresolvable
|
|
host, or a host with any non-public address.
|
|
"""
|
|
parts = urlsplit(url or '')
|
|
if parts.scheme not in ('http', 'https'):
|
|
raise UnsafeURLError('URL must start with http:// or https://')
|
|
host = parts.hostname
|
|
if not host:
|
|
raise UnsafeURLError('URL has no host')
|
|
try:
|
|
port = parts.port or (443 if parts.scheme == 'https' else 80)
|
|
except ValueError:
|
|
raise UnsafeURLError('URL has an invalid port')
|
|
try:
|
|
infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
|
|
except (socket.gaierror, UnicodeError) as exc:
|
|
raise UnsafeURLError(f'Could not resolve {host}: {exc}')
|
|
addrs = [ipaddress.ip_address(info[4][0].split('%')[0]) for info in infos]
|
|
if not addrs:
|
|
raise UnsafeURLError(f'Could not resolve {host}')
|
|
if not env_bool(ALLOW_PRIVATE_ENV):
|
|
blocked = [a for a in addrs if not _is_public(a)]
|
|
if blocked:
|
|
raise UnsafeURLError(
|
|
f'{host} resolves to a non-public address ({blocked[0]}); set '
|
|
f'{ALLOW_PRIVATE_ENV}=1 to allow internal targets')
|
|
return parts, addrs[0]
|
|
|
|
|
|
class _PinnedAdapter(HTTPAdapter):
|
|
"""Keeps TLS SNI and certificate checks on the hostname while the socket
|
|
goes to the pre-resolved IP in the URL."""
|
|
|
|
def __init__(self, hostname, **kwargs):
|
|
self._hostname = hostname
|
|
super().__init__(**kwargs)
|
|
|
|
def init_poolmanager(self, *args, **kwargs):
|
|
kwargs['server_hostname'] = self._hostname
|
|
kwargs['assert_hostname'] = self._hostname
|
|
super().init_poolmanager(*args, **kwargs)
|
|
|
|
|
|
def post(url, *, timeout=10, max_bytes=MAX_RESPONSE_BYTES, headers=None, **kwargs):
|
|
"""``requests.post`` to a public address only; see the module docstring.
|
|
|
|
The returned response's body is truncated to *max_bytes*.
|
|
"""
|
|
parts, ip = resolve_public(url)
|
|
ip_host = f'[{ip}]' if ip.version == 6 else str(ip)
|
|
netloc = ip_host if parts.port is None else f'{ip_host}:{parts.port}'
|
|
pinned = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
|
|
|
|
host_header = parts.hostname if parts.port is None else f'{parts.hostname}:{parts.port}'
|
|
headers = {**(headers or {}), 'Host': host_header}
|
|
|
|
with requests.Session() as session:
|
|
if parts.scheme == 'https':
|
|
session.mount('https://', _PinnedAdapter(parts.hostname))
|
|
resp = session.post(pinned, headers=headers, timeout=timeout,
|
|
allow_redirects=False, stream=True, **kwargs)
|
|
try:
|
|
resp._content = resp.raw.read(max_bytes, decode_content=True)
|
|
finally:
|
|
resp.close()
|
|
return resp
|