Skip to content

Commit eb5615d

Browse files
authored
Merge pull request #36 from ActiveState/BE-3130-cve-2021-4189
Be 3130 CVE 2021 4189
2 parents 8627fbf + 9c666f7 commit eb5615d

File tree

4 files changed

+160
-7
lines changed

4 files changed

+160
-7
lines changed

Doc/whatsnew/2.7.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2777,6 +2777,15 @@ It has been replaced by the new ``make regen-all`` target.
27772777
27782778
.. _acks27:
27792779

2780+
Security fix for FTP
2781+
================================
2782+
2783+
A security fix alters the :class:`ftplib.FTP` behavior to not trust the
2784+
IPv4 address sent from the remote server when setting up a passive data
2785+
channel. We reuse the ftp server IP address instead. For unusual code
2786+
requiring the old behavior, set a ``trust_server_pasv_ipv4_address``
2787+
attribute on your FTP instance to ``True``. (See :issue:`43285`)
2788+
27802789
Acknowledgements
27812790
================
27822791

Lib/ftplib.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,30 +108,55 @@ class FTP:
108108
file = None
109109
welcome = None
110110
passiveserver = 1
111+
encoding = "latin-1"
112+
# # Disables https://bugs.python.org/issue43285 security if set to True.
113+
# trust_server_pasv_ipv4_address = False
111114

112115
# Initialization method (called by class instantiation).
113116
# Initialize host to localhost, port to standard ftp port
114117
# Optional arguments are host (for connect()),
115118
# and user, passwd, acct (for login())
116119
def __init__(self, host='', user='', passwd='', acct='',
117-
timeout=_GLOBAL_DEFAULT_TIMEOUT):
120+
timeout=_GLOBAL_DEFAULT_TIMEOUT, source_address=None):
121+
self.source_address = source_address
122+
# Disables https://bugs.python.org/issue43285 security if set to True.
123+
self.trust_server_pasv_ipv4_address = False
118124
self.timeout = timeout
119125
if host:
120126
self.connect(host)
121127
if user:
122128
self.login(user, passwd, acct)
123129

124-
def connect(self, host='', port=0, timeout=-999):
130+
def __enter__(self):
131+
return self
132+
133+
# Context management protocol: try to quit() if active
134+
def __exit__(self, *args):
135+
if self.sock is not None:
136+
try:
137+
self.quit()
138+
except (OSError, EOFError):
139+
pass
140+
finally:
141+
if self.sock is not None:
142+
self.close()
143+
144+
def connect(self, host='', port=0, timeout=-999, source_address=None):
125145
'''Connect to host. Arguments are:
126146
- host: hostname to connect to (string, default previous host)
127147
- port: port to connect to (integer, default previous port)
148+
- timeout: the timeout to set against the ftp socket(s)
149+
- source_address: a 2-tuple (host, port) for the socket to bind
150+
to as its source address before connecting.
128151
'''
129152
if host != '':
130153
self.host = host
131154
if port > 0:
132155
self.port = port
133156
if timeout != -999:
134157
self.timeout = timeout
158+
if source_address is not None:
159+
self.source_address = source_address
135160
self.sock = socket.create_connection((self.host, self.port), self.timeout)
136161
self.af = self.sock.family
137162
self.file = self.sock.makefile('rb')
@@ -310,8 +335,13 @@ def makeport(self):
310335
return sock
311336

312337
def makepasv(self):
338+
"""Internal: Does the PASV or EPSV handshake -> (address, port)"""
313339
if self.af == socket.AF_INET:
314-
host, port = parse227(self.sendcmd('PASV'))
340+
untrusted_host, port = parse227(self.sendcmd('PASV'))
341+
if self.trust_server_pasv_ipv4_address:
342+
host = untrusted_host
343+
else:
344+
host = self.sock.getpeername()[0]
315345
else:
316346
host, port = parse229(self.sendcmd('EPSV'), self.sock.getpeername())
317347
return host, port

Lib/test/test_ftplib.py

Lines changed: 110 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
except ImportError:
1616
ssl = None
1717

18+
from contextlib import closing
1819
from unittest import TestCase, SkipTest, skipUnless
1920
from test import test_support
2021
from test.test_support import HOST, HOSTv6
@@ -67,6 +68,10 @@ def __init__(self, conn):
6768
self.rest = None
6869
self.next_retr_data = RETR_DATA
6970
self.push('220 welcome')
71+
# We use this as the string IPv4 address to direct the client
72+
# to in response to a PASV command. To test security behavior.
73+
# https://bugs.python.org/issue43285/.
74+
self.fake_pasv_server_ip = '252.253.254.255'
7075

7176
def collect_incoming_data(self, data):
7277
self.in_buffer.append(data)
@@ -109,13 +114,13 @@ def cmd_pasv(self, arg):
109114
sock.bind((self.socket.getsockname()[0], 0))
110115
sock.listen(5)
111116
sock.settimeout(10)
112-
ip, port = sock.getsockname()[:2]
113-
ip = ip.replace('.', ',')
114-
p1, p2 = divmod(port, 256)
117+
port = sock.getsockname()[1]
118+
ip = self.fake_pasv_server_ip
119+
ip = ip.replace('.', ','); p1 = port / 256; p2 = port % 256
115120
self.push('227 entering passive mode (%s,%d,%d)' %(ip, p1, p2))
116121
conn, addr = sock.accept()
117122
self.dtp = self.dtp_handler(conn, baseclass=self)
118-
123+
119124
def cmd_eprt(self, arg):
120125
af, ip, port = arg.split(arg[0])[1:-1]
121126
port = int(port)
@@ -577,6 +582,107 @@ def test_makepasv(self):
577582
# IPv4 is in use, just make sure send_epsv has not been used
578583
self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv')
579584

585+
def test_makepasv_issue43285_security_disabled(self):
586+
"""Test the opt-in to the old vulnerable behavior."""
587+
self.client.trust_server_pasv_ipv4_address = True
588+
bad_host, port = self.client.makepasv()
589+
self.assertEqual(
590+
bad_host, self.server.handler_instance.fake_pasv_server_ip)
591+
# Opening and closing a connection keeps the dummy server happy
592+
# instead of timing out on accept.
593+
socket.create_connection((self.client.sock.getpeername()[0], port),
594+
timeout=TIMEOUT).close()
595+
596+
def test_makepasv_issue43285_security_enabled_default(self):
597+
self.assertFalse(self.client.trust_server_pasv_ipv4_address)
598+
trusted_host, port = self.client.makepasv()
599+
self.assertNotEqual(
600+
trusted_host, self.server.handler_instance.fake_pasv_server_ip)
601+
# Opening and closing a connection keeps the dummy server happy
602+
# instead of timing out on accept.
603+
socket.create_connection((trusted_host, port), timeout=TIMEOUT).close()
604+
605+
def test_with_statement(self):
606+
self.client.quit()
607+
608+
def is_client_connected():
609+
if self.client.sock is None:
610+
return False
611+
try:
612+
self.client.sendcmd('noop')
613+
except (OSError, EOFError):
614+
return False
615+
return True
616+
617+
# base test
618+
with ftplib.FTP(timeout=TIMEOUT) as self.client:
619+
self.client.connect(self.server.host, self.server.port)
620+
self.client.sendcmd('noop')
621+
self.assertTrue(is_client_connected())
622+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'quit')
623+
self.assertFalse(is_client_connected())
624+
625+
# QUIT sent inside the with block
626+
with ftplib.FTP(timeout=TIMEOUT) as self.client:
627+
self.client.connect(self.server.host, self.server.port)
628+
self.client.sendcmd('noop')
629+
self.client.quit()
630+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'quit')
631+
self.assertFalse(is_client_connected())
632+
633+
# force a wrong response code to be sent on QUIT: error_perm
634+
# is expected and the connection is supposed to be closed
635+
try:
636+
with ftplib.FTP(timeout=TIMEOUT) as self.client:
637+
self.client.connect(self.server.host, self.server.port)
638+
self.client.sendcmd('noop')
639+
self.server.handler_instance.next_response = '550 error on quit'
640+
except ftplib.error_perm as err:
641+
self.assertEqual(str(err), '550 error on quit')
642+
else:
643+
self.fail('Exception not raised')
644+
# needed to give the threaded server some time to set the attribute
645+
# which otherwise would still be == 'noop'
646+
time.sleep(0.1)
647+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'quit')
648+
self.assertFalse(is_client_connected())
649+
650+
def test_source_address(self):
651+
self.client.quit()
652+
port = test_support.find_unused_port()
653+
try:
654+
self.client.connect(self.server.host, self.server.port,
655+
source_address=(HOST, port))
656+
self.assertEqual(self.client.sock.getsockname()[1], port)
657+
self.client.quit()
658+
except OSError as e:
659+
if e.errno == errno.EADDRINUSE:
660+
self.skipTest("couldn't bind to port %d" % port)
661+
raise
662+
663+
def test_source_address_passive_connection(self):
664+
port = test_support.find_unused_port()
665+
self.client.source_address = (HOST, port)
666+
try:
667+
with closing(self.client.transfercmd('list')) as sock:
668+
self.assertEqual(sock.getsockname()[1], port)
669+
except OSError as e:
670+
if e.errno == errno.EADDRINUSE:
671+
self.skipTest("couldn't bind to port %d" % port)
672+
raise
673+
674+
def test_parse257(self):
675+
self.assertEqual(ftplib.parse257('257 "/foo/bar"'), '/foo/bar')
676+
self.assertEqual(ftplib.parse257('257 "/foo/bar" created'), '/foo/bar')
677+
self.assertEqual(ftplib.parse257('257 ""'), '')
678+
self.assertEqual(ftplib.parse257('257 "" created'), '')
679+
self.assertRaises(ftplib.error_reply, ftplib.parse257, '250 "/foo/bar"')
680+
# The 257 response is supposed to include the directory
681+
# name and in case it contains embedded double-quotes
682+
# they must be doubled (see RFC-959, chapter 7, appendix 2).
683+
self.assertEqual(ftplib.parse257('257 "/foo/b""ar"'), '/foo/b"ar')
684+
self.assertEqual(ftplib.parse257('257 "/foo/b""ar" created'), '/foo/b"ar')
685+
580686
def test_line_too_long(self):
581687
self.assertRaises(ftplib.Error, self.client.sendcmd,
582688
'x' * self.client.maxline * 2)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
:mod:`ftplib` no longer trusts the IP address value returned from the server
2+
in response to the PASV command by default. This prevents a malicious FTP
3+
server from using the response to probe IPv4 address and port combinations
4+
on the client network.
5+
6+
Code that requires the former vulnerable behavior may set a
7+
``trust_server_pasv_ipv4_address`` attribute on their
8+
:class:`ftplib.FTP` instances to ``True`` to re-enable it.

0 commit comments

Comments
 (0)