From 24b7d3e51341f03a80c5aac6938d34b4fdbd61a0 Mon Sep 17 00:00:00 2001 From: Hector Vior Date: Fri, 5 Dec 2025 16:43:40 +0100 Subject: [PATCH] feat: Allow specifying SSL/TLS protocol version for FTP-TLS connections. --- fsspec/implementations/ftp.py | 60 +++++++++++++++++++++- fsspec/implementations/tests/ftp_tlsv12.py | 43 ++++++++++++++++ fsspec/implementations/tests/test_ftp.py | 28 +++++++++- 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 fsspec/implementations/tests/ftp_tlsv12.py diff --git a/fsspec/implementations/ftp.py b/fsspec/implementations/ftp.py index a3db22b04..4c135dd2e 100644 --- a/fsspec/implementations/ftp.py +++ b/fsspec/implementations/ftp.py @@ -1,4 +1,5 @@ import os +import ssl import uuid from ftplib import FTP, FTP_TLS, Error, error_perm from typing import Any @@ -6,6 +7,39 @@ from ..spec import AbstractBufferedFile, AbstractFileSystem from ..utils import infer_storage_options, isfilelike +SECURITY_PROTOCOL_MAP = { + "tls": ssl.PROTOCOL_TLS, + "tlsv1": ssl.PROTOCOL_TLSv1, + "tlsv1_1": ssl.PROTOCOL_TLSv1_1, + "tlsv1_2": ssl.PROTOCOL_TLSv1_2, + "sslv2": "sslv2 has been deprecated due to security issues", + "sslv23": ssl.PROTOCOL_SSLv23, + "sslv3": "sslv3 has been deprecated due to security issues", +} + + +class ImplicitFTPTLS(FTP_TLS): + """ + FTP_TLS subclass that automatically wraps sockets in SSL + to support implicit FTPS. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._sock = None + + @property + def sock(self): + """Return the socket.""" + return self._sock + + @sock.setter + def sock(self, value): + """When modifying the socket, ensure that it is ssl wrapped.""" + if value is not None and not isinstance(value, ssl.SSLSocket): + value = self.context.wrap_socket(value) + self._sock = value + class FTPFileSystem(AbstractFileSystem): """A filesystem over classic FTP""" @@ -26,6 +60,7 @@ def __init__( timeout=30, encoding="utf-8", tls=False, + security_tls=None, **kwargs, ): """ @@ -57,6 +92,14 @@ def __init__( Encoding to use for directories and filenames in FTP connection tls: bool Use FTP-TLS, by default False + security_tls: str or None, by default None + SSL/TLS implicit protocol to use when tls=True. Options: + - None: Use explicit tls protocol + - "tls": Auto-negotiate highest protocol (default) + - "tlsv1": TLS v1.0 + - "tlsv1_1": TLS v1.1 + - "tlsv1_2": TLS v1.2 + - "sslv23": SSL v2/v3 (deprecated, not recommended) """ super().__init__(**kwargs) self.host = host @@ -70,16 +113,29 @@ def __init__( else: self.blocksize = 2**16 self.tls = tls + self.security_tls = security_tls self._connect() - if self.tls: + if self.tls and not self.security_tls: self.ftp.prot_p() def _connect(self): + security = None if self.tls: - ftp_cls = FTP_TLS + if self.security_tls: + ftp_cls = ImplicitFTPTLS + security = SECURITY_PROTOCOL_MAP.get( + self.security_tls, + f"Not supported {self.security_tls} protocol", + ) + if isinstance(security, str): + raise ValueError(security) + else: + ftp_cls = FTP_TLS else: ftp_cls = FTP self.ftp = ftp_cls(timeout=self.timeout, encoding=self.encoding) + if security: + self.ftp.ssl_version = security self.ftp.connect(self.host, self.port) self.ftp.login(*self.cred) diff --git a/fsspec/implementations/tests/ftp_tlsv12.py b/fsspec/implementations/tests/ftp_tlsv12.py new file mode 100644 index 000000000..afbc54f1b --- /dev/null +++ b/fsspec/implementations/tests/ftp_tlsv12.py @@ -0,0 +1,43 @@ +import os +import ssl + +from pyftpdlib.authorizers import DummyAuthorizer +from pyftpdlib.handlers import FTPHandler +from pyftpdlib.servers import FTPServer + + +def ftp(): + """Script to run FTP server that accepts TLS v1.2 implicitly""" + # Set up FTP server parameters + FTP_HOST = "localhost" + FTP_PORT = 2122 + FTP_DIRECTORY = os.path.dirname(os.path.abspath(__file__)) + + # Instantiate a dummy authorizer + authorizer = DummyAuthorizer() + authorizer.add_user( + "user", + "pass", + FTP_DIRECTORY, + "elradfmwMT", + ) + authorizer.add_anonymous(FTP_DIRECTORY) + + # Instantiate FTPHandler for implicit TLS + handler = FTPHandler + handler.authorizer = authorizer + + # Instantiate FTP server + server = FTPServer((FTP_HOST, FTP_PORT), handler) + + # Wrap socket for implicit TLS + certfile = os.path.join(os.path.dirname(__file__), "keycert.pem") + context = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2) + context.load_cert_chain(certfile) + server.socket = context.wrap_socket(server.socket, server_side=True) + + server.serve_forever() + + +if __name__ == "__main__": + ftp() diff --git a/fsspec/implementations/tests/test_ftp.py b/fsspec/implementations/tests/test_ftp.py index e480ecaff..ab5d5b339 100644 --- a/fsspec/implementations/tests/test_ftp.py +++ b/fsspec/implementations/tests/test_ftp.py @@ -8,7 +8,7 @@ import fsspec from fsspec import open_files -from fsspec.implementations.ftp import FTPFileSystem +from fsspec.implementations.ftp import FTPFileSystem, ImplicitFTPTLS ftplib = pytest.importorskip("ftplib") here = os.path.dirname(os.path.abspath(__file__)) @@ -30,6 +30,22 @@ def ftp(): P.wait() +@pytest.fixture() +def ftp_tlsv12(): + pytest.importorskip("pyftpdlib") + P = subprocess.Popen( + [sys.executable, os.path.join(here, "ftp_tlsv12.py")], + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + try: + time.sleep(1) + yield "localhost", 2122 + finally: + P.terminate() + P.wait() + + @pytest.mark.parametrize( "tls,exp_cls", ( @@ -43,6 +59,16 @@ def test_tls(ftp, tls, exp_cls): assert isinstance(fs.ftp, exp_cls) +@pytest.mark.parametrize( + "tls,exp_cls,security_tls", + ((True, ImplicitFTPTLS, "tlsv1_2"),), +) +def test_tls_v12(ftp_tlsv12, tls, exp_cls, security_tls): + host, port = ftp_tlsv12 + fs = FTPFileSystem(host, port, tls=tls, security_tls=security_tls) + assert isinstance(fs.ftp, exp_cls) + + @pytest.mark.parametrize( "tls,username,password", (