3030from collections import namedtuple
3131from pathlib import Path
3232from urllib .parse import unquote
33+ from urllib .parse import urljoin
3334from urllib .parse import urlparse
3435
3536from django .utils .http import parse_header_parameters
3637
3738import git
3839import requests
40+ import urllib3
3941from commoncode import command
4042from commoncode .hash import multi_checksums
4143from commoncode .text import python_safe_name
6466# certain conditions.
6567HTTP_REQUEST_TIMEOUT = 30
6668
69+ # Maximum number of HTTP redirects to follow when fetching a URL, re-validating
70+ # the target of each hop with ``is_safe_url`` to prevent a safe URL from
71+ # redirecting the request to an internal address.
72+ MAX_REDIRECT_HOPS = 5
73+
74+ REDIRECT_STATUS_CODES = frozenset ({301 , 302 , 303 , 307 , 308 })
75+
76+ # Backslash and control/whitespace characters can make ``urlparse`` and the
77+ # urllib3-based HTTP client disagree on the host part of a URL, defeating the
78+ # ``is_safe_url`` check below.
79+ UNSAFE_URL_CHARS_RE = re .compile (r"[\x00-\x20\x7f\\]" )
80+
6781
6882def get_request_session (uri ):
6983 """Return a Requests session setup with authentication and headers."""
@@ -87,8 +101,7 @@ def fetch_http(uri, to=None):
87101 Download a given `uri` in a temporary directory and return the directory's
88102 path.
89103 """
90- request_session = get_request_session (uri )
91- response = request_session .get (uri , timeout = HTTP_REQUEST_TIMEOUT )
104+ response = _request_with_safe_redirects (uri , "get" )
92105
93106 if response .status_code != 200 :
94107 raise requests .RequestException
@@ -413,42 +426,81 @@ def is_safe_url(url):
413426 Check that a URL does not point to a private or internal network address.
414427 Mitigates SSRF by ensuring the target host resolves only to public IPs.
415428 """
416- parsed = urlparse (url )
429+ # Reject characters that can make `urlparse` and the urllib3-based HTTP
430+ # client disagree on the host part of the same URL.
431+ if UNSAFE_URL_CHARS_RE .search (url ):
432+ return False
417433
418- # Only allow http and https schemes
419- if parsed .scheme not in ("http" , "https" ):
434+ if urlparse (url ).scheme not in ("http" , "https" ):
420435 return False
421436
422- # Reject URLs with no hostname
423- if not parsed .hostname :
437+ # Use urllib3's own URL parser, the one `requests` relies on to pick a
438+ # connection host, so this check and the actual request always agree on
439+ # which host is being contacted.
440+ host = urllib3 .util .parse_url (url ).host
441+ if not host :
424442 return False
425443
426- # Resolve the hostname to catch internal addresses hidden behind DNS
444+ # Resolve the hostname to catch internal addresses hidden behind DNS.
445+ # `getaddrinfo` is used instead of `gethostbyname` to also cover IPv6-only
446+ # records, since a client may connect over either address family.
427447 try :
428- resolved_ip = socket .gethostbyname ( parsed . hostname )
448+ address_infos = socket .getaddrinfo ( host , None )
429449 except socket .gaierror :
430450 return False
431451
432- # Reject private, loopback, link-local, and reserved addresses
433- ip = ipaddress .ip_address (resolved_ip )
434- unsafe = (
435- ip .is_private
436- or ip .is_loopback
437- or ip .is_link_local
438- or ip .is_reserved
439- or ip .is_multicast
440- )
441- return not unsafe
452+ resolved_ips = {address_info [4 ][0 ] for address_info in address_infos }
453+ if not resolved_ips :
454+ return False
442455
456+ # Reject private, loopback, link-local, reserved, and multicast addresses.
457+ # If any resolved address is unsafe, the host is rejected since the HTTP
458+ # client is free to connect to any of them.
459+ for resolved_ip in resolved_ips :
460+ ip = ipaddress .ip_address (resolved_ip )
461+ unsafe = (
462+ ip .is_private
463+ or ip .is_loopback
464+ or ip .is_link_local
465+ or ip .is_reserved
466+ or ip .is_multicast
467+ )
468+ if unsafe :
469+ return False
470+
471+ return True
443472
444- def check_url (url ):
445- """Check that a URL is safe and accessible."""
446- if not is_safe_url (url ):
447- return False
448473
474+ def _request_with_safe_redirects (url , method_name ):
475+ """
476+ Perform an HTTP request for `url` using the session `method_name`
477+ ("get" or "head"), re-validating each redirect target with `is_safe_url`
478+ before following it.
479+ Raise `requests.RequestException` if the URL, or any redirect target, is
480+ unsafe or if too many redirects are followed.
481+ """
449482 request_session = get_request_session (url )
483+ session_method = getattr (request_session , method_name )
484+
485+ for _ in range (MAX_REDIRECT_HOPS + 1 ):
486+ if not is_safe_url (url ):
487+ raise requests .RequestException (f"Unsafe URL: { url } " )
488+
489+ response = session_method (
490+ url , timeout = HTTP_REQUEST_TIMEOUT , allow_redirects = False
491+ )
492+ if response .status_code not in REDIRECT_STATUS_CODES :
493+ return response
494+
495+ url = urljoin (url , response .headers ["location" ])
496+
497+ raise requests .RequestException ("Too many redirects." )
498+
499+
500+ def check_url (url ):
501+ """Check that a URL is safe and accessible."""
450502 try :
451- response = request_session . head (url , timeout = HTTP_REQUEST_TIMEOUT )
503+ response = _request_with_safe_redirects (url , "head" )
452504 response .raise_for_status ()
453505 except requests .exceptions .RequestException :
454506 return False
0 commit comments