diff -r c43362d35d8d Doc/library/smtpd.rst --- a/Doc/library/smtpd.rst Fri Jul 04 17:00:25 2014 -0700 +++ b/Doc/library/smtpd.rst Sat Jul 19 00:08:47 2014 +0200 @@ -28,7 +28,7 @@ .. class:: SMTPServer(localaddr, remoteaddr, data_size_limit=33554432,\ - map=None, decode_data=True) + map=None, decode_data=True, enable_AUTH=False) Create a new :class:`SMTPServer` object, which binds to local address *localaddr*. It will treat *remoteaddr* as an upstream SMTP relayer. It @@ -46,6 +46,15 @@ compatibility reasons, but will change to ``False`` in Python 3.6. Specify the keyword value explicitly to avoid the :exc:`DeprecationWarning`. + *enable_AUTH* was implementd to be able to test :class:`smtplib.SMTP` and + should not be used unless you provide sufficiant security + mesures to prevent password snooping (with an encrypted tunnel) or + password snooping is not relevant (in a testing scenario). + If *enable_AUTH* is set to ``True`` the server advertises ``PLAIN`` and + ``LOGIN`` ``AUTH`` mechanisms and activates authentication. + :meth:`process_auth` is called during authentication to validate user + credentials. + .. method:: process_message(peer, mailfrom, rcpttos, data) Raise :exc:`NotImplementedError` exception. Override this in subclasses to @@ -60,6 +69,27 @@ argument will be a unicode string. If it is set to ``False``, it will be a bytes object. + .. method:: accept_recipient(self, mailfrom, rcptto, peername, user=None) + + Returns `True`. Overwrite this method to restrict recipients based on the + sender address *mailfrom*, recipient address *rcptto*, *peername* + (the client address as tuple of IP address and port), and *user* (always + `None` unless `AUTH` was enabled which is not recommended). + This method should return True to accept or False to deny a recipient. + It may raise a smtpd.SMTPResponseException which is sent to the client. + + .. method:: process_auth(user, password) + + Raise :exc:`NotImplementedError` exception. Override this in subclasses + to support authentication. It is used and needs to be overwritten if the + init parameter *enable_AUTH* is set to ``True`` (which is not + recommanded). + This method gets *user* and *password* as strings and should return a + boolean indicating weather the user credentials are valid. It may raise a + :exc:`smtplib.SMTPResponseException` which is passed to the client as + SMTP response. You also need to override :meth:`accept_recipient` to + support authorization as well. + .. attribute:: channel_class Override this in subclasses to use a custom :class:`SMTPChannel` for @@ -68,8 +98,12 @@ .. versionchanged:: 3.4 The *map* argument was added. - .. versionchanged:: 3.5 the *decode_data* argument was added, and *localaddr* - and *remoteaddr* may now contain IPv6 addresses. + .. versionchanged:: 3.5 + *localaddr* and *remoteaddr* may now contain IPv6 addresses. + + .. versionadded:: 3.5 + The *decode_data* and *enable_AUTH* arguments and the abstract methods + :meth:`process_auth` and :meth:`accept_recipient` were added. DebuggingServer Objects diff -r c43362d35d8d Lib/smtpd.py --- a/Lib/smtpd.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/smtpd.py Sat Jul 19 00:08:47 2014 +0200 @@ -81,8 +81,11 @@ import socket import asyncore import asynchat +import base64 import collections from warnings import warn +from ssl import wrap_socket +from smtplib import SMTPResponseException from email._header_value_parser import get_addr_spec, get_angle_addr __all__ = ["SMTPServer","DebuggingServer","PureProxy","MailmanProxy"] @@ -112,13 +115,16 @@ class SMTPChannel(asynchat.async_chat): COMMAND = 0 DATA = 1 + AUTH = 2 command_size_limit = 512 command_size_limits = collections.defaultdict(lambda x=command_size_limit: x) - command_size_limits.update({ - 'MAIL': command_size_limit + 26, - }) - max_command_size_limit = max(command_size_limits.values()) + + @property + def max_command_size_limit(self): + if len(self.command_size_limits.values()): + return max(self.command_size_limits.values()) + return self.command_size_limits.default_factory() def __init__(self, server, conn, addr, data_size_limit=DATA_SIZE_DEFAULT, map=None, decode_data=None): @@ -151,6 +157,7 @@ self.received_data = '' self.fqdn = socket.getfqdn() self.num_bytes = 0 + self.authenticated_user = None try: self.peer = conn.getpeername() except OSError as err: @@ -338,6 +345,13 @@ return method(arg) return + elif self.smtp_state == self.AUTH: + try: + self.auth_object(line) + except SMTPResponseException as e: + self.smtp_state = self.COMMAND + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + return else: if self.smtp_state != self.DATA: self.push('451 Internal confusion') @@ -389,11 +403,16 @@ if self.seen_greeting: self.push('503 Duplicate HELO/EHLO') else: + self.command_size_limits.clear() self.seen_greeting = arg self.extended_smtp = True self.push('250-%s' % self.fqdn) if self.data_size_limit: self.push('250-SIZE %s' % self.data_size_limit) + self.command_size_limits['MAIL'] += 26 + if self.smtp_server.enable_AUTH: + self.push('250-AUTH LOGIN PLAIN') + self.command_size_limits['MAIL'] += 500 self.push('250 HELP') def smtp_NOOP(self, arg): @@ -466,6 +485,80 @@ self.push('250 Supported commands: EHLO HELO MAIL RCPT DATA ' 'RSET NOOP QUIT VRFY') + def smtp_AUTH(self, arg): + if not self.seen_greeting: + self.push('503 Error: send EHLO first') + return + if not self.extended_smtp or not self.smtp_server.enable_AUTH: + self.push('500 Error: command "AUTH" not recognized') + return + if self.authenticated_user is not None: + self.push( + '503 Bad sequence of commands: already authenticated') + return + args = arg.split() + if len(args) not in [1, 2]: + self.push('501 Syntax: AUTH []') + return + auth_object_name = '_auth_%s' % args[0].lower().replace('-', '_') + try: + self.auth_object = getattr(self, auth_object_name) + except AttributeError: + self.push('504 Command parameter not implemented: ' + 'unsupported authentication mechanism') + return + self.smtp_state = self.AUTH + try: + self.auth_object(args[1] if len(args) == 2 else None) + except SMTPResponseException as e: + self.smtp_state = self.COMMAND + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + + def _authenticate(self, user, password): + """Translates the output of `_verify_user_credentials` to SMTP + responses, sets `self.user` on success and resets the internal + state.""" + if self.smtp_server.process_auth(user, password): + self.authenticated_user = user + self.push('235 Authentication Succeeded') + else: + self.push('535 Authentication credentials invalid') + self.smtp_state = self.COMMAND + + def _decode(self, string): + """Decode string containing base64 data to unicode literal.""" + try: + return base64.decodebytes(string.encode('ascii')).decode('utf-8') + except base64.binascii.Error as e: + raise SMTPResponseException( + 501, 'Encoding error: %s' % str(e)) + + def _auth_plain(self, arg=None): + """AUTH helper processing PLAIN authentication.""" + if arg is None: + self.push('334 ') + else: + try: + user, password = self._decode(arg).strip('\0').split('\0') + except ValueError as e: + self.push('535 Splitting input into user and password failed') + return + self._authenticate(user, password) + + def _auth_login(self, arg=None): + """AUTH helper processing LOGIN authentication.""" + if arg is None: + # base64 encoded 'Username:' + self.push('334 VXNlcm5hbWU6') + elif not hasattr(self, '_auth_login_user'): + self._auth_login_user = self._decode(arg) + # base64 encoded 'Password:' + self.push('334 UGFzc3dvcmQ6') + else: + password = self._decode(arg) + self._authenticate(self._auth_login_user, password) + del self._auth_login_user + def smtp_VRFY(self, arg): if arg: address, params = self._getaddr(arg) @@ -514,6 +607,8 @@ elif self.data_size_limit and int(size) > self.data_size_limit: self.push('552 Error: message size exceeds fixed maximum message size') return + # See RFC 4954 section 5 + self.auth_mailbox = params.pop('AUTH', self.authenticated_user) if len(params.keys()) > 0: self.push('555 MAIL FROM parameters not recognized or not implemented') return @@ -558,6 +653,18 @@ if not address: self.push('501 Syntax: RCPT TO:
') return + try: + if not self.smtp_server.accept_recipient( + user=self.authenticated_user, + mailfrom=self.mailfrom, + rcptto=address, + peername=self.conn.getpeername()): + self.push("530 Sending emails from '%s' to '%s' is" + " not permitted" % (self.mailfrom, address)) + return + except SMTPResponseException as e: + self.push('%s %s' % (e.smtp_code, e.smtp_error)) + return self.rcpttos.append(address) print('recips:', self.rcpttos, file=DEBUGSTREAM) self.push('250 OK') @@ -598,10 +705,11 @@ def __init__(self, localaddr, remoteaddr, data_size_limit=DATA_SIZE_DEFAULT, map=None, - decode_data=None): + decode_data=None, enable_AUTH=False): self._localaddr = localaddr self._remoteaddr = remoteaddr self.data_size_limit = data_size_limit + self.enable_AUTH = enable_AUTH if decode_data is None: warn("The decode_data default of True will change to False in 3.6;" " specify an explicit value for this keyword", @@ -655,6 +763,25 @@ """ raise NotImplementedError + def process_auth(self, user, password): + """Overwrite this method to provide authentication. + This method gets `user` and `password` as strings and should return a + boolean. It may raise an smtpd.SMTPResponseException on errors instead. + SMTPResponseExceptions are sent to to the client as SMTP response. + This method will only be called if `enable_AUTH=True` was set as init + parameter which is NOT recommended unless you know how to prevent + password snooping.""" + raise NotImplementedError + + def accept_recipient(self, mailfrom, rcptto, peername, user=None): + """Returns True. Overwrite this method to restrict recipients based on + the sender address `mailfrom`, recipient address `rcptto`, `peername` + (the client address, a tuple of IP address and port), and if AUTH is + enabled `user` (None by default). This method should return True to + accept or False to deny a recipient. It may raise a + smtpd.SMTPResponseException which will be send to the client.""" + return True + class DebuggingServer(SMTPServer): # Do something with the gathered message diff -r c43362d35d8d Lib/test/test_smtpd.py --- a/Lib/test/test_smtpd.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/test/test_smtpd.py Sat Jul 19 00:08:47 2014 +0200 @@ -55,6 +55,26 @@ with self.assertWarns(DeprecationWarning): smtpd.SMTPServer((support.HOST, 0), ('b', 0)) + def test_process_auth_unimplemented(self): + server = smtpd.SMTPServer((support.HOST, 0), ('b', 0), + decode_data=True, enable_AUTH=True) + conn, addr = server.accept() + channel = smtpd.SMTPChannel(server, conn, addr, decode_data=True) + + def write_line(line): + channel.socket.queue_recv(line) + channel.handle_read() + write_line(b'EHLO example') + self.assertRaises( + NotImplementedError, + write_line, + b'AUTH PLAIN AGhhbGxvAGhhbGxv\r\n') + + def test_accept_recipient_returns_true_without_looking_at_args(self): + server = smtpd.SMTPServer((support.HOST, 0), ('b', 0), + decode_data=True) + self.assertTrue(server.accept_recipient(None, None, None, None)) + def tearDown(self): asyncore.close_all() asyncore.socket = smtpd.socket = socket @@ -86,6 +106,7 @@ self.old_debugstream = smtpd.DEBUGSTREAM self.debug = smtpd.DEBUGSTREAM = io.StringIO() self.server = DummyServer((support.HOST, 0), ('b', 0)) + self.server.process_auth = lambda user, password: True conn, addr = self.server.accept() self.channel = smtpd.SMTPChannel(self.server, conn, addr, decode_data=True) @@ -180,6 +201,94 @@ self.assertEqual(self.channel.socket.last, b'501 Syntax: MAIL FROM:
\r\n') + def test_AUTH_requires_greeting(self): + self.server.enable_AUTH = True + self.write_line(b'AUTH PLAIN') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '503 ') + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_can_be_accepted_conditionally(self): + self.server.process_auth = lambda u, p: u == p + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + # base64 encoded '\0no\0access' + self.write_line(b'AUTH PLAIN AG5vAGFjY2Vzcw==') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '535 ') + # base64 encoded '\0hello\0hello' + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + self.assertEqual(self.channel.authenticated_user, 'hallo') + self.assertEqual(self.channel.smtp_state, self.channel.COMMAND) + + def test_AUTH_with_more_then_one_message(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN') + self.assertEqual(self.channel.socket.last, b'334 \r\n') + self.assertEqual(self.channel.smtp_state, self.channel.AUTH) + self.write_line(b'AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_LOGIN(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH LOGIN') + self.assertEqual(self.channel.socket.last, b'334 VXNlcm5hbWU6\r\n') + self.write_line(b'aGFsbG8=') + self.assertEqual(self.channel.socket.last, b'334 UGFzc3dvcmQ6\r\n') + self.write_line(b'aGFsbG8=') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + + def test_AUTH_exceptions(self): + self.server.enable_AUTH = True + def verify(user, password): + raise smtpd.SMTPResponseException(404, 'Code not found') + self.server.process_auth = verify + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN hallo') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '501 ') + self.write_line(b'AUTH PAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '504 ') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '404 ') + + def test_multiple_AUTH_denied(self): + self.server.enable_AUTH = True + self.server.accept_recipiant = lambda u, f, t: True + self.write_line(b'EHLO beispiel') + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '235 ') + self.assertEqual(self.channel.authenticated_user, 'hallo') + self.assertEqual(self.channel.smtp_state, self.channel.COMMAND) + self.write_line(b'AUTH PLAIN AGhhbGxvAGhhbGxv') + self.assertEqual(self.channel.socket.last.decode('ascii')[:4], '503 ') + + def test_RCPT_denied_when_accept_recipient_returns_false(self): + self.server.accept_recipient = lambda *args, **kwargs: False + self.write_line(b'EHLO beispiel') + self.write_line(b'MAIL FROM: test1@example.com') + self.write_line(b'RCPT TO: test2@example.com') + self.assertEqual(self.channel.socket.last[:4], b'530 ') + + def test_accept_recipient(self): + def fancy_accept_rcpt(mailfrom, rcptto, peername, user=None): + if rcptto.split('@')[1] == 'example.com' or mailfrom == rcptto: + return True + elif peername == 'peer': + return False + self.fail('Arguments were not as expected') + self.server.accept_recipient = fancy_accept_rcpt + self.write_line(b'EHLO beispiel') + self.write_line(b'MAIL FROM: test@example.net') + self.write_line(b'RCPT TO: test@example.com') + self.assertEqual(self.channel.socket.last[:4], b'250 ') + self.write_line(b'RCPT TO: test@example.net') + self.assertEqual(self.channel.socket.last[:4], b'250 ') + self.write_line(b'RCPT TO: test2@example.net') + self.assertEqual(self.channel.socket.last[:4], b'530 ') + def test_MAIL_allows_space_after_colon(self): self.write_line(b'HELO example') self.write_line(b'MAIL from: ') @@ -234,19 +343,38 @@ b'500 Error: line too long\r\n') def test_MAIL_command_limit_extended_with_SIZE(self): + fill_len = self.channel.command_size_limit - len('MAIL from:<@example>') self.write_line(b'EHLO example') - fill_len = self.channel.command_size_limit - len('MAIL from:<@example>') + self.write_line(b'MAIL from:<' + + b'a' * (fill_len + 26) + + b'@example> SIZE=1234') + self.assertEqual(self.channel.socket.last, + b'500 Error: line too long\r\n') + self.write_line(b'MAIL from:<' + b'a' * fill_len + b'@example> SIZE=1234') self.assertEqual(self.channel.socket.last, b'250 OK\r\n') - self.write_line(b'MAIL from:<' + - b'a' * (fill_len + 26) + - b'@example> SIZE=1234') + def test_MAIL_command_limit_extended_with_AUTH(self): + self.server.enable_AUTH = True + self.write_line(b'EHLO example') + prefix = b'mail from: <' + suffix = b'@example.com>' + + self.write_line( + prefix + + b'a' * (512 + 26 + 500 - len(prefix) - len(suffix) + 1) + + suffix) self.assertEqual(self.channel.socket.last, b'500 Error: line too long\r\n') + self.write_line( + prefix + + b'a' * (512 + 26 + 500 - len(prefix) - len(suffix)) + + suffix) + self.assertEqual(self.channel.socket.last, b'250 OK\r\n') + def test_data_longer_than_default_data_size_limit(self): # Hack the default so we don't have to generate so much data. self.channel.data_size_limit = 1048 @@ -548,6 +676,7 @@ self.old_debugstream = smtpd.DEBUGSTREAM self.debug = smtpd.DEBUGSTREAM = io.StringIO() self.server = DummyServer((support.HOSTv6, 0), ('b', 0)) + self.server.process_auth = lambda user, password: True conn, addr = self.server.accept() self.channel = smtpd.SMTPChannel(self.server, conn, addr, decode_data=True) diff -r c43362d35d8d Lib/test/test_smtplib.py --- a/Lib/test/test_smtplib.py Fri Jul 04 17:00:25 2014 -0700 +++ b/Lib/test/test_smtplib.py Sat Jul 19 00:08:47 2014 +0200 @@ -623,12 +623,18 @@ rcpt_count = 0 rset_count = 0 disconnect = 0 + user_dict = {} def __init__(self, extra_features, *args, **kw): self._extrafeatures = ''.join( [ "250-{0}\r\n".format(x) for x in extra_features ]) super(SimSMTPChannel, self).__init__(*args, **kw) + def _authenticate(self, user, password): + super(SimSMTPChannel, self)._authenticate(user, password) + if self.smtp_server.process_auth(user, password): + self.smtp_server.sim_auth = (user, password) + def smtp_EHLO(self, arg): resp = ('250-testhost\r\n' '250-EXPN\r\n' @@ -662,17 +668,10 @@ def smtp_AUTH(self, arg): mech = arg.strip().lower() - if mech=='cram-md5': + if mech == 'cram-md5': self.push('334 {}'.format(sim_cram_md5_challenge)) - elif mech not in sim_auth_credentials: - self.push('504 auth type unimplemented') - return - elif mech=='plain': - self.push('334 ') - elif mech=='login': - self.push('334 ') else: - self.push('550 No access for you!') + super(SimSMTPChannel, self).smtp_AUTH(arg) def smtp_QUIT(self, arg): if self.quit_response is None: @@ -726,6 +725,14 @@ def process_message(self, peer, mailfrom, rcpttos, data): pass + def process_auth(self, user, password): + if (user, password) == sim_auth: + return True + return False + + def accept_recipient(self, user, mailfrom, rcptto): + return True + def add_feature(self, feature): self._extra_features.append(feature) @@ -744,7 +751,8 @@ self.serv_evt = threading.Event() self.client_evt = threading.Event() # Pick a random unused port by passing 0 for the port number - self.serv = SimSMTPServer((HOST, 0), ('nowhere', -1), decode_data=True) + self.serv = SimSMTPServer( + (HOST, 0), ('nowhere', -1), decode_data=True, enable_AUTH=True) # Keep a note of what port was assigned self.port = self.serv.socket.getsockname()[1] serv_args = (self.serv, self.serv_evt, self.client_evt) @@ -829,17 +837,15 @@ def testAUTH_PLAIN(self): self.serv.add_feature("AUTH PLAIN") smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15) - try: smtp.login(sim_auth[0], sim_auth[1]) - except smtplib.SMTPAuthenticationError as err: - self.assertIn(sim_auth_plain, str(err)) + smtp.login(sim_auth[0], sim_auth[1]) + self.assertEqual(self.serv.sim_auth, sim_auth) smtp.close() def testAUTH_LOGIN(self): self.serv.add_feature("AUTH LOGIN") smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15) - try: smtp.login(sim_auth[0], sim_auth[1]) - except smtplib.SMTPAuthenticationError as err: - self.assertIn(sim_auth_login_user, str(err)) + smtp.login(sim_auth[0], sim_auth[1]) + self.assertEqual(self.serv.sim_auth, sim_auth) smtp.close() def testAUTH_CRAM_MD5(self): @@ -855,7 +861,9 @@ # Test that multiple authentication methods are tried. self.serv.add_feature("AUTH BOGUS PLAIN LOGIN CRAM-MD5") smtp = smtplib.SMTP(HOST, self.port, local_hostname='localhost', timeout=15) - try: smtp.login(sim_auth[0], sim_auth[1]) + try: + smtp.login(sim_auth[0], sim_auth[1]) + self.assertEqual(self.serv.sim_auth, sim_auth) except smtplib.SMTPAuthenticationError as err: self.assertIn(sim_auth_login_user, str(err)) smtp.close() @@ -872,6 +880,8 @@ for mechanism, method in supported.items(): try: smtp.auth(mechanism, method) except smtplib.SMTPAuthenticationError as err: + if err.smtp_code == 503: + break self.assertIn(sim_auth_credentials[mechanism.lower()].upper(), str(err)) smtp.close()