Skip to content

Commit 0da7942

Browse files
authored
fix: harden SSRF mitigation against redirect and URL-parser bypasses (#2192)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 530c623 commit 0da7942

7 files changed

Lines changed: 305 additions & 54 deletions

File tree

scanpipe/pipes/fetch.py

Lines changed: 76 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@
3030
from collections import namedtuple
3131
from pathlib import Path
3232
from urllib.parse import unquote
33+
from urllib.parse import urljoin
3334
from urllib.parse import urlparse
3435

3536
from django.utils.http import parse_header_parameters
3637

3738
import git
3839
import requests
40+
import urllib3
3941
from commoncode import command
4042
from commoncode.hash import multi_checksums
4143
from commoncode.text import python_safe_name
@@ -64,6 +66,18 @@
6466
# certain conditions.
6567
HTTP_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

6882
def 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

Comments
 (0)