mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 21:45:04 +00:00
de57484fc9
Scoping the connect-time allowance to the configured proxy kept the exception tied to loopback, so a proxy anywhere else internal — the common case of a socks5 or HTTP proxy on the LAN — was refused with "Refusing to connect to non-global address". The only workaround was ALLOW_PRIVATE_ADDRESSES, which switches the whole guard off, a far larger concession than the setup needs. The allowance was never really about loopback: it is about the operator having named this host:port as a proxy. Widen it to any address at a configured proxy endpoint and nothing is given away, because the match is on the configured host string rather than the resolved address — a hostile media URL that resolves to the proxy's address under another name gets no allowance, and one that names the proxy endpoint itself only reaches the proxy. Every other internal destination stays refused. Also log each configured proxy endpoint, so the next report of this shape can be diagnosed from the log rather than from the guard's source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
356 lines
16 KiB
Python
356 lines
16 KiB
Python
"""Tests for the SSRF URL guard (``url_guard.validate_url``)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import socket
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
import url_guard
|
|
from url_guard import (
|
|
validate_url,
|
|
_address_allowed_at_connect,
|
|
_address_is_global,
|
|
_guarded_getaddrinfo,
|
|
_proxy_endpoint,
|
|
install_socket_guard,
|
|
)
|
|
|
|
|
|
def _addrinfo(*addrs, family=socket.AF_INET):
|
|
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (addr, 0)) for addr in addrs]
|
|
|
|
|
|
class NonUrlInputTests(unittest.TestCase):
|
|
"""Bare IDs and yt-dlp search/extractor prefixes must pass untouched."""
|
|
|
|
def test_bare_video_id_allowed(self):
|
|
self.assertIsNone(validate_url("dQw4w9WgXcQ"))
|
|
|
|
def test_ytsearch_prefix_allowed(self):
|
|
self.assertIsNone(validate_url("ytsearch:some song"))
|
|
|
|
def test_empty_string_allowed(self):
|
|
self.assertIsNone(validate_url(""))
|
|
|
|
def test_non_string_rejected(self):
|
|
self.assertIsNotNone(validate_url(None))
|
|
|
|
|
|
class SchemeTests(unittest.TestCase):
|
|
def test_file_scheme_blocked(self):
|
|
self.assertIsNotNone(validate_url("file:///etc/passwd"))
|
|
|
|
def test_ftp_scheme_blocked(self):
|
|
self.assertIsNotNone(validate_url("ftp://example.com/x"))
|
|
|
|
def test_data_scheme_blocked(self):
|
|
self.assertIsNotNone(validate_url("data://text/plain;base64,AAAA"))
|
|
|
|
|
|
class HostnameBlocklistTests(unittest.TestCase):
|
|
def test_localhost_blocked_without_lookup(self):
|
|
with mock.patch("url_guard.socket.getaddrinfo") as gai:
|
|
self.assertIsNotNone(validate_url("http://localhost:8080/x"))
|
|
gai.assert_not_called()
|
|
|
|
def test_localhost_subdomain_blocked(self):
|
|
self.assertIsNotNone(validate_url("http://foo.localhost/x"))
|
|
|
|
def test_gcp_metadata_name_blocked(self):
|
|
self.assertIsNotNone(validate_url("http://metadata.google.internal/x"))
|
|
|
|
|
|
class AddressResolutionTests(unittest.TestCase):
|
|
def _validate_with_addrs(self, url, *addrs, family=socket.AF_INET):
|
|
with mock.patch("url_guard.socket.getaddrinfo", return_value=_addrinfo(*addrs, family=family)):
|
|
return validate_url(url)
|
|
|
|
def test_public_https_allowed(self):
|
|
self.assertIsNone(self._validate_with_addrs("https://youtube.com/watch?v=x", "142.250.1.1"))
|
|
|
|
def test_public_http_allowed(self):
|
|
self.assertIsNone(self._validate_with_addrs("http://example.com/x", "93.184.216.34"))
|
|
|
|
def test_link_local_metadata_blocked(self):
|
|
self.assertIsNotNone(self._validate_with_addrs("http://metadata/x", "169.254.169.254"))
|
|
|
|
def test_loopback_ipv4_blocked(self):
|
|
self.assertIsNotNone(self._validate_with_addrs("http://127.0.0.1/x", "127.0.0.1"))
|
|
|
|
def test_private_rfc1918_blocked(self):
|
|
self.assertIsNotNone(self._validate_with_addrs("http://intranet/x", "10.0.0.5"))
|
|
|
|
def test_decimal_ip_form_blocked(self):
|
|
# 2852039166 == 169.254.169.254; the OS resolver normalizes it.
|
|
self.assertIsNotNone(self._validate_with_addrs("http://2852039166/x", "169.254.169.254"))
|
|
|
|
def test_ipv6_loopback_blocked(self):
|
|
self.assertIsNotNone(
|
|
self._validate_with_addrs("http://[::1]/x", "::1", family=socket.AF_INET6)
|
|
)
|
|
|
|
def test_ipv4_mapped_ipv6_metadata_blocked(self):
|
|
self.assertIsNotNone(
|
|
self._validate_with_addrs(
|
|
"http://evil/x", "::ffff:169.254.169.254", family=socket.AF_INET6
|
|
)
|
|
)
|
|
|
|
def test_mixed_public_and_private_blocked(self):
|
|
# If any resolved address is internal, reject the whole URL.
|
|
self.assertIsNotNone(self._validate_with_addrs("http://mixed/x", "142.250.1.1", "127.0.0.1"))
|
|
|
|
def test_resolution_failure_is_rejected(self):
|
|
# Fail closed: an unresolvable host cannot be verified as non-internal.
|
|
with mock.patch("url_guard.socket.getaddrinfo", side_effect=socket.gaierror):
|
|
self.assertIsNotNone(validate_url("http://does-not-resolve.example/x"))
|
|
|
|
|
|
class ConnectAddressPolicyTests(unittest.TestCase):
|
|
"""Connect-time policy: allow global, plus anything at a destination the
|
|
caller has established is the operator's configured proxy."""
|
|
|
|
def test_global_allowed(self):
|
|
self.assertTrue(_address_allowed_at_connect("142.250.1.1"))
|
|
|
|
def test_loopback_blocked_by_default(self):
|
|
# A blanket loopback allowance is what let manifest-derived media URLs
|
|
# reach services on the server's own loopback interface.
|
|
self.assertFalse(_address_allowed_at_connect("127.0.0.1"))
|
|
self.assertFalse(_address_allowed_at_connect("::1"))
|
|
|
|
def test_loopback_allowed_only_when_opted_in(self):
|
|
self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_proxy_endpoint=True))
|
|
self.assertTrue(_address_allowed_at_connect("::1", is_proxy_endpoint=True))
|
|
|
|
def test_proxy_opt_in_covers_any_internal_range(self):
|
|
# A proxy is just as legitimately on the LAN or a VPN range as on
|
|
# loopback (#1055): the allowance follows the operator's configured
|
|
# endpoint, not a particular address family.
|
|
self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_proxy_endpoint=True))
|
|
self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_proxy_endpoint=True))
|
|
self.assertTrue(_address_allowed_at_connect("fd00::1", is_proxy_endpoint=True))
|
|
|
|
def test_opt_in_still_rejects_non_addresses(self):
|
|
self.assertFalse(_address_allowed_at_connect("not-an-ip", is_proxy_endpoint=True))
|
|
|
|
def test_link_local_metadata_blocked(self):
|
|
self.assertFalse(_address_allowed_at_connect("169.254.169.254"))
|
|
|
|
def test_private_blocked(self):
|
|
self.assertFalse(_address_allowed_at_connect("10.0.0.5"))
|
|
self.assertFalse(_address_allowed_at_connect("192.168.1.10"))
|
|
|
|
def test_ipv4_mapped_metadata_blocked(self):
|
|
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
|
|
|
|
|
|
class TunnelledIPv4Tests(unittest.TestCase):
|
|
"""IPv6 transition forms that carry an IPv4 address the outer address hides.
|
|
|
|
``is_global`` looks only at the outer address, so a form that tunnels an
|
|
internal IPv4 has to be unwrapped before it is judged (GHSA-5mq5-qr7m-f4wx).
|
|
"""
|
|
|
|
def test_nat64_well_known_prefix_blocked(self):
|
|
# 2000::/3 global unicast on its face; carries the metadata address.
|
|
self.assertFalse(_address_is_global("64:ff9b::a9fe:a9fe"))
|
|
self.assertFalse(_address_is_global("64:ff9b::7f00:1"))
|
|
self.assertFalse(_address_allowed_at_connect("64:ff9b::a9fe:a9fe"))
|
|
|
|
def test_nat64_carrying_a_public_address_allowed(self):
|
|
self.assertTrue(_address_is_global("64:ff9b::8.8.8.8"))
|
|
|
|
def test_ipv4_compatible_form_blocked(self):
|
|
# The deprecated ::/96 form, likewise global-looking to is_global.
|
|
self.assertFalse(_address_is_global("::a9fe:a9fe"))
|
|
self.assertFalse(_address_allowed_at_connect("::a9fe:a9fe"))
|
|
|
|
def test_sixtofour_and_teredo_stay_blocked(self):
|
|
# Python rejects these ranges wholesale. Unwrapping must not promote a
|
|
# blocked address to an allowed one just because the payload is global.
|
|
self.assertFalse(_address_is_global("2002:a9fe:a9fe::"))
|
|
self.assertFalse(_address_is_global("2002:0808:0808::"))
|
|
self.assertFalse(_address_is_global("2001:0:4136:e378:8000:63bf:3fff:fdd2"))
|
|
|
|
def test_plain_addresses_unaffected(self):
|
|
self.assertTrue(_address_is_global("142.250.1.1"))
|
|
self.assertTrue(_address_is_global("2607:f8b0:4004:c07::64"))
|
|
self.assertFalse(_address_is_global("not-an-ip"))
|
|
|
|
def test_tunnelled_form_blocked_at_ingress(self):
|
|
with mock.patch(
|
|
"url_guard.socket.getaddrinfo",
|
|
return_value=_addrinfo("64:ff9b::a9fe:a9fe", family=socket.AF_INET6),
|
|
):
|
|
self.assertIsNotNone(validate_url("http://nat64.example/x"))
|
|
|
|
|
|
class ProxyEndpointParsingTests(unittest.TestCase):
|
|
def test_explicit_port(self):
|
|
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
|
|
|
def test_default_port_per_scheme(self):
|
|
self.assertEqual(_proxy_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080))
|
|
self.assertEqual(_proxy_endpoint("http://127.0.0.1"), ("127.0.0.1", 80))
|
|
|
|
def test_bare_host_port(self):
|
|
self.assertEqual(_proxy_endpoint("127.0.0.1:8080"), ("127.0.0.1", 8080))
|
|
|
|
def test_hostname_lowercased(self):
|
|
self.assertEqual(_proxy_endpoint("http://LocalHost.:9050"), ("localhost", 9050))
|
|
|
|
def test_ipv6_literal(self):
|
|
self.assertEqual(_proxy_endpoint("http://[::1]:9050"), ("::1", 9050))
|
|
|
|
def test_empty_and_invalid(self):
|
|
self.assertIsNone(_proxy_endpoint(""))
|
|
self.assertIsNone(_proxy_endpoint(" "))
|
|
self.assertIsNone(_proxy_endpoint(None))
|
|
self.assertIsNone(_proxy_endpoint("http://"))
|
|
|
|
|
|
class GuardedGetaddrinfoTests(unittest.TestCase):
|
|
def setUp(self):
|
|
# Default state: no proxy configured, so no loopback destination allowed.
|
|
saved = set(url_guard._allowed_proxy_endpoints)
|
|
url_guard._allowed_proxy_endpoints = set()
|
|
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
|
|
|
def test_internal_only_raises(self):
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")):
|
|
with self.assertRaises(socket.gaierror):
|
|
_guarded_getaddrinfo("metadata", 80)
|
|
|
|
def test_filters_internal_keeps_global(self):
|
|
# Split-horizon rebinding: keep the public address, drop the internal one.
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("142.250.1.1", "10.0.0.1")):
|
|
results = _guarded_getaddrinfo("mixed", 80)
|
|
self.assertEqual([r[4][0] for r in results], ["142.250.1.1"])
|
|
|
|
def test_loopback_blocked_without_matching_proxy(self):
|
|
# The advisory case: an m3u8 segment URL pointing at a loopback service.
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
|
with self.assertRaises(socket.gaierror):
|
|
_guarded_getaddrinfo("127.0.0.1", 9999)
|
|
|
|
def test_loopback_allowed_at_configured_proxy_endpoint(self):
|
|
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
|
results = _guarded_getaddrinfo("127.0.0.1", 9050)
|
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
|
|
|
def test_loopback_blocked_at_other_port_on_proxy_host(self):
|
|
# Same host as the proxy, different port: still off limits.
|
|
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
|
with self.assertRaises(socket.gaierror):
|
|
_guarded_getaddrinfo("127.0.0.1", 9999)
|
|
|
|
def test_proxy_reachable_by_hostname(self):
|
|
url_guard._allowed_proxy_endpoints = {("localhost", 9050)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
|
results = _guarded_getaddrinfo("localhost", 9050)
|
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
|
|
|
def test_string_port_is_normalised(self):
|
|
url_guard._allowed_proxy_endpoints = {("127.0.0.1", 9050)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")):
|
|
results = _guarded_getaddrinfo("127.0.0.1", "9050")
|
|
self.assertEqual([r[4][0] for r in results], ["127.0.0.1"])
|
|
|
|
def test_lan_proxy_reachable(self):
|
|
# #1055: a socks5 proxy on the LAN, refused while the allowance was
|
|
# loopback-only, which pushed operators to ALLOW_PRIVATE_ADDRESSES.
|
|
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
|
results = _guarded_getaddrinfo("10.1.20.30", 1080)
|
|
self.assertEqual([r[4][0] for r in results], ["10.1.20.30"])
|
|
|
|
def test_other_lan_host_still_blocked(self):
|
|
# The allowance is the proxy's endpoint, not its subnet.
|
|
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.31")):
|
|
with self.assertRaises(socket.gaierror):
|
|
_guarded_getaddrinfo("10.1.20.31", 1080)
|
|
|
|
def test_proxy_address_not_borrowable_by_another_host(self):
|
|
# Matching is on the configured host string: a manifest URL that resolves
|
|
# to the proxy's address under its own name gets no allowance.
|
|
url_guard._allowed_proxy_endpoints = {("10.1.20.30", 1080)}
|
|
with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("10.1.20.30")):
|
|
with self.assertRaises(socket.gaierror):
|
|
_guarded_getaddrinfo("evil.example", 1080)
|
|
|
|
|
|
class AllowPrivateBypassTests(unittest.TestCase):
|
|
"""ALLOW_PRIVATE_ADDRESSES: trusted proxy/VPN environments opt out of the
|
|
SSRF address checks (e.g. Fake-IP clients that resolve to 198.18.0.0/15)."""
|
|
|
|
def test_internal_address_allowed_when_bypassed(self):
|
|
# Fake-IP benchmarking range that is_global rejects by default.
|
|
self.assertIsNone(validate_url("http://www.youtube.com/x", allow_private=True))
|
|
|
|
def test_private_host_allowed_when_bypassed(self):
|
|
# No DNS lookup needed: the bypass returns before resolution.
|
|
with mock.patch("url_guard.socket.getaddrinfo") as gai:
|
|
self.assertIsNone(validate_url("http://192.168.1.1/x", allow_private=True))
|
|
gai.assert_not_called()
|
|
|
|
def test_scheme_still_enforced_when_bypassed(self):
|
|
self.assertIsNotNone(validate_url("file:///etc/passwd", allow_private=True))
|
|
|
|
def test_socket_guard_not_installed_when_bypassed(self):
|
|
original = socket.getaddrinfo
|
|
try:
|
|
install_socket_guard(allow_private=True)
|
|
self.assertIs(socket.getaddrinfo, original)
|
|
finally:
|
|
socket.getaddrinfo = original
|
|
|
|
|
|
class InstallSocketGuardTests(unittest.TestCase):
|
|
def setUp(self):
|
|
original, saved = socket.getaddrinfo, set(url_guard._allowed_proxy_endpoints)
|
|
self.addCleanup(lambda: setattr(socket, "getaddrinfo", original))
|
|
self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved))
|
|
# Keep the host's own environment out of the assertions below.
|
|
patcher = mock.patch("url_guard.urllib.request.getproxies", return_value={})
|
|
self.getproxies = patcher.start()
|
|
self.addCleanup(patcher.stop)
|
|
|
|
def test_install_replaces_and_is_idempotent(self):
|
|
install_socket_guard()
|
|
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
|
# Re-installing must not wrap the wrapper (real fn captured at import).
|
|
install_socket_guard()
|
|
self.assertIs(socket.getaddrinfo, url_guard._guarded_getaddrinfo)
|
|
|
|
def test_no_proxy_means_no_loopback_allowance(self):
|
|
install_socket_guard()
|
|
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
|
|
|
def test_explicit_proxy_is_registered(self):
|
|
install_socket_guard(proxy_urls=("socks5://127.0.0.1:9050",))
|
|
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 9050)})
|
|
|
|
def test_unset_proxy_option_is_ignored(self):
|
|
# ytdl_opts.get('proxy') is None when the operator configured no proxy.
|
|
install_socket_guard(proxy_urls=(None,))
|
|
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
|
|
|
def test_environment_proxies_are_registered(self):
|
|
self.getproxies.return_value = {"http": "http://127.0.0.1:8080"}
|
|
install_socket_guard()
|
|
self.assertEqual(url_guard._allowed_proxy_endpoints, {("127.0.0.1", 8080)})
|
|
|
|
def test_endpoints_reset_between_installs(self):
|
|
install_socket_guard(proxy_urls=("http://127.0.0.1:8080",))
|
|
install_socket_guard(proxy_urls=(None,))
|
|
self.assertEqual(url_guard._allowed_proxy_endpoints, set())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|