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)