From bb450e1be9d264ea52c849c220af18ec97db81b6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 26 Mar 2026 14:05:41 +0100 Subject: [PATCH] Add default protection for SSRF --- src/pretix/helpers/monkeypatching.py | 132 +++++++++++++++++++++++++++ src/pretix/settings.py | 1 + src/tests/helpers/test_urllib.py | 93 +++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 src/tests/helpers/test_urllib.py diff --git a/src/pretix/helpers/monkeypatching.py b/src/pretix/helpers/monkeypatching.py index 8d824612ce..59f25bf7c3 100644 --- a/src/pretix/helpers/monkeypatching.py +++ b/src/pretix/helpers/monkeypatching.py @@ -19,12 +19,26 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +import ipaddress +import socket +import sys import types from datetime import datetime from http import cookies +from django.conf import settings from PIL import Image from requests.adapters import HTTPAdapter +from urllib3.connection import HTTPConnection, HTTPSConnection +from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool +from urllib3.exceptions import ( + ConnectTimeoutError, HTTPError, LocationParseError, NameResolutionError, + NewConnectionError, +) +from urllib3.util.connection import ( + _TYPE_SOCKET_OPTIONS, _set_socket_options, allowed_gai_family, +) +from urllib3.util.timeout import _DEFAULT_TIMEOUT def monkeypatch_vobject_performance(): @@ -89,6 +103,123 @@ def monkeypatch_requests_timeout(): HTTPAdapter.send = httpadapter_send +def monkeypatch_urllib3_ssrf_protection(): + """ + pretix allows HTTP requests to untrusted URLs, e.g. through webhooks or external API URLs. This is dangerous since + it can allow access to private networks that should not be reachable by users ("server-side request forgery", SSRF). + Validating URLs at submission is not sufficient, since with DNS rebinding an attacker can make a domain name pass + validation and then resolve to a private IP address on actual execution. Unfortunately, there seems no clean solution + to this in Python land, so we monkeypatch urllib3's connection management to check the IP address to be external + *after* the DNS resolution. + + This does not work when a global http(s) proxy is used, but in that scenario the proxy can perform the validation. + """ + if settings.ALLOW_HTTP_TO_PRIVATE_NETWORKS: + # Settings are not supposed to change during runtime, so we can optimize performance and complexity by skipping + # this if not needed. + return + + def create_connection( + address: tuple[str, int], + timeout=_DEFAULT_TIMEOUT, + source_address: tuple[str, int] | None = None, + socket_options: _TYPE_SOCKET_OPTIONS | None = None, + ) -> socket.socket: + # This is copied from urllib3.util.connection v2.3.0 + host, port = address + if host.startswith("["): + host = host.strip("[]") + err = None + + # Using the value from allowed_gai_family() in the context of getaddrinfo lets + # us select whether to work with IPv4 DNS records, IPv6 records, or both. + # The original create_connection function always returns all records. + family = allowed_gai_family() + + try: + host.encode("idna") + except UnicodeError: + raise LocationParseError(f"'{host}', label empty or too long") from None + + for res in socket.getaddrinfo(host, port, family, socket.SOCK_STREAM): + af, socktype, proto, canonname, sa = res + + if not settings.ALLOW_HTTP_TO_PRIVATE_NETWORKS: + ip_addr = ipaddress.ip_address(sa[0]) + if ip_addr.is_multicast: + raise HTTPError(f"Request to multicast address {sa[0]} blocked") + if ip_addr.is_loopback or ip_addr.is_link_local: + raise HTTPError(f"Request to local address {sa[0]} blocked") + if ip_addr.is_private: + raise HTTPError(f"Request to private address {sa[0]} blocked") + + sock = None + try: + sock = socket.socket(af, socktype, proto) + + # If provided, set socket level options before connecting. + _set_socket_options(sock, socket_options) + + if timeout is not _DEFAULT_TIMEOUT: + sock.settimeout(timeout) + if source_address: + sock.bind(source_address) + sock.connect(sa) + # Break explicitly a reference cycle + err = None + return sock + + except OSError as _: + err = _ + if sock is not None: + sock.close() + + if err is not None: + try: + raise err + finally: + # Break explicitly a reference cycle + err = None + else: + raise OSError("getaddrinfo returns an empty list") + + class ProtectionMixin: + def _new_conn(self) -> socket.socket: + # This is 1:1 the version from urllib3.connection.HTTPConnection._new_conn v2.3.0 + # just with a call to our own create_connection + try: + sock = create_connection( + (self._dns_host, self.port), + self.timeout, + source_address=self.source_address, + socket_options=self.socket_options, + ) + except socket.gaierror as e: + raise NameResolutionError(self.host, self, e) from e + except socket.timeout as e: + raise ConnectTimeoutError( + self, + f"Connection to {self.host} timed out. (connect timeout={self.timeout})", + ) from e + + except OSError as e: + raise NewConnectionError( + self, f"Failed to establish a new connection: {e}" + ) from e + + sys.audit("http.client.connect", self, self.host, self.port) + return sock + + class ProtectedHTTPConnection(ProtectionMixin, HTTPConnection): + pass + + class ProtectedHTTPSConnection(ProtectionMixin, HTTPSConnection): + pass + + HTTPConnectionPool.ConnectionCls = ProtectedHTTPConnection + HTTPSConnectionPool.ConnectionCls = ProtectedHTTPSConnection + + def monkeypatch_cookie_morsel(): # See https://code.djangoproject.com/ticket/34613 cookies.Morsel._flags.add("partitioned") @@ -99,4 +230,5 @@ def monkeypatch_all_at_ready(): monkeypatch_vobject_performance() monkeypatch_pillow_safer() monkeypatch_requests_timeout() + monkeypatch_urllib3_ssrf_protection() monkeypatch_cookie_morsel() diff --git a/src/pretix/settings.py b/src/pretix/settings.py index 6c49116d94..d5ec121589 100644 --- a/src/pretix/settings.py +++ b/src/pretix/settings.py @@ -223,6 +223,7 @@ CSRF_TRUSTED_ORIGINS = [urlparse(SITE_URL).scheme + '://' + urlparse(SITE_URL).h TRUST_X_FORWARDED_FOR = config.getboolean('pretix', 'trust_x_forwarded_for', fallback=False) USE_X_FORWARDED_HOST = config.getboolean('pretix', 'trust_x_forwarded_host', fallback=False) +ALLOW_HTTP_TO_PRIVATE_NETWORKS = config.getboolean('pretix', 'allow_http_to_private_networks', fallback=False) REQUEST_ID_HEADER = config.get('pretix', 'request_id_header', fallback=False) diff --git a/src/tests/helpers/test_urllib.py b/src/tests/helpers/test_urllib.py new file mode 100644 index 0000000000..d4342bbe26 --- /dev/null +++ b/src/tests/helpers/test_urllib.py @@ -0,0 +1,93 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +from socket import AF_INET, SOCK_STREAM +from unittest import mock + +import pytest +import requests +from django.test import override_settings +from dns.inet import AF_INET6 +from urllib3.exceptions import HTTPError + + +def test_local_blocked(): + with pytest.raises(HTTPError, match="Request to local address.*"): + requests.get("http://localhost", timeout=0.1) + with pytest.raises(HTTPError, match="Request to local address.*"): + requests.get("https://localhost", timeout=0.1) + + +def test_private_ip_blocked(): + with pytest.raises(HTTPError, match="Request to private address.*"): + requests.get("http://10.0.0.1", timeout=0.1) + with pytest.raises(HTTPError, match="Request to private address.*"): + requests.get("https://10.0.0.1", timeout=0.1) + + +@pytest.mark.django_db +@pytest.mark.parametrize("res", [ + [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.3', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('0.0.0.0', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('127.1.1.1', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('192.168.5.3', 443))], + [(AF_INET, SOCK_STREAM, 6, '', ('224.0.0.1', 443))], + [(AF_INET6, SOCK_STREAM, 6, '', ('::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('fe80::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('ff00::1', 443, 0, 0))], + [(AF_INET6, SOCK_STREAM, 6, '', ('fc00::1', 443, 0, 0))], +]) +def test_dns_resolving_to_local_blocked(res): + with mock.patch('socket.getaddrinfo') as mock_addr: + mock_addr.return_value = res + with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"): + requests.get("https://example.org", timeout=0.1) + with pytest.raises(HTTPError, match="Request to (multicast|private|local) address.*"): + requests.get("http://example.org", timeout=0.1) + + +def test_dns_remote_allowed(): + class SocketOk(Exception): + pass + + def side_effect(*args, **kwargs): + raise SocketOk + + with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket: + mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('8.8.8.8', 443))] + mock_socket.side_effect = side_effect + with pytest.raises(SocketOk): + requests.get("https://example.org", timeout=0.1) + + +@override_settings(ALLOW_HTTP_TO_PRIVATE_NETWORKS=True) +def test_local_is_allowed(): + class SocketOk(Exception): + pass + + def side_effect(*args, **kwargs): + raise SocketOk + + with mock.patch('socket.getaddrinfo') as mock_addr, mock.patch('socket.socket') as mock_socket: + mock_addr.return_value = [(AF_INET, SOCK_STREAM, 6, '', ('10.0.0.1', 443))] + mock_socket.side_effect = side_effect + with pytest.raises(SocketOk): + requests.get("https://example.org", timeout=0.1)