diff --git a/maildump/__init__.py b/maildump/__init__.py index 7da3185..89dd439 100644 --- a/maildump/__init__.py +++ b/maildump/__init__.py @@ -12,7 +12,7 @@ stopper = None -def start(http_host, http_port, smtp_host, smtp_port, db_path=None): +def start(http_host, http_port, smtp_host, smtp_port, smtp_auth, db_path=None): global stopper # Webserver log.notice('Starting web server on http://{0}:{1}'.format(http_host, http_port)) @@ -20,7 +20,9 @@ def start(http_host, http_port, smtp_host, smtp_port, db_path=None): stopper = http_server.close # SMTP server log.notice('Starting smtp server on {0}:{1}'.format(smtp_host, smtp_port)) - SMTPServer((smtp_host, smtp_port), smtp_handler) + if smtp_auth: + log.notice('Enabled SMTP authorization with htpasswd file {0}'.format(smtp_auth)) + SMTPServer((smtp_host, smtp_port), smtp_handler, smtp_auth) gevent.spawn(asyncore.loop) # Database connect(db_path) diff --git a/maildump/smtp.py b/maildump/smtp.py index 0177c8e..d64cc7c 100644 --- a/maildump/smtp.py +++ b/maildump/smtp.py @@ -1,3 +1,4 @@ +import base64 import smtpd from email.parser import BytesParser @@ -8,10 +9,113 @@ log = Logger(__name__) -class SMTPServer(smtpd.SMTPServer, object): - def __init__(self, listener, handler): +class SMTPChannel(smtpd.SMTPChannel): + def __init__(self, server, conn, addr, smtp_auth, data_size_limit=smtpd.DATA_SIZE_DEFAULT, + map=None, enable_SMTPUTF8=False, decode_data=False): + super(SMTPChannel, self).__init__(server, conn, addr, data_size_limit, map, enable_SMTPUTF8, decode_data) + self._smtp_auth = smtp_auth + self._authorized = False + + def is_valid_user(self, auth_data): + auth_data_parts = auth_data.split(b'\x00') + if len(auth_data_parts) != 3: + return False + + if not auth_data.startswith(b'\x00') and auth_data_parts[0] != auth_data_parts[1]: + return False + + return self._smtp_auth.check_password(auth_data_parts[1], auth_data_parts[2]) + + def smtp_EHLO(self, arg): + if not arg: + self.push('501 Syntax: EHLO hostname') + return + # See issue #21783 for a discussion of this behavior. + if self.seen_greeting: + self.push('503 Duplicate HELO/EHLO') + return + self._set_rset_state() + self.seen_greeting = arg + self.extended_smtp = True + self.push('250-%s' % self.fqdn) + if self._smtp_auth: + self.push('250-AUTH PLAIN') + if self.data_size_limit: + self.push('250-SIZE %s' % self.data_size_limit) + self.command_size_limits['MAIL'] += 26 + if not self._decode_data: + self.push('250-8BITMIME') + if self.enable_SMTPUTF8: + self.push('250-SMTPUTF8') + self.command_size_limits['MAIL'] += 10 + self.push('250 HELP') + + def smtp_AUTH(self, arg): + print('auth:', arg, file=smtpd.DEBUGSTREAM) + if not self._smtp_auth: + self.push('501 Syntax: AUTH not enabled') + return + + if not arg: + self.push('501 Syntax: AUTH TYPE base64(username:password)') + return + + if not arg.lower().startswith('plain '): + self.push('501 Syntax: only PLAIN auth possible') + return + + auth_type, auth_data = arg.split(None, 1) + try: + auth_data = base64.b64decode(auth_data.strip()) + except TypeError: + self.push('535 5.7.8 Authentication credentials invalid') + return + + if self.is_valid_user(auth_data): + self.push('235 Authentication successful') + self._authorized = True + return + + self._authorized = False + self.push('535 5.7.8 Authentication credentials invalid') + + def smtp_MAIL(self, arg): + if self._smtp_auth and not self._authorized: + self.push('530 5.7.0 Authentication required') + return + super(SMTPChannel, self).smtp_MAIL(arg) + + def smtp_RCPT(self, arg): + if self._smtp_auth and not self._authorized: + self.push('530 5.7.0 Authentication required') + return + super(SMTPChannel, self).smtp_RCPT(arg) + + def smtp_DATA(self, arg): + if self._smtp_auth and not self._authorized: + self.push('530 5.7.0 Authentication required') + return + super(SMTPChannel, self).smtp_DATA(arg) + + +class SMTPServer(smtpd.SMTPServer): + def __init__(self, listener, handler, smtp_auth): super(SMTPServer, self).__init__(listener, None) self._handler = handler + self._smtp_auth = smtp_auth + + def handle_accepted(self, conn, addr): + if self._smtp_auth: + channel = SMTPChannel(self, + conn, + addr, + self._smtp_auth, + self.data_size_limit, + self._map, + self.enable_SMTPUTF8, + self._decode_data) + else: + super(SMTPServer, self).handle_accepted(conn, addr) def process_message(self, peer, mailfrom, rcpttos, data, **kwargs): return self._handler(sender=mailfrom, recipients=rcpttos, body=data) diff --git a/maildump_runner/main.py b/maildump_runner/main.py index 2a2df01..f73a58d 100644 --- a/maildump_runner/main.py +++ b/maildump_runner/main.py @@ -35,6 +35,10 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument('--smtp-ip', default='127.0.0.1', metavar='IP', help='SMTP ip (default: 127.0.0.1)') parser.add_argument('--smtp-port', default=1025, type=int, metavar='PORT', help='SMTP port (default: 1025)') + parser.add_argument('--smtp-auth', metavar='HTPASSWD', help='Apache-style htpasswd file for SMTP authorization. ' + 'WARNING: do not rely only on this as a security ' + 'mechanism, use also additional methods for securing ' + 'your MailDump instance, ie. IP restrictions.') parser.add_argument('--http-ip', default='127.0.0.1', metavar='IP', help='HTTP ip (default: 127.0.0.1)') parser.add_argument('--http-port', default=1080, type=int, metavar='PORT', help='HTTP port (default: 1080)') parser.add_argument('--db', metavar='PATH', help='SQLite database - in-memory if missing') @@ -87,11 +91,19 @@ def main(): args.htpasswd = os.path.abspath(args.htpasswd) print('Htpasswd path is relative, using {0}'.format(args.htpasswd)) + if args.smtp_auth and not os.path.isabs(args.smtp_auth): + args.smtp_auth = os.path.abspath(args.smtp_auth) + print('Htpasswd path for SMTP AUTH is relative, using {0}'.format(args.smtp_auth)) + # Check if the password file is valid if args.htpasswd and not os.path.isfile(args.htpasswd): print('Htpasswd file does not exist') sys.exit(1) + if args.smtp_auth and not os.path.isfile(args.smtp_auth): + print('Htpasswd file for SMTP AUTH does not exist') + sys.exit(1) + daemon_kw = { 'monkey_greenlet_report': False, 'signal_map': {signal.SIGTERM: terminate_server, signal.SIGINT: terminate_server}, @@ -133,7 +145,7 @@ def main(): app.debug = args.debug app.config['MAILDUMP_HTPASSWD'] = None - if args.htpasswd: + if args.htpasswd or args.smtp_auth: # passlib is broken on py39, hence the local import # https://foss.heptapod.net/python-libs/passlib/-/issues/115 try: @@ -141,15 +153,18 @@ def main(): except OSError: print('Are you using Python 3.9? If yes, authentication is currently not available due to a bug.\n\n') raise + + if args.htpasswd: app.config['MAILDUMP_HTPASSWD'] = HtpasswdFile(args.htpasswd) app.config['MAILDUMP_NO_QUIT'] = args.no_quit + smtp_auth = HtpasswdFile(args.smtp_auth) if args.smtp_auth else None level = logbook.DEBUG if args.debug else logbook.INFO format_string = u'[{record.time:%Y-%m-%d %H:%M:%S}] {record.level_name:<8} {record.channel}: {record.message}' stderr_handler = ColorizedStderrHandler(level=level, format_string=format_string) with NullHandler().applicationbound(): with stderr_handler.applicationbound(): - start(args.http_ip, args.http_port, args.smtp_ip, args.smtp_port, args.db) + start(args.http_ip, args.http_port, args.smtp_ip, args.smtp_port, smtp_auth, args.db) if __name__ == '__main__':