Skip to content

Commit b5c8cc6

Browse files
committed
gh-127478: ftplib: prefer EPSV over PASV on IPv4 connections
makepasv() now tries EPSV (RFC 2428) before PASV when connected over IPv4. EPSV returns only a port number without an IP address, making it transparent to firewall FTP Application Layer Gateways (ALGs) that intercept and often mangle PASV responses containing embedded IPs. Falls back to PASV if the server responds with an error to EPSV. A new class attribute FTP.prefer_epsv (default True) allows reverting to the old PASV-first behavior when set to False. This also fixes connectivity issues caused by the trust_server_pasv_ipv4_address security fix (bpo-43285): when firewalls rewrite PASV responses, clients connecting to the control channel IP on the data port often fail because nothing is listening there. EPSV avoids this entirely since the client always connects back to the same IP.
1 parent 28eac9a commit b5c8cc6

3 files changed

Lines changed: 46 additions & 3 deletions

File tree

Lib/ftplib.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ class FTP:
105105
passiveserver = True
106106
# Disables https://bugs.python.org/issue43285 security if set to True.
107107
trust_server_pasv_ipv4_address = False
108+
# Prefer EPSV (RFC 2428) over PASV on IPv4 connections.
109+
# EPSV is firewall-transparent (no IP in response) and works on both
110+
# IPv4 and IPv6. Falls back to PASV if server doesn't support EPSV.
111+
prefer_epsv = True
108112

109113
def __init__(self, host='', user='', passwd='', acct='',
110114
timeout=_GLOBAL_DEFAULT_TIMEOUT, source_address=None, *,
@@ -322,8 +326,20 @@ def makeport(self):
322326
return sock
323327

324328
def makepasv(self):
325-
"""Internal: Does the PASV or EPSV handshake -> (address, port)"""
329+
"""Internal: Does the EPSV or PASV handshake -> (address, port)
330+
331+
Prefers EPSV (RFC 2428) on IPv4 when prefer_epsv is True, falling
332+
back to PASV if the server does not support EPSV. EPSV is always
333+
used on IPv6 regardless of prefer_epsv.
334+
"""
326335
if self.af == socket.AF_INET:
336+
if self.prefer_epsv:
337+
try:
338+
host, port = parse229(self.sendcmd('EPSV'),
339+
self.sock.getpeername())
340+
return host, port
341+
except error_perm:
342+
pass
327343
untrusted_host, port = parse227(self.sendcmd('PASV'))
328344
if self.trust_server_pasv_ipv4_address:
329345
host = untrusted_host

Lib/test/test_ftplib.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ def cmd_eprt(self, arg):
178178

179179
def cmd_epsv(self, arg):
180180
with socket.create_server((self.socket.getsockname()[0], 0),
181-
family=socket.AF_INET6) as sock:
181+
family=self.socket.family) as sock:
182182
sock.settimeout(TIMEOUT)
183183
port = sock.getsockname()[1]
184184
self.push('229 entering extended passive mode (|||%d|)' %port)
@@ -724,11 +724,31 @@ def test_makepasv(self):
724724
host, port = self.client.makepasv()
725725
conn = socket.create_connection((host, port), timeout=TIMEOUT)
726726
conn.close()
727-
# IPv4 is in use, just make sure send_epsv has not been used
727+
# IPv4 with prefer_epsv=True (default) should use EPSV
728+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'epsv')
729+
730+
def test_makepasv_prefer_epsv_disabled(self):
731+
self.client.prefer_epsv = False
732+
host, port = self.client.makepasv()
733+
conn = socket.create_connection((host, port), timeout=TIMEOUT)
734+
conn.close()
728735
self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv')
729736

737+
def test_makepasv_prefer_epsv_fallback_to_pasv(self):
738+
# Simulate server not supporting EPSV by monkey-patching the handler
739+
original_cmd_epsv = self.server.handler.cmd_epsv
740+
self.server.handler.cmd_epsv = lambda self_handler, arg: self_handler.push('500 EPSV not understood')
741+
try:
742+
host, port = self.client.makepasv()
743+
conn = socket.create_connection((host, port), timeout=TIMEOUT)
744+
conn.close()
745+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv')
746+
finally:
747+
self.server.handler.cmd_epsv = original_cmd_epsv
748+
730749
def test_makepasv_issue43285_security_disabled(self):
731750
"""Test the opt-in to the old vulnerable behavior."""
751+
self.client.prefer_epsv = False
732752
self.client.trust_server_pasv_ipv4_address = True
733753
bad_host, port = self.client.makepasv()
734754
self.assertEqual(
@@ -739,6 +759,7 @@ def test_makepasv_issue43285_security_disabled(self):
739759
timeout=TIMEOUT).close()
740760

741761
def test_makepasv_issue43285_security_enabled_default(self):
762+
self.client.prefer_epsv = False
742763
self.assertFalse(self.client.trust_server_pasv_ipv4_address)
743764
trusted_host, port = self.client.makepasv()
744765
self.assertNotEqual(
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
:mod:`ftplib`: :meth:`~ftplib.FTP.makepasv` now prefers EPSV (RFC 2428) over
2+
PASV on IPv4 connections, falling back to PASV if the server does not support
3+
EPSV. EPSV is firewall-transparent as it does not embed an IP address in the
4+
response, avoiding interference from firewall FTP Application Layer Gateways
5+
(ALGs). A new class attribute :attr:`~ftplib.FTP.prefer_epsv` (default
6+
``True``) controls this behavior.

0 commit comments

Comments
 (0)