diff --git a/Doc/library/ftplib.rst b/Doc/library/ftplib.rst index e1baeff3f373bf1..27bafd8120a5098 100644 --- a/Doc/library/ftplib.rst +++ b/Doc/library/ftplib.rst @@ -276,6 +276,22 @@ FTP objects prints the line to :data:`sys.stdout`. + .. attribute:: FTP.prefer_epsv + + A :class:`bool` that controls whether passive mode data connections + try the EPSV command (:rfc:`2428`) before falling back to PASV on IPv4 + connections. Defaults to ``True``. + + EPSV responses contain only a port number and no IP address, making them + transparent to firewall FTP Application Layer Gateways (ALGs) that + commonly intercept and mangle PASV responses. If the server does not + support EPSV, the connection falls back to PASV automatically. + + Set to ``False`` to restore the legacy PASV-first behavior on IPv4. + + .. versionadded:: 3.15 + + .. method:: FTP.set_pasv(val) Enable "passive" mode if *val* is true, otherwise disable passive mode. diff --git a/Lib/ftplib.py b/Lib/ftplib.py index 2f092d50f31782b..ad31cea92343336 100644 --- a/Lib/ftplib.py +++ b/Lib/ftplib.py @@ -105,6 +105,10 @@ class FTP: passiveserver = True # Disables https://bugs.python.org/issue43285 security if set to True. trust_server_pasv_ipv4_address = False + # Prefer EPSV (RFC 2428) over PASV on IPv4 connections. + # EPSV is firewall-transparent (no IP in response) and works on both + # IPv4 and IPv6. Falls back to PASV if server doesn't support EPSV. + prefer_epsv = True def __init__(self, host='', user='', passwd='', acct='', timeout=_GLOBAL_DEFAULT_TIMEOUT, source_address=None, *, @@ -322,8 +326,20 @@ def makeport(self): return sock def makepasv(self): - """Internal: Does the PASV or EPSV handshake -> (address, port)""" + """Internal: Does the EPSV or PASV handshake -> (address, port) + + Prefers EPSV (RFC 2428) on IPv4 when prefer_epsv is True, falling + back to PASV if the server does not support EPSV. EPSV is always + used on IPv6 regardless of prefer_epsv. + """ if self.af == socket.AF_INET: + if self.prefer_epsv: + try: + host, port = parse229(self.sendcmd('EPSV'), + self.sock.getpeername()) + return host, port + except error_perm: + pass untrusted_host, port = parse227(self.sendcmd('PASV')) if self.trust_server_pasv_ipv4_address: host = untrusted_host diff --git a/Lib/test/test_ftplib.py b/Lib/test/test_ftplib.py index f1eff9430f7351c..524303e136457b0 100644 --- a/Lib/test/test_ftplib.py +++ b/Lib/test/test_ftplib.py @@ -178,7 +178,7 @@ def cmd_eprt(self, arg): def cmd_epsv(self, arg): with socket.create_server((self.socket.getsockname()[0], 0), - family=socket.AF_INET6) as sock: + family=self.socket.family) as sock: sock.settimeout(TIMEOUT) port = sock.getsockname()[1] self.push('229 entering extended passive mode (|||%d|)' %port) @@ -724,11 +724,31 @@ def test_makepasv(self): host, port = self.client.makepasv() conn = socket.create_connection((host, port), timeout=TIMEOUT) conn.close() - # IPv4 is in use, just make sure send_epsv has not been used + # IPv4 with prefer_epsv=True (default) should use EPSV + self.assertEqual(self.server.handler_instance.last_received_cmd, 'epsv') + + def test_makepasv_prefer_epsv_disabled(self): + self.client.prefer_epsv = False + host, port = self.client.makepasv() + conn = socket.create_connection((host, port), timeout=TIMEOUT) + conn.close() self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv') + def test_makepasv_prefer_epsv_fallback_to_pasv(self): + # Simulate server not supporting EPSV by monkey-patching the handler + original_cmd_epsv = self.server.handler.cmd_epsv + self.server.handler.cmd_epsv = lambda self_handler, arg: self_handler.push('500 EPSV not understood') + try: + host, port = self.client.makepasv() + conn = socket.create_connection((host, port), timeout=TIMEOUT) + conn.close() + self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv') + finally: + self.server.handler.cmd_epsv = original_cmd_epsv + def test_makepasv_issue43285_security_disabled(self): """Test the opt-in to the old vulnerable behavior.""" + self.client.prefer_epsv = False self.client.trust_server_pasv_ipv4_address = True bad_host, port = self.client.makepasv() self.assertEqual( @@ -739,6 +759,7 @@ def test_makepasv_issue43285_security_disabled(self): timeout=TIMEOUT).close() def test_makepasv_issue43285_security_enabled_default(self): + self.client.prefer_epsv = False self.assertFalse(self.client.trust_server_pasv_ipv4_address) trusted_host, port = self.client.makepasv() self.assertNotEqual( diff --git a/Misc/NEWS.d/next/Library/2026-05-22-12-00-00.gh-issue-127478.EpsvFtp.rst b/Misc/NEWS.d/next/Library/2026-05-22-12-00-00.gh-issue-127478.EpsvFtp.rst new file mode 100644 index 000000000000000..fe08ce28e7299e4 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-22-12-00-00.gh-issue-127478.EpsvFtp.rst @@ -0,0 +1,6 @@ +:mod:`ftplib`: Passive mode data connections now prefer EPSV (RFC 2428) over +PASV on IPv4 connections, falling back to PASV if the server does not support +EPSV. EPSV is firewall-transparent as it does not embed an IP address in the +response, avoiding interference from firewall FTP Application Layer Gateways +(ALGs). A new class attribute :attr:`~ftplib.FTP.prefer_epsv` (default +``True``) controls this behavior.