fix: scope the connect-time loopback allowance to the configured proxy

The connect-time guard permitted every loopback address so that a locally
configured proxy (proxy: http://127.0.0.1:9050) stayed reachable. But the
connect guard is the only check that media URLs derived from remote metadata
ever face — validate_url sees just the submitted URL — so that blanket
allowance let a remote manifest steer the download subprocess at services
bound to the server's loopback interface, with the response written to the
download directory and served back by the UI.

Permit loopback only at the host:port of a proxy the operator configured,
taken from yt-dlp's proxy option and the *_proxy environment variables.
Nothing is lost: when a proxy is in use yt-dlp hands it the media URL instead
of resolving that URL locally, so the two cases never overlap. Every other
loopback destination now falls under the same is_global policy as the rest.

Reported by m3rl1nu5 (https://github.com/hai135) in GHSA-73g4-qhhq-c32c.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Shnitman
2026-08-04 22:38:17 +03:00
parent 0445f5858b
commit 482381d6b9
3 changed files with 213 additions and 32 deletions
+96 -12
View File
@@ -30,12 +30,23 @@ all of these:
import ipaddress
import logging
import socket
import urllib.request
from urllib.parse import urlsplit
log = logging.getLogger('url_guard')
_ALLOWED_SCHEMES = ('http', 'https')
# Ports to assume when a configured proxy URL omits one, per proxy scheme.
_PROXY_DEFAULT_PORTS = {
'http': 80,
'https': 443,
'socks4': 1080,
'socks4a': 1080,
'socks5': 1080,
'socks5h': 1080,
}
# Hostnames that must be blocked without needing a lookup. ``localhost`` and any
# subdomain of it are conventionally loopback, and the GCP metadata name is a
# well-known SSRF target that may resolve via a resolver we don't control.
@@ -68,40 +79,107 @@ def _address_is_global(addr: str) -> bool:
return ip is not None and ip.is_global
def _address_allowed_at_connect(addr: str) -> bool:
def _address_allowed_at_connect(addr: str, allow_loopback: bool = False) -> bool:
"""True if *addr* may be connected to at download time.
Permits global addresses and loopback — loopback so that locally-configured
proxies (e.g. ``proxy: http://127.0.0.1:9050``) keep working. Blocks the SSRF
targets that matter: link-local (cloud metadata at 169.254.169.254), private
(RFC1918), unique-local and every other non-global, non-loopback range.
Permits global addresses only. Loopback is permitted just for the specific
host:port of an operator-configured proxy (see ``_loopback_endpoint_allowed``),
never as a blanket rule: media URLs that yt-dlp derives from a remote manifest
are attacker-controlled and reach this policy without passing ``validate_url``,
so a general loopback allowance would let a hostile playlist read any service
on the server's loopback interface. Blocks link-local (cloud metadata at
169.254.169.254), private (RFC1918), unique-local and every other non-global
range.
"""
ip = _normalise_ip(addr)
return ip is not None and (ip.is_global or ip.is_loopback)
if ip is None:
return False
return ip.is_global or (allow_loopback and ip.is_loopback)
def _proxy_endpoint(proxy_url: str):
"""Parse a proxy URL into a ``(hostname, port)`` pair, or ``None`` if it has
no usable host. Used to scope the loopback allowance to that endpoint alone."""
if not isinstance(proxy_url, str) or not proxy_url.strip():
return None
candidate = proxy_url.strip()
if '://' not in candidate:
# Bare host:port, as accepted by the *_proxy environment variables.
candidate = '//' + candidate
try:
parts = urlsplit(candidate)
hostname, port = parts.hostname, parts.port
except ValueError:
return None
if not hostname:
return None
if port is None:
port = _PROXY_DEFAULT_PORTS.get(parts.scheme.lower())
return (hostname.rstrip('.').lower(), port)
def _collect_proxy_endpoints(proxy_urls) -> set:
"""Endpoints of every proxy this download may legitimately dial: the explicit
yt-dlp ``proxy`` option plus the ``*_proxy`` environment variables yt-dlp falls
back to. All are operator-configured, unlike the URLs inside fetched media."""
candidates = list(proxy_urls) + list(urllib.request.getproxies().values())
return {ep for ep in map(_proxy_endpoint, candidates) if ep is not None}
# Captured at import so re-installing the guard never wraps the wrapper.
_real_getaddrinfo = socket.getaddrinfo
# Populated by install_socket_guard; empty means no loopback destination is allowed.
_allowed_loopback_endpoints: set = set()
def _normalise_port(port):
if isinstance(port, str):
try:
return int(port)
except ValueError:
try:
return socket.getservbyname(port)
except OSError:
return None
return port
def _loopback_endpoint_allowed(host, port) -> bool:
if not _allowed_loopback_endpoints or host is None:
return False
return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_loopback_endpoints
def _guarded_getaddrinfo(host, *args, **kwargs):
results = _real_getaddrinfo(host, *args, **kwargs)
allowed = [r for r in results if _address_allowed_at_connect(r[4][0])]
# Mirrors getaddrinfo(host, port, ...): port is the first optional argument.
port = args[0] if args else kwargs.get('port')
allow_loopback = _loopback_endpoint_allowed(host, port)
allowed = [r for r in results if _address_allowed_at_connect(r[4][0], allow_loopback)]
if not allowed:
raise socket.gaierror(f'Refusing to connect to non-global address for host {host!r}')
return allowed
def install_socket_guard(allow_private: bool = False) -> None:
def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
"""Enforce the no-internal-hosts policy at actual connection time.
``validate_url`` only checks the *submitted* URL string; yt-dlp then follows
HTTP redirects and resolves media URLs from remote metadata without
re-validating them. Installing this in the download subprocess re-checks
every resolved address at connect time, covering redirects and DNS rebinding
for any networking backend that resolves through Python's socket module
(urllib, requests). Native resolvers — notably curl_cffi/libcurl used by
``--impersonate`` — bypass this and rely on network isolation as the backstop.
every resolved address at connect time, covering redirects, DNS rebinding and
manifest-derived media URLs for any networking backend that resolves through
Python's socket module (urllib, requests). Native resolvers — notably
curl_cffi/libcurl used by ``--impersonate`` — bypass this and rely on network
isolation as the backstop.
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
the ``*_proxy`` environment variables are picked up automatically). A proxy on
loopback is reachable at its own host:port, and nothing else on loopback is.
That costs proxied setups nothing: yt-dlp resolves the proxy itself at exactly
that host:port, and a media URL is either handed to the proxy unresolved or
resolved on its own merits — never inheriting the proxy's allowance.
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
installed at all, so proxy/VPN setups that route through private or Fake-IP
@@ -109,6 +187,12 @@ def install_socket_guard(allow_private: bool = False) -> None:
"""
if allow_private:
return
_allowed_loopback_endpoints.clear()
_allowed_loopback_endpoints.update(_collect_proxy_endpoints(proxy_urls))
for host, port in sorted(_allowed_loopback_endpoints, key=lambda ep: (ep[0], ep[1] or 0)):
ip = _normalise_ip(host)
if ip is not None and ip.is_loopback:
log.info(f'Allowing connections to configured loopback proxy {host}:{port}')
socket.getaddrinfo = _guarded_getaddrinfo