Files
Juan Denis a8c5237d32 Refuse internal targets for user-set webhook URLs
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.
2026-10-10 06:52:32 -04:00

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