diff --git a/app/tests/test_url_guard.py b/app/tests/test_url_guard.py index 7279616..54de12a 100644 --- a/app/tests/test_url_guard.py +++ b/app/tests/test_url_guard.py @@ -12,7 +12,7 @@ from url_guard import ( _address_allowed_at_connect, _address_is_global, _guarded_getaddrinfo, - _proxy_endpoint, + _url_endpoint, install_socket_guard, ) @@ -121,19 +121,19 @@ class ConnectAddressPolicyTests(unittest.TestCase): 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)) + self.assertTrue(_address_allowed_at_connect("127.0.0.1", is_allowed_endpoint=True)) + self.assertTrue(_address_allowed_at_connect("::1", is_allowed_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)) + self.assertTrue(_address_allowed_at_connect("10.1.20.30", is_allowed_endpoint=True)) + self.assertTrue(_address_allowed_at_connect("192.168.1.10", is_allowed_endpoint=True)) + self.assertTrue(_address_allowed_at_connect("fd00::1", is_allowed_endpoint=True)) def test_opt_in_still_rejects_non_addresses(self): - self.assertFalse(_address_allowed_at_connect("not-an-ip", is_proxy_endpoint=True)) + self.assertFalse(_address_allowed_at_connect("not-an-ip", is_allowed_endpoint=True)) def test_link_local_metadata_blocked(self): self.assertFalse(_address_allowed_at_connect("169.254.169.254")) @@ -187,36 +187,36 @@ class TunnelledIPv4Tests(unittest.TestCase): self.assertIsNotNone(validate_url("http://nat64.example/x")) -class ProxyEndpointParsingTests(unittest.TestCase): +class EndpointParsingTests(unittest.TestCase): def test_explicit_port(self): - self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050)) + self.assertEqual(_url_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)) + self.assertEqual(_url_endpoint("socks5://127.0.0.1"), ("127.0.0.1", 1080)) + self.assertEqual(_url_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)) + self.assertEqual(_url_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)) + self.assertEqual(_url_endpoint("http://LocalHost.:9050"), ("localhost", 9050)) def test_ipv6_literal(self): - self.assertEqual(_proxy_endpoint("http://[::1]:9050"), ("::1", 9050)) + self.assertEqual(_url_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://")) + self.assertIsNone(_url_endpoint("")) + self.assertIsNone(_url_endpoint(" ")) + self.assertIsNone(_url_endpoint(None)) + self.assertIsNone(_url_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)) + saved = set(url_guard._allowed_endpoints) + url_guard._allowed_endpoints = set() + self.addCleanup(lambda: setattr(url_guard, "_allowed_endpoints", saved)) def test_internal_only_raises(self): with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("169.254.169.254")): @@ -235,27 +235,27 @@ class GuardedGetaddrinfoTests(unittest.TestCase): 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)} + def test_loopback_allowed_at_configured_url_endpoint(self): + url_guard._allowed_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)} + url_guard._allowed_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)} + url_guard._allowed_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)} + url_guard._allowed_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"]) @@ -263,22 +263,38 @@ class GuardedGetaddrinfoTests(unittest.TestCase): 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)} + url_guard._allowed_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)} + url_guard._allowed_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_pot_provider_reachable_on_loopback(self): + # #1064: the bundled PO token provider listens on loopback, and blocking + # it left every default install downloading YouTube without a token. + url_guard._allowed_endpoints = {("127.0.0.1", 4416)} + with mock.patch("url_guard._real_getaddrinfo", return_value=_addrinfo("127.0.0.1")): + results = _guarded_getaddrinfo("127.0.0.1", 4416) + self.assertEqual([r[4][0] for r in results], ["127.0.0.1"]) + + def test_other_loopback_service_still_blocked(self): + # MeTube's own port is one hop away on the same interface: allowing the + # token provider must not allow the rest of loopback. + url_guard._allowed_endpoints = {("127.0.0.1", 4416)} + 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", 8081) + 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)} + url_guard._allowed_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) @@ -312,9 +328,9 @@ class AllowPrivateBypassTests(unittest.TestCase): class InstallSocketGuardTests(unittest.TestCase): def setUp(self): - original, saved = socket.getaddrinfo, set(url_guard._allowed_proxy_endpoints) + original, saved = socket.getaddrinfo, set(url_guard._allowed_endpoints) self.addCleanup(lambda: setattr(socket, "getaddrinfo", original)) - self.addCleanup(lambda: setattr(url_guard, "_allowed_proxy_endpoints", saved)) + self.addCleanup(lambda: setattr(url_guard, "_allowed_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() @@ -329,26 +345,45 @@ class InstallSocketGuardTests(unittest.TestCase): def test_no_proxy_means_no_loopback_allowance(self): install_socket_guard() - self.assertEqual(url_guard._allowed_proxy_endpoints, set()) + self.assertEqual(url_guard._allowed_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)}) + self.assertEqual(url_guard._allowed_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()) + self.assertEqual(url_guard._allowed_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)}) + self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 8080)}) + + def test_service_url_is_registered(self): + install_socket_guard(service_urls=("http://127.0.0.1:4416",)) + self.assertEqual(url_guard._allowed_endpoints, {("127.0.0.1", 4416)}) + + def test_service_and_proxy_endpoints_coexist(self): + install_socket_guard( + proxy_urls=("socks5://10.1.20.30:1080",), + service_urls=("http://127.0.0.1:4416",), + ) + self.assertEqual( + url_guard._allowed_endpoints, + {("10.1.20.30", 1080), ("127.0.0.1", 4416)}, + ) + + def test_service_urls_reset_between_installs(self): + install_socket_guard(service_urls=("http://127.0.0.1:4416",)) + install_socket_guard() + self.assertEqual(url_guard._allowed_endpoints, set()) 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()) + self.assertEqual(url_guard._allowed_endpoints, set()) if __name__ == "__main__": diff --git a/app/tests/test_ytdl_utils.py b/app/tests/test_ytdl_utils.py index f207e64..358a487 100644 --- a/app/tests/test_ytdl_utils.py +++ b/app/tests/test_ytdl_utils.py @@ -77,6 +77,7 @@ from ytdl import ( MusicMetadataPreProcessor, _compact_persisted_entry, _convert_srt_to_txt_file, + _pot_provider_urls, _AlbumArtistPostProcessor, _resolve_outtmpl_fields, _sanitize_entry_for_pickle, @@ -1071,5 +1072,44 @@ class ShortTitleForFailedUrlTests(unittest.TestCase): self.assertEqual(_short_title_for_failed_url(malformed), malformed) +class PotProviderUrlsTests(unittest.TestCase): + """#1064: the connect-time guard must let the download reach the PO token + provider, so it has to know every endpoint yt-dlp might dial for one.""" + + def test_bundled_provider_by_default(self): + self.assertEqual(_pot_provider_urls({}), ("http://127.0.0.1:4416",)) + + def test_configured_base_url_is_added(self): + urls = _pot_provider_urls({ + "extractor_args": {"youtubepot-bgutilhttp": {"base_url": ["http://pot:4416"]}}, + }) + # The bundled server runs regardless, so both stay reachable. + self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416")) + + def test_deprecated_base_url_arg_is_honoured(self): + urls = _pot_provider_urls({ + "extractor_args": {"youtube": {"getpot_bgutil_baseurl": ["http://pot:4416"]}}, + }) + self.assertEqual(urls, ("http://127.0.0.1:4416", "http://pot:4416")) + + def test_unrelated_extractor_args_are_ignored(self): + urls = _pot_provider_urls({ + "extractor_args": {"youtube": {"player_client": ["web"]}}, + }) + self.assertEqual(urls, ("http://127.0.0.1:4416",)) + + def test_malformed_extractor_args_do_not_raise(self): + # YTDL_OPTIONS is operator-supplied JSON and reaches here unvalidated. + for opts in ( + {"extractor_args": None}, + {"extractor_args": "youtube:player_client=web"}, + {"extractor_args": {"youtubepot-bgutilhttp": "http://pot:4416"}}, + {"extractor_args": {"youtubepot-bgutilhttp": {"base_url": []}}}, + ): + with self.subTest(opts=opts): + self.assertEqual(_pot_provider_urls(opts), ("http://127.0.0.1:4416",)) + + if __name__ == "__main__": + unittest.main() diff --git a/app/url_guard.py b/app/url_guard.py index 9dfea77..cb2c524 100644 --- a/app/url_guard.py +++ b/app/url_guard.py @@ -37,8 +37,8 @@ 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 = { +# Ports to assume when a configured endpoint URL omits one, per scheme. +_SCHEME_DEFAULT_PORTS = { 'http': 80, 'https': 443, 'socks4': 1080, @@ -129,31 +129,32 @@ def _address_is_global(addr: str) -> bool: return bool(ips) and all(ip.is_global for ip in ips) -def _address_allowed_at_connect(addr: str, is_proxy_endpoint: bool = False) -> bool: +def _address_allowed_at_connect(addr: str, is_allowed_endpoint: bool = False) -> bool: """True if *addr* may be connected to at download time. Permits global addresses, and anything at all when the destination is an - operator-configured proxy (see ``_is_proxy_endpoint``). Internal addresses - are otherwise refused with no blanket exception: media URLs that yt-dlp - derives from a remote manifest are attacker-controlled and reach this policy - without passing ``validate_url``, so any range opened here is a range a - hostile playlist can read from the server's own network. Blocks link-local + endpoint the operator or the image configured — a proxy, or the PO token + provider (see ``_is_allowed_endpoint``). Internal addresses are otherwise + refused with no blanket exception: media URLs that yt-dlp derives from a + remote manifest are attacker-controlled and reach this policy without passing + ``validate_url``, so any range opened here is a range a hostile playlist can + read from the server's own network. Blocks link-local (cloud metadata at 169.254.169.254), private (RFC1918), loopback, unique-local and every other non-global range. """ ips = _ips_to_judge(addr) if not ips: return False - return is_proxy_endpoint or all(ip.is_global for ip in ips) + return is_allowed_endpoint or all(ip.is_global for ip in ips) -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 internal-address allowance to that endpoint - alone.""" - if not isinstance(proxy_url, str) or not proxy_url.strip(): +def _url_endpoint(url: str): + """Parse a configured URL into a ``(hostname, port)`` pair, or ``None`` if it + has no usable host. Used to scope the internal-address allowance to that + endpoint alone.""" + if not isinstance(url, str) or not url.strip(): return None - candidate = proxy_url.strip() + candidate = url.strip() if '://' not in candidate: # Bare host:port, as accepted by the *_proxy environment variables. candidate = '//' + candidate @@ -165,23 +166,28 @@ def _proxy_endpoint(proxy_url: str): if not hostname: return None if port is None: - port = _PROXY_DEFAULT_PORTS.get(parts.scheme.lower()) + port = _SCHEME_DEFAULT_PORTS.get(parts.scheme.lower()) return (hostname.rstrip('.').lower(), port) +def _endpoints(urls) -> set: + """The parseable endpoints among *urls*, dropping any that name no host.""" + return {ep for ep in map(_url_endpoint, urls) if ep is not None} + + 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} + return _endpoints(candidates) # Captured at import so re-installing the guard never wraps the wrapper. _real_getaddrinfo = socket.getaddrinfo # Populated by install_socket_guard; empty means no internal destination is allowed. -_allowed_proxy_endpoints: set = set() +_allowed_endpoints: set = set() def _normalise_port(port): @@ -196,28 +202,29 @@ def _normalise_port(port): return port -def _is_proxy_endpoint(host, port) -> bool: - """True when host:port is exactly an endpoint the operator configured as a - proxy. Matching is on the configured host *string*, not on the resolved - address, so a hostile media URL cannot borrow the allowance by resolving to - the same address under a different name.""" - if not _allowed_proxy_endpoints or host is None: +def _is_allowed_endpoint(host, port) -> bool: + """True when host:port is exactly one of the endpoints this download is + configured to dial — a proxy or the PO token provider. Matching is on the + configured host *string*, not on the resolved address, so a hostile media URL + cannot borrow the allowance by resolving to the same address under a + different name.""" + if not _allowed_endpoints or host is None: return False - return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_proxy_endpoints + return (str(host).rstrip('.').lower(), _normalise_port(port)) in _allowed_endpoints def _guarded_getaddrinfo(host, *args, **kwargs): results = _real_getaddrinfo(host, *args, **kwargs) # Mirrors getaddrinfo(host, port, ...): port is the first optional argument. port = args[0] if args else kwargs.get('port') - is_proxy = _is_proxy_endpoint(host, port) - allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_proxy)] + is_configured = _is_allowed_endpoint(host, port) + allowed = [r for r in results if _address_allowed_at_connect(r[4][0], is_configured)] 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, proxy_urls=()) -> None: +def install_socket_guard(allow_private: bool = False, proxy_urls=(), service_urls=()) -> None: """Enforce the no-internal-hosts policy at actual connection time. ``validate_url`` only checks the *submitted* URL string; yt-dlp then follows @@ -230,12 +237,16 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None: 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 is - reachable at its own host:port wherever it lives — loopback, the LAN, a VPN - range — and nothing else internal is. That costs proxied setups nothing and - gives away 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. + the ``*_proxy`` environment variables are picked up automatically), and + *service_urls* the helper services the download itself has to reach — the PO + token provider this image ships and starts on loopback. Each is reachable at + its own host:port wherever it lives — loopback, the LAN, a VPN range — and + nothing else internal is. That costs those setups nothing and gives away + little: yt-dlp dials each 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 allowance. A hostile media URL naming an allowed endpoint + reaches only what is listening there: a proxy that would have fetched it + anyway, or a token server with two endpoints and nothing to read. 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 @@ -243,10 +254,13 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None: """ if allow_private: return - _allowed_proxy_endpoints.clear() - _allowed_proxy_endpoints.update(_collect_proxy_endpoints(proxy_urls)) - for host, port in sorted(_allowed_proxy_endpoints, key=lambda ep: (ep[0], ep[1] or 0)): - log.info(f'Allowing connections to configured proxy {host}:{port}') + proxy_endpoints = _collect_proxy_endpoints(proxy_urls) + service_endpoints = _endpoints(service_urls) - proxy_endpoints + _allowed_endpoints.clear() + _allowed_endpoints.update(proxy_endpoints | service_endpoints) + for label, endpoints in (('proxy', proxy_endpoints), ('service', service_endpoints)): + for host, port in sorted(endpoints, key=lambda ep: (ep[0], ep[1] or 0)): + log.info(f'Allowing connections to configured {label} {host}:{port}') socket.getaddrinfo = _guarded_getaddrinfo diff --git a/app/ytdl.py b/app/ytdl.py index 11ac146..f7258a2 100644 --- a/app/ytdl.py +++ b/app/ytdl.py @@ -92,6 +92,36 @@ class _DownloadYtdlLogger: # vanish in the child can deadlock it silently before it does any work. This # app creates background threads (executors, notifier callbacks) well before # any download starts, so forcing fork there reproduces exactly that hazard. +# The image ships yt-dlp's bgutil PO token provider and starts it on loopback +# (docker-entrypoint.sh); the plugin dials this URL unless pointed elsewhere. +# Without a token YouTube serves 403s, so the connect-time guard has to let the +# download subprocess reach it. +_POT_PROVIDER_DEFAULT_URL = 'http://127.0.0.1:4416' + +# extractor-arg keys the bgutil HTTP provider reads its base URL from: the +# current one first, then the deprecated form it still honours. +_POT_PROVIDER_BASE_URL_ARGS = ( + ('youtubepot-bgutilhttp', 'base_url'), + ('youtube', 'getpot_bgutil_baseurl'), +) + + +def _pot_provider_urls(ytdl_opts: dict) -> tuple: + """Every PO token provider endpoint this download may dial: the bundled one, + plus any the operator pointed yt-dlp at through ``extractor_args``. The + bundled server runs either way, so it stays allowed even when a base URL is + configured.""" + urls = [_POT_PROVIDER_DEFAULT_URL] + extractor_args = ytdl_opts.get('extractor_args') + if isinstance(extractor_args, dict): + for ie_key, arg in _POT_PROVIDER_BASE_URL_ARGS: + section = extractor_args.get(ie_key) + values = section.get(arg) if isinstance(section, dict) else None + if values: + urls.append(values[0]) + return tuple(urls) + + _MP_CTX = ( multiprocessing.get_context("fork") if sys.platform.startswith("linux") and "fork" in multiprocessing.get_all_start_methods() @@ -764,10 +794,15 @@ class Download: # Re-validate every outbound connection at fetch time. validate_url only # saw the submitted URL string; this catches redirects, DNS rebinding and # attacker-controlled media URLs pulled from a remote manifest, none of - # which it can see. The configured proxy is passed so that a proxy on an - # internal address stays reachable at its own host:port without opening up - # anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the environment. - install_socket_guard(self.allow_private, proxy_urls=(self.ytdl_opts.get('proxy'),)) + # which it can see. The configured proxy and the PO token provider are + # passed so that each stays reachable at its own host:port without opening + # up anything else. Skipped when ALLOW_PRIVATE_ADDRESSES trusts the + # environment. + install_socket_guard( + self.allow_private, + proxy_urls=(self.ytdl_opts.get('proxy'),), + service_urls=_pot_provider_urls(self.ytdl_opts), + ) log.info(f"Starting download for: {self.info.title} ({self.info.url})") # Bound outside the try so the except branch can read what was captured # before the error was raised.