Skip to content

Commit a675c8b

Browse files
committed
allow a SSLContext to be given to ftplib.FTP_TLS
1 parent 1eef430 commit a675c8b

4 files changed

Lines changed: 120 additions & 37 deletions

File tree

Doc/library/ftplib.rst

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,26 @@ The module defines the following items:
5555
*timeout* was added.
5656

5757

58-
.. class:: FTP_TLS([host[, user[, passwd[, acct[, keyfile[, certfile[, timeout]]]]]]])
58+
.. class:: FTP_TLS([host[, user[, passwd[, acct[, keyfile[, certfile[, context[, timeout]]]]]]]])
5959

60-
A :class:`FTP` subclass which adds TLS support to FTP as described in
60+
A :class:`FTP` subclass which adds TLS support to FTP as described in
6161
:rfc:`4217`.
6262
Connect as usual to port 21 implicitly securing the FTP control connection
6363
before authenticating. Securing the data connection requires the user to
64-
explicitly ask for it by calling the :meth:`prot_p` method.
65-
*keyfile* and *certfile* are optional -- they can contain a PEM formatted
66-
private key and certificate chain file name for the SSL connection.
64+
explicitly ask for it by calling the :meth:`prot_p` method. *context*
65+
is a :class:`ssl.SSLContext` object which allows bundling SSL configuration
66+
options, certificates and private keys into a single (potentially
67+
long-lived) structure. Please read :ref:`ssl-security` for best practices.
68+
69+
*keyfile* and *certfile* are a legacy alternative to *context* -- they
70+
can point to PEM-formatted private key and certificate chain files
71+
(respectively) for the SSL connection.
6772

6873
.. versionadded:: 2.7
6974

75+
.. versionchanged:: 2.7.10
76+
The *context* parameter was added.
77+
7078
Here's a sample session using the :class:`FTP_TLS` class:
7179

7280
>>> from ftplib import FTP_TLS

Lib/ftplib.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -641,9 +641,21 @@ class FTP_TLS(FTP):
641641
ssl_version = ssl.PROTOCOL_SSLv23
642642

643643
def __init__(self, host='', user='', passwd='', acct='', keyfile=None,
644-
certfile=None, timeout=_GLOBAL_DEFAULT_TIMEOUT):
644+
certfile=None, context=None,
645+
timeout=_GLOBAL_DEFAULT_TIMEOUT, source_address=None):
646+
if context is not None and keyfile is not None:
647+
raise ValueError("context and keyfile arguments are mutually "
648+
"exclusive")
649+
if context is not None and certfile is not None:
650+
raise ValueError("context and certfile arguments are mutually "
651+
"exclusive")
645652
self.keyfile = keyfile
646653
self.certfile = certfile
654+
if context is None:
655+
context = ssl._create_stdlib_context(self.ssl_version,
656+
certfile=certfile,
657+
keyfile=keyfile)
658+
self.context = context
647659
self._prot_p = False
648660
FTP.__init__(self, host, user, passwd, acct, timeout)
649661

@@ -660,8 +672,8 @@ def auth(self):
660672
resp = self.voidcmd('AUTH TLS')
661673
else:
662674
resp = self.voidcmd('AUTH SSL')
663-
self.sock = ssl.wrap_socket(self.sock, self.keyfile, self.certfile,
664-
ssl_version=self.ssl_version)
675+
self.sock = self.context.wrap_socket(self.sock,
676+
server_hostname=self.host)
665677
self.file = self.sock.makefile(mode='rb')
666678
return resp
667679

@@ -692,8 +704,8 @@ def prot_c(self):
692704
def ntransfercmd(self, cmd, rest=None):
693705
conn, size = FTP.ntransfercmd(self, cmd, rest)
694706
if self._prot_p:
695-
conn = ssl.wrap_socket(conn, self.keyfile, self.certfile,
696-
ssl_version=self.ssl_version)
707+
conn = self.context.wrap_socket(conn,
708+
server_hostname=self.host)
697709
return conn, size
698710

699711
def retrbinary(self, cmd, callback, blocksize=8192, rest=None):

Lib/test/test_ftplib.py

Lines changed: 88 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from test.test_support import HOST, HOSTv6
2121
threading = test_support.import_module('threading')
2222

23-
23+
TIMEOUT = 3
2424
# the dummy data returned by server over the data channel when
2525
# RETR, LIST and NLST commands are issued
2626
RETR_DATA = 'abcde12345\r\n' * 1000
@@ -223,6 +223,7 @@ def __init__(self, address, af=socket.AF_INET):
223223
self.active = False
224224
self.active_lock = threading.Lock()
225225
self.host, self.port = self.socket.getsockname()[:2]
226+
self.handler_instance = None
226227

227228
def start(self):
228229
assert not self.active
@@ -246,8 +247,7 @@ def stop(self):
246247

247248
def handle_accept(self):
248249
conn, addr = self.accept()
249-
self.handler = self.handler(conn)
250-
self.close()
250+
self.handler_instance = self.handler(conn)
251251

252252
def handle_connect(self):
253253
self.close()
@@ -262,7 +262,8 @@ def handle_error(self):
262262

263263
if ssl is not None:
264264

265-
CERTFILE = os.path.join(os.path.dirname(__file__), "keycert.pem")
265+
CERTFILE = os.path.join(os.path.dirname(__file__), "keycert3.pem")
266+
CAFILE = os.path.join(os.path.dirname(__file__), "pycacert.pem")
266267

267268
class SSLConnection(object, asyncore.dispatcher):
268269
"""An asyncore.dispatcher subclass supporting TLS/SSL."""
@@ -271,23 +272,25 @@ class SSLConnection(object, asyncore.dispatcher):
271272
_ssl_closing = False
272273

273274
def secure_connection(self):
274-
self.socket = ssl.wrap_socket(self.socket, suppress_ragged_eofs=False,
275-
certfile=CERTFILE, server_side=True,
276-
do_handshake_on_connect=False,
277-
ssl_version=ssl.PROTOCOL_SSLv23)
275+
socket = ssl.wrap_socket(self.socket, suppress_ragged_eofs=False,
276+
certfile=CERTFILE, server_side=True,
277+
do_handshake_on_connect=False,
278+
ssl_version=ssl.PROTOCOL_SSLv23)
279+
self.del_channel()
280+
self.set_socket(socket)
278281
self._ssl_accepting = True
279282

280283
def _do_ssl_handshake(self):
281284
try:
282285
self.socket.do_handshake()
283-
except ssl.SSLError, err:
286+
except ssl.SSLError as err:
284287
if err.args[0] in (ssl.SSL_ERROR_WANT_READ,
285288
ssl.SSL_ERROR_WANT_WRITE):
286289
return
287290
elif err.args[0] == ssl.SSL_ERROR_EOF:
288291
return self.handle_close()
289292
raise
290-
except socket.error, err:
293+
except socket.error as err:
291294
if err.args[0] == errno.ECONNABORTED:
292295
return self.handle_close()
293296
else:
@@ -297,18 +300,21 @@ def _do_ssl_shutdown(self):
297300
self._ssl_closing = True
298301
try:
299302
self.socket = self.socket.unwrap()
300-
except ssl.SSLError, err:
303+
except ssl.SSLError as err:
301304
if err.args[0] in (ssl.SSL_ERROR_WANT_READ,
302305
ssl.SSL_ERROR_WANT_WRITE):
303306
return
304-
except socket.error, err:
307+
except socket.error as err:
305308
# Any "socket error" corresponds to a SSL_ERROR_SYSCALL return
306309
# from OpenSSL's SSL_shutdown(), corresponding to a
307310
# closed socket condition. See also:
308311
# http://www.mail-archive.com/openssl-users@openssl.org/msg60710.html
309312
pass
310313
self._ssl_closing = False
311-
super(SSLConnection, self).close()
314+
if getattr(self, '_ccc', False) is False:
315+
super(SSLConnection, self).close()
316+
else:
317+
pass
312318

313319
def handle_read_event(self):
314320
if self._ssl_accepting:
@@ -329,7 +335,7 @@ def handle_write_event(self):
329335
def send(self, data):
330336
try:
331337
return super(SSLConnection, self).send(data)
332-
except ssl.SSLError, err:
338+
except ssl.SSLError as err:
333339
if err.args[0] in (ssl.SSL_ERROR_EOF, ssl.SSL_ERROR_ZERO_RETURN,
334340
ssl.SSL_ERROR_WANT_READ,
335341
ssl.SSL_ERROR_WANT_WRITE):
@@ -339,13 +345,13 @@ def send(self, data):
339345
def recv(self, buffer_size):
340346
try:
341347
return super(SSLConnection, self).recv(buffer_size)
342-
except ssl.SSLError, err:
348+
except ssl.SSLError as err:
343349
if err.args[0] in (ssl.SSL_ERROR_WANT_READ,
344350
ssl.SSL_ERROR_WANT_WRITE):
345-
return ''
351+
return b''
346352
if err.args[0] in (ssl.SSL_ERROR_EOF, ssl.SSL_ERROR_ZERO_RETURN):
347353
self.handle_close()
348-
return ''
354+
return b''
349355
raise
350356

351357
def handle_error(self):
@@ -355,6 +361,8 @@ def close(self):
355361
if (isinstance(self.socket, ssl.SSLSocket) and
356362
self.socket._sslobj is not None):
357363
self._do_ssl_shutdown()
364+
else:
365+
super(SSLConnection, self).close()
358366

359367

360368
class DummyTLS_DTPHandler(SSLConnection, DummyDTPHandler):
@@ -462,12 +470,12 @@ def test_acct(self):
462470

463471
def test_rename(self):
464472
self.client.rename('a', 'b')
465-
self.server.handler.next_response = '200'
473+
self.server.handler_instance.next_response = '200'
466474
self.assertRaises(ftplib.error_reply, self.client.rename, 'a', 'b')
467475

468476
def test_delete(self):
469477
self.client.delete('foo')
470-
self.server.handler.next_response = '199'
478+
self.server.handler_instance.next_response = '199'
471479
self.assertRaises(ftplib.error_reply, self.client.delete, 'foo')
472480

473481
def test_size(self):
@@ -515,7 +523,7 @@ def test_retrlines(self):
515523
def test_storbinary(self):
516524
f = StringIO.StringIO(RETR_DATA)
517525
self.client.storbinary('stor', f)
518-
self.assertEqual(self.server.handler.last_received_data, RETR_DATA)
526+
self.assertEqual(self.server.handler_instance.last_received_data, RETR_DATA)
519527
# test new callback arg
520528
flag = []
521529
f.seek(0)
@@ -527,12 +535,12 @@ def test_storbinary_rest(self):
527535
for r in (30, '30'):
528536
f.seek(0)
529537
self.client.storbinary('stor', f, rest=r)
530-
self.assertEqual(self.server.handler.rest, str(r))
538+
self.assertEqual(self.server.handler_instance.rest, str(r))
531539

532540
def test_storlines(self):
533541
f = StringIO.StringIO(RETR_DATA.replace('\r\n', '\n'))
534542
self.client.storlines('stor', f)
535-
self.assertEqual(self.server.handler.last_received_data, RETR_DATA)
543+
self.assertEqual(self.server.handler_instance.last_received_data, RETR_DATA)
536544
# test new callback arg
537545
flag = []
538546
f.seek(0)
@@ -551,14 +559,14 @@ def test_dir(self):
551559
def test_makeport(self):
552560
self.client.makeport()
553561
# IPv4 is in use, just make sure send_eprt has not been used
554-
self.assertEqual(self.server.handler.last_received_cmd, 'port')
562+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'port')
555563

556564
def test_makepasv(self):
557565
host, port = self.client.makepasv()
558566
conn = socket.create_connection((host, port), 10)
559567
conn.close()
560568
# IPv4 is in use, just make sure send_epsv has not been used
561-
self.assertEqual(self.server.handler.last_received_cmd, 'pasv')
569+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'pasv')
562570

563571
def test_line_too_long(self):
564572
self.assertRaises(ftplib.Error, self.client.sendcmd,
@@ -600,13 +608,13 @@ def test_af(self):
600608

601609
def test_makeport(self):
602610
self.client.makeport()
603-
self.assertEqual(self.server.handler.last_received_cmd, 'eprt')
611+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'eprt')
604612

605613
def test_makepasv(self):
606614
host, port = self.client.makepasv()
607615
conn = socket.create_connection((host, port), 10)
608616
conn.close()
609-
self.assertEqual(self.server.handler.last_received_cmd, 'epsv')
617+
self.assertEqual(self.server.handler_instance.last_received_cmd, 'epsv')
610618

611619
def test_transfer(self):
612620
def retr():
@@ -642,7 +650,7 @@ class TestTLS_FTPClass(TestCase):
642650
def setUp(self):
643651
self.server = DummyTLS_FTPServer((HOST, 0))
644652
self.server.start()
645-
self.client = ftplib.FTP_TLS(timeout=10)
653+
self.client = ftplib.FTP_TLS(timeout=TIMEOUT)
646654
self.client.connect(self.server.host, self.server.port)
647655

648656
def tearDown(self):
@@ -695,6 +703,59 @@ def test_auth_ssl(self):
695703
finally:
696704
self.client.ssl_version = ssl.PROTOCOL_TLSv1
697705

706+
def test_context(self):
707+
self.client.quit()
708+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
709+
self.assertRaises(ValueError, ftplib.FTP_TLS, keyfile=CERTFILE,
710+
context=ctx)
711+
self.assertRaises(ValueError, ftplib.FTP_TLS, certfile=CERTFILE,
712+
context=ctx)
713+
self.assertRaises(ValueError, ftplib.FTP_TLS, certfile=CERTFILE,
714+
keyfile=CERTFILE, context=ctx)
715+
716+
self.client = ftplib.FTP_TLS(context=ctx, timeout=TIMEOUT)
717+
self.client.connect(self.server.host, self.server.port)
718+
self.assertNotIsInstance(self.client.sock, ssl.SSLSocket)
719+
self.client.auth()
720+
self.assertIs(self.client.sock.context, ctx)
721+
self.assertIsInstance(self.client.sock, ssl.SSLSocket)
722+
723+
self.client.prot_p()
724+
sock = self.client.transfercmd('list')
725+
try:
726+
self.assertIs(sock.context, ctx)
727+
self.assertIsInstance(sock, ssl.SSLSocket)
728+
finally:
729+
sock.close()
730+
731+
def test_check_hostname(self):
732+
self.client.quit()
733+
ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
734+
ctx.verify_mode = ssl.CERT_REQUIRED
735+
ctx.check_hostname = True
736+
ctx.load_verify_locations(CAFILE)
737+
self.client = ftplib.FTP_TLS(context=ctx, timeout=TIMEOUT)
738+
739+
# 127.0.0.1 doesn't match SAN
740+
self.client.connect(self.server.host, self.server.port)
741+
with self.assertRaises(ssl.CertificateError):
742+
self.client.auth()
743+
# exception quits connection
744+
745+
self.client.connect(self.server.host, self.server.port)
746+
self.client.prot_p()
747+
with self.assertRaises(ssl.CertificateError):
748+
self.client.transfercmd("list").close()
749+
self.client.quit()
750+
751+
self.client.connect("localhost", self.server.port)
752+
self.client.auth()
753+
self.client.quit()
754+
755+
self.client.connect("localhost", self.server.port)
756+
self.client.prot_p()
757+
self.client.transfercmd("list").close()
758+
698759

699760
class TestTimeouts(TestCase):
700761

Misc/NEWS

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Core and Builtins
1515
Library
1616
-------
1717

18+
- Backport the context argument to ftplib.FTP_TLS.
19+
1820
- Issue #23111: Maximize compatibility in protocol versions of ftplib.FTP_TLS.
1921

2022
- Issue #23112: Fix SimpleHTTPServer to correctly carry the query string and

0 commit comments

Comments
 (0)