From e57845f8b1de0b20d3d30f2293d1a064cc994547 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 13 Feb 2022 00:14:38 +0100 Subject: [PATCH 01/15] DKIM-authorisation - init --- .gitignore | 129 +++++++ BASEOS | 1 - VERSION | 1 - app/ldap-acl-milter.py | 512 +++++++++++++++---------- docker-build.sh | 27 -- requirements.txt | 3 + tests/miltertest-ip-conn_reuse.lua | 90 +++++ tests/miltertest-ip-multiple_rcpts.lua | 66 ++++ tests/miltertest-ip.lua | 52 +++ tests/miltertest-sasl.lua | 53 +++ tests/miltertest-x509.lua | 54 +++ 11 files changed, 757 insertions(+), 231 deletions(-) create mode 100644 .gitignore delete mode 100644 BASEOS delete mode 100644 VERSION delete mode 100755 docker-build.sh create mode 100644 requirements.txt create mode 100644 tests/miltertest-ip-conn_reuse.lua create mode 100644 tests/miltertest-ip-multiple_rcpts.lua create mode 100644 tests/miltertest-ip.lua create mode 100644 tests/miltertest-sasl.lua create mode 100644 tests/miltertest-x509.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25ff4b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/BASEOS b/BASEOS deleted file mode 100644 index 2dee175..0000000 --- a/BASEOS +++ /dev/null @@ -1 +0,0 @@ -debian diff --git a/VERSION b/VERSION deleted file mode 100644 index d18f0e6..0000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -19.04 diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index f89e07b..1e3aa99 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -1,6 +1,7 @@ +from argparse import Action import Milter from ldap3 import ( - Server,ServerPool,Connection,NONE,LDAPOperationResult,set_config_parameter + Server, Connection, NONE, set_config_parameter ) import sys import traceback @@ -10,6 +11,8 @@ import string import random import re from timeit import default_timer as timer +import email.utils +import authres # Globals... g_milter_name = 'ldap-acl-milter' @@ -17,7 +20,6 @@ g_milter_socket = '/socket/' + g_milter_name g_milter_reject_message = 'Security policy violation!' g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' g_ldap_conn = None -# ...with mostly senseless defaults ;) g_ldap_server = 'ldap://127.0.0.1:389' g_ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' g_ldap_bindpw = 'TopSecret;-)' @@ -33,123 +35,101 @@ g_milter_schema = False g_milter_schema_wildcard_domain = False # works only if g_milter_schema == True g_milter_expect_auth = False g_milter_whitelisted_rcpts = {} +g_milter_dkim_enabled = False +g_milter_trusted_authservid = None g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): - self.time_start = timer() - self.ldap_conn = g_ldap_conn + # client_addr gets overriden on any connect() self.client_addr = None + + def reset(self): + self.proto_stage = 'proto-stage' self.env_from = None self.env_from_domain = None self.sasl_user = None self.x509_subject = None self.x509_issuer = None - # recipients list + self.queue_id = 'qid-na' self.env_rcpts = [] + self.hdr_from = None + self.hdr_from_domain = None + self.dkim_valid = False + self.passed_dkim_results = [] + logging.debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 self.mconn_id = g_milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) - # Not registered/used callbacks - #@Milter.nocallback - #def hello(self, heloname) - # return Milter.CONTINUE - @Milter.nocallback - def header(self, name, hval): - return Milter.CONTINUE - @Milter.nocallback - def eoh(self): - return Milter.CONTINUE - @Milter.nocallback - def body(self, chunk): - return Milter.CONTINUE + def milter_action(self, **kwargs): + if 'action' not in kwargs: + raise Exception("'action' kwarg is mandatory!") + message = None + smfir = None + smtp_code = None + smtp_ecode = None + if kwargs['action'] == 'reject': + message = g_milter_reject_message + smtp_code = '550' + smtp_ecode = '5.7.1' + smfir = Milter.REJECT + elif kwargs['action'] == 'tmpfail': + message = g_milter_tmpfail_message + smtp_code = '450' + smtp_ecode = '4.7.1' + smfir = Milter.TEMPFAIL + elif kwargs['action'] == 'continue': + message = 'continue' + smfir = Milter.CONTINUE + else: + raise Exception("Invalid 'action': {}".format(kwargs['action'])) + # override message + if 'message' in kwargs: + message = kwargs['message'] + # prepend queue-id to message if it´s already available (DATA and later) + if self.queue_id: + message = "queue_id: {0} - {1}".format(self.queue_id, message) + # append reason to message + if 'reason' in kwargs: + message = "{0} - reason: {1}".format(message, kwargs['reason']) + if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': + self.setreply(smtp_code, smtp_ecode, message) + logging.info(self.mconn_id + "/" + + self.proto_stage + ": milter_action={0} message={1}".format(kwargs['action'], message) + ) + return smfir - def connect(self, IPname, family, hostaddr): - self.client_addr = hostaddr[0] + def check_policy(self, from_addr, rcpt_addr): logging.debug(self.mconn_id + - "/CONNECT client_addr=[" + self.client_addr + "]:" + str(hostaddr[1]) + " /CHECK_POLICY/{0} from={1} rcpt={2}".format( + self.proto_stage, from_addr, rcpt_addr + ) ) - return Milter.CONTINUE - - def envfrom(self, mailfrom, *str): - try: - # this may fail, if no x509 client certificate was used. - # postfix only passes this macro to milters if the TLS connection - # with the authenticating client was trusted in a x509 manner! - # http://postfix.1071664.n5.nabble.com/verification-levels-and-Milter-tp91634p91638.html - # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ - x509_subject = self.getsymval('{cert_subject}') - if x509_subject != None: - self.x509_subject = x509_subject - logging.info(self.mconn_id + "/FROM x509_subject=" + self.x509_subject) - x509_issuer = self.getsymval('{cert_issuer}') - if x509_issuer != None: - self.x509_issuer = x509_issuer - logging.info(self.mconn_id + "/FROM x509_issuer=" + self.x509_issuer) - except: - logging.error(self.mconn_id + "/FROM x509 " + traceback.format_exc()) - try: - # this may fail, if no SASL authentication preceded - sasl_user = self.getsymval('{auth_authen}') - if sasl_user != None: - self.sasl_user = sasl_user - logging.info(self.mconn_id + "/FROM sasl_user=" + self.sasl_user) - except: - logging.error(self.mconn_id + "/FROM sasl_user " + traceback.format_exc()) - mailfrom = mailfrom.replace("<","") - mailfrom = mailfrom.replace(">","") - # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) - # Strip out Simple Private Signature (PRVS) - mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) - # SRS (https://www.libsrs2.org/srs/srs.pdf) - m_srs = g_re_srs.match(mailfrom) - if m_srs != None: - logging.info(self.mconn_id + "/FROM " + - "Found SRS-encoded envelope-sender: " + mailfrom - ) - mailfrom = m_srs.group(2) + '@' + m_srs.group(1) - logging.info(self.mconn_id + "/FROM " + - "SRS envelope-sender replaced with: " + mailfrom - ) - self.env_from = mailfrom - m = g_re_domain.match(self.env_from) + if rcpt_addr in g_milter_whitelisted_rcpts: + return self.milter_action(action = 'continue') + m = g_re_domain.match(from_addr) if m == None: - logging.error(self.mconn_id + "/FROM " + - "Could not determine domain of 5321.from=" + self.env_from + return self.milter_action( + action = 'tmpfail', + reason = "Could not determine domain of from={}".format(from_addr) ) - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - self.env_from_domain = m.group(1) + from_domain = m.group(1) logging.debug(self.mconn_id + - "/FROM env_from_domain=" + self.env_from_domain + "/{0} from_domain={1}".format(self.queue_id, from_domain) ) - return Milter.CONTINUE - - def envrcpt(self, to, *str): - time_start = timer() - to = to.replace("<","") - to = to.replace(">","") - if to in g_milter_whitelisted_rcpts: - time_end = timer() - self.env_rcpts.append({ - "rcpt": to, "action":'whitelisted_rcpt',"time_start":time_start,"time_end":time_end - }) - return Milter.CONTINUE - m = g_re_domain.match(to) + m = g_re_domain.match(rcpt_addr) if m == None: - logging.error(self.mconn_id + "/RCPT " + - "Could not determine domain of 5321.to: " + to + return self.milter_action( + action = 'tmpfail', + reason = "Could not determine domain of rcpt={}".format(rcpt_addr) ) - self.setreply('450','4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL rcpt_domain = m.group(1) logging.debug(self.mconn_id + - "/RCPT rcpt_domain=" + rcpt_domain + "/{0} rcpt_domain={1}".format(self.queue_id, rcpt_domain) ) - time_end = None try: if g_milter_schema == True: # LDAP-ACL-Milter schema @@ -181,178 +161,298 @@ class LdapAclMilter(Milter.Base): # be ASCII-HEX encoded '\2a' (42 in decimal => answer to everything) # for proper use in LDAP queries. # In this case *@ cannot be a real address! - if re.match(r'^\*@.+$', self.env_from, re.IGNORECASE): - logging.info(self.mconn_id + "/RCPT REJECT " + - "Literal wildcard sender (*@) is not " + - "allowed in wildcard mode!" + if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): + return self.milter_action( + action = 'reject', + reason = "Literal wildcard sender (*@) is not " + + "allowed in wildcard mode!" ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' + if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): + return self.milter_action( + action = 'reject', + reason = "Literal wildcard recipient (*@) is not " + + "allowed in wildcard mode!" ) - return Milter.REJECT - if re.match(r'^\*@.+$', to, re.IGNORECASE): - logging.info(self.mconn_id + "/RCPT REJECT " + - "Literal wildcard recipient (*@) is not " + - "allowed in wildcard mode!" - ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT - self.ldap_conn.search(g_ldap_base, + g_ldap_conn.search(g_ldap_base, "(&" + auth_method + "(|"+ - "(allowedRcpts="+to+")"+ - "(allowedRcpts=\\2a@"+rcpt_domain+")"+ + "(allowedRcpts=" + rcpt_addr + ")"+ + "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ "(allowedRcpts=\\2a@\\2a)"+ ")"+ "(|"+ - "(allowedSenders="+self.env_from+")"+ - "(allowedSenders=\\2a@"+self.env_from_domain+")"+ + "(allowedSenders=" + from_addr + ")"+ + "(allowedSenders=\\2a@" + from_domain + ")"+ "(allowedSenders=\\2a@\\2a)"+ ")"+ ")", attributes=['policyID'] ) else: + # Wildcard-domain DISABLED # Asterisk must be ASCII-HEX encoded for LDAP queries - query_from = self.env_from.replace("*","\\2a") - query_to = to.replace("*","\\2a") - self.ldap_conn.search(g_ldap_base, + query_from = from_addr.replace("*","\\2a") + query_to = rcpt_addr.replace("*","\\2a") + g_ldap_conn.search(g_ldap_base, "(&" + auth_method + - "(allowedRcpts="+query_to+")" + - "(allowedSenders="+query_from+")" + + "(allowedRcpts=" + query_to + ")" + + "(allowedSenders=" + query_from + ")" + ")", attributes=['policyID'] ) - time_end = timer() - if len(self.ldap_conn.entries) == 0: + if len(g_ldap_conn.entries) == 0: # Policy not found in LDAP - self.env_rcpts.append({ - "rcpt": to, "action": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) if g_milter_expect_auth == True: - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from=" + self.env_from + ", 5321.rcpt=" + to + + logging.info(self.mconn_id + " " + "policy mismatch " + "from=" + from_addr + ", rcpt=" + rcpt_addr + ", auth_method=" + auth_method ) else: - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from=" + self.env_from + ", 5321.rcpt=" + to + logging.info(self.mconn_id + " " + "policy mismatch " + "from=" + from_addr + ", rcpt=" + rcpt_addr ) if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT REJECT " - + g_milter_reject_message + return self.milter_action( + action = 'reject', + reason = "policy not found!" ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT else: - logging.info(self.mconn_id + "/RCPT TEST_MODE " + + logging.info(self.mconn_id + " TEST_MODE " + g_milter_reject_message ) - return Milter.CONTINUE - elif len(self.ldap_conn.entries) == 1: + elif len(g_ldap_conn.entries) == 1: # Policy found in LDAP, but which one? - entry = self.ldap_conn.entries[0] + entry = g_ldap_conn.entries[0] logging.info(self.mconn_id + - "/RCPT Policy match: " + entry.policyID.value + " Policy match: " + entry.policyID.value ) - elif len(self.ldap_conn.entries) > 1: + elif len(g_ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! - logging.warn(self.mconn_id + "/RCPT More than one policies found! "+ - "5321.from=" + self.env_from + ", 5321.rcpt=" + to + - ", auth_method=" + auth_method + logging.warning(self.mconn_id + " More than one policies found! "+ + "from=" + from_addr + ", rcpt=" + rcpt_addr + + ", auth_method=" + auth_method ) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' - ) - return Milter.REJECT + return self.milter_action(action = 'reject') else: # Custom LDAP schema # 'build' a LDAP query per recipient # replace all placeholders in query templates - query = g_ldap_query.replace("%rcpt%",to) - query = query.replace("%from%", self.env_from) + query = g_ldap_query.replace("%rcpt%", rcpt_addr) + query = query.replace("%from%", from_addr) query = query.replace("%client_addr%", self.client_addr) query = query.replace("%sasl_user%", self.sasl_user) - query = query.replace("%from_domain%", self.env_from_domain) + query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) - logging.debug(self.mconn_id + "/RCPT " + query) - self.ldap_conn.search(g_ldap_base, query) - time_end = timer() - if len(self.ldap_conn.entries) == 0: - self.env_rcpts.append({ - "rcpt": to, "action": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) - logging.info(self.mconn_id + "/RCPT " + "policy mismatch " - "5321.from: " + self.env_from + " and 5321.rcpt: " + to + logging.debug(self.mconn_id + " " + query) + g_ldap_conn.search(g_ldap_base, query) + if len(g_ldap_conn.entries) == 0: + logging.info(self.mconn_id + " " + "policy mismatch " + "from: " + from_addr + " and rcpt: " + rcpt_addr ) if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT REJECT " + g_milter_reject_message) - self.setreply('550','5.7.1', - g_milter_reject_message + ' (' + self.mconn_id + ')' + return self.milter_action( + action = 'reject', + reason = 'policy mismatch' ) - return Milter.REJECT else: - logging.info(self.mconn_id + "/RCPT TEST_MODE " + + logging.info(self.mconn_id + " TEST_MODE " + g_milter_reject_message ) - return Milter.CONTINUE except LDAPOperationResult as e: - logging.warn(self.mconn_id + "/RCPT LDAP: " + str(e)) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL + logging.error(self.mconn_id + " LDAP: " + str(e)) + return self.milter_action(action = 'tmpfail') except: - logging.error(self.mconn_id + "/RCPT LDAP: " + traceback.format_exc()) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - self.env_rcpts.append({ - "rcpt": to, "action":'pass',"time_start":time_start,"time_end":time_end - }) - return Milter.CONTINUE + logging.error(self.mconn_id + " LDAP: " + traceback.format_exc()) + return self.milter_action(action = 'tmpfail') + return self.milter_action(action = 'continue') - def data(self): - # A queue-id will be generated after the first accepted RCPT TO - # and therefore not available until DATA command - self.queue_id = self.getsymval('i') - try: - for rcpt in self.env_rcpts: - duration = rcpt['time_end'] - rcpt['time_start'] - logging.info(self.mconn_id + "/DATA " + self.queue_id + - ": 5321.from=" + self.env_from + " 5321.rcpt=" + - rcpt['rcpt'] + " action=" + rcpt['action'] + - " duration=" + str(duration) + "sec." + # Not registered/used callbacks + @Milter.nocallback + def eoh(self): + return self.milter_action(action = 'continue') + @Milter.nocallback + def body(self, chunk): + return self.milter_action(action = 'continue') + + def connect(self, IPname, family, hostaddr): + self.reset() + self.proto_stage = 'CONNECT' + self.client_addr = hostaddr[0] + logging.debug(self.mconn_id + + "/CONNECT client_addr=[" + self.client_addr + "]:" + str(hostaddr[1]) + ) + return self.milter_action(action = 'continue') + + def envfrom(self, mailfrom, *str): + self.reset() + self.proto_stage = 'FROM' + if g_milter_expect_auth: + try: + # this may fail, if no x509 client certificate was used. + # postfix only passes this macro to milters if the TLS connection + # with the authenticating client was trusted in a x509 manner! + # http://postfix.1071664.n5.nabble.com/verification-levels-and-Milter-tp91634p91638.html + # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ + x509_subject = self.getsymval('{cert_subject}') + if x509_subject != None: + self.x509_subject = x509_subject + logging.debug(self.mconn_id + "/FROM x509_subject=" + self.x509_subject) + else: + logging.debug(self.mconn_id + "/FROM No x509_subject registered") + x509_issuer = self.getsymval('{cert_issuer}') + if x509_issuer != None: + self.x509_issuer = x509_issuer + logging.debug(self.mconn_id + "/FROM x509_issuer=" + self.x509_issuer) + else: + logging.debug(self.mconn_id + "/FROM No x509_issuer registered") + except: + logging.error(self.mconn_id + "/FROM x509 " + traceback.format_exc()) + try: + # this may fail, if no SASL authentication preceded + sasl_user = self.getsymval('{auth_authen}') + if sasl_user != None: + self.sasl_user = sasl_user + logging.debug(self.mconn_id + "/FROM sasl_user=" + self.sasl_user) + else: + logging.debug(self.mconn_id + "/FROM No sasl_user registered") + except: + logging.error(self.mconn_id + "/FROM sasl_user " + traceback.format_exc()) + logging.info(self.mconn_id + "/FROM auth: " + + "client_ip={0}, x509_subject={1}, x509_issuer={2}, sasl_user={3}".format( + self.client_addr, self.x509_subject, self.x509_issuer, self.sasl_user ) - except: - logging.warn(self.mconn_id + "/DATA " + self.queue_id + - ": " + traceback.format_exc()) - self.setreply('451', '4.7.1', g_milter_tmpfail_message) - return Milter.TEMPFAIL - return Milter.CONTINUE + ) + mailfrom = mailfrom.replace("<","") + mailfrom = mailfrom.replace(">","") + # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) + # Strip out Simple Private Signature (PRVS) + mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) + # SRS (https://www.libsrs2.org/srs/srs.pdf) + m_srs = g_re_srs.match(mailfrom) + if m_srs != None: + logging.info(self.mconn_id + "/FROM " + + "Found SRS-encoded envelope-sender: " + mailfrom + ) + mailfrom = m_srs.group(2) + '@' + m_srs.group(1) + logging.info(self.mconn_id + "/FROM " + + "SRS envelope-sender replaced with: " + mailfrom + ) + self.env_from = mailfrom.lower() + logging.debug(self.mconn_id + "/FROM 5321.from={}".format(self.env_from)) + m = g_re_domain.match(self.env_from) + if m == None: + return self.milter_action( + action = 'tmpfail', + reason = "Could not determine domain of 5321.from=" + self.env_from + ) + self.env_from_domain = m.group(1) + logging.debug(self.mconn_id + + "/FROM 5321.from_domain={}".format(self.env_from_domain) + ) + return self.milter_action(action = 'continue') + + def envrcpt(self, to, *str): + self.proto_stage = 'RCPT' + to = to.replace("<","") + to = to.replace(">","") + to = to.lower() + logging.debug(self.mconn_id + + "/RCPT env_rcpt={}".format(to) + ) + if g_milter_dkim_enabled: + # Collect all envelope-recipients for later + # investigation (EOM). Do not perform any + # policy action at this protocol phase. + self.env_rcpts.append(to) + else: + return self.check_policy(self.env_from, to) + return self.milter_action(action = 'continue') + + def header(self, hname, hval): + self.proto_stage = 'HDR' + self.queue_id = self.getsymval('i') + if g_milter_dkim_enabled == True: + # Parse RFC-5322-From header + if(hname.lower() == "From".lower()): + hdr_5322_from = email.utils.parseaddr(hval) + self.hdr_from = hdr_5322_from[1].lower() + m = re.match(g_re_domain, self.hdr_from) + if m is None: + return self.milter_action( + action = 'reject', + reason = "Could not determine domain-part of 5322.from=" + self.hdr_from + ) + self.hdr_from_domain = m.group(1) + logging.info(self.mconn_id + "/" + str(self.queue_id) + + "/HDR: 5322.from={0}, 5322.from_domain={1}".format( + self.hdr_from, self.hdr_from_domain + ) + ) + # Parse RFC-7601 Authentication-Results header + elif(hname.lower() == "Authentication-Results".lower()): + ar = None + try: + ar = authres.AuthenticationResultsHeader.parse( + "{0}: {1}".format(hname, hval) + ) + if ar.authserv_id.lower() == g_milter_trusted_authservid.lower(): + for ar_result in ar.results: + if ar_result.method == 'dkim': + if ar_result.result == 'pass': + self.passed_dkim_results.append({ + "sdid": ar_result.header_d.lower() + }) + logging.debug(self.mconn_id + "/" + str(self.queue_id) + + "/HDR: DKIM passed SDID {0}".format(ar_result.header_d) + ) + self.dkim_valid = True + else: + logging.debug(self.mconn_id + "/" + str(self.queue_id) + + "/HDR: Ignoring authentication results of {0}".format(ar.authserv_id) + ) + except Exception as e: + logging.info(self.mconn_id + "/" + str(self.queue_id) + + "/HDR: AR-parse exception: {0}".format(str(e)) + ) + return self.milter_action(action = 'continue') def eom(self): - # EOM is not optional and thus, always called by MTA - time_end = timer() - duration = time_end - self.time_start - logging.info(self.mconn_id + "/EOM " + self.queue_id + - " processed in " + str(duration) + " sec." - ) - return Milter.CONTINUE + self.proto_stage = 'EOM' + if g_milter_dkim_enabled == True: + accept_message = True + for rcpt in self.env_rcpts: + logging.debug(self.mconn_id + + "/{0}/EOM rcpt={1}".format(self.queue_id, rcpt) + ) + # Check 5321.sender against policy + ret = self.check_policy(self.env_from, rcpt) + if ret != Milter.CONTINUE: + if self.dkim_valid: + # Check 5322.sender against policy + ret = self.check_policy(self.hdr_from, rcpt) + if ret != Milter.CONTINUE: + accept_message = False + else: + accept_message = False + if accept_message == False: + return self.milter_action( + action = 'reject', + reason = 'sender unauthorized!' + ) + return self.milter_action(action = 'continue') def abort(self): # Client disconnected prematurely - return Milter.CONTINUE + self.proto_stage = 'ABORT' + return self.milter_action(action = 'continue') def close(self): # Always called, even when abort is called. # Clean up any external resources here. - return Milter.CONTINUE + self.proto_stage = 'CLOSE' + return self.milter_action(action = 'continue') if __name__ == "__main__": try: @@ -377,7 +477,7 @@ if __name__ == "__main__": if re.match(r'^reject|permit$',os.environ['MILTER_DEFAULT_POLICY'], re.IGNORECASE): g_milter_default_policy = str(os.environ['MILTER_DEFAULT_POLICY']).lower() else: - logging.warn("MILTER_DEFAULT_POLICY invalid value: " + + logging.warning("MILTER_DEFAULT_POLICY invalid value: " + os.environ['MILTER_DEFAULT_POLICY'] ) if 'MILTER_NAME' in os.environ: @@ -420,7 +520,6 @@ if __name__ == "__main__": if 'MILTER_WHITELISTED_RCPTS' in os.environ: # A blank separated list is expected whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] -# for whitelisted_rcpt in whitelisted_rcpts_str.split(): for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): if g_re_email.match(whitelisted_rcpt) == None: logging.error( @@ -431,6 +530,15 @@ if __name__ == "__main__": else: logging.info("ENV[MILTER_WHITELISTED_RCPTS]: " + whitelisted_rcpt) g_milter_whitelisted_rcpts[whitelisted_rcpt] = {} + if 'MILTER_DKIM_ENABLED' in os.environ: + g_milter_dkim_enabled = True + if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: + g_milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() + logging.info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format(g_milter_trusted_authservid)) + else: + logging.error("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") + sys.exit(1) + logging.info("ENV[MILTER_DKIM_ENABLED]: {0}".format(g_milter_dkim_enabled)) set_config_parameter("RESTARTABLE_SLEEPTIME", 2) set_config_parameter("RESTARTABLE_TRIES", 2) server = Server(g_ldap_server, get_info=NONE) diff --git a/docker-build.sh b/docker-build.sh deleted file mode 100755 index 0e5adf5..0000000 --- a/docker-build.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/sh - -BRANCH="$(/usr/bin/git branch|/bin/grep \*|/usr/bin/awk {'print $2'})" -VERSION="$(/bin/cat VERSION)" -BASEOS="$(/bin/cat BASEOS)" -GO="" - -while getopts g opt -do - case $opt in - g) GO="go";; - esac -done - -if [ -z "${GO}" ] ; then - echo "Building ldap-acl-milter@docker on '${BASEOS}' for version '${VERSION}' in branch '${BRANCH}'!" - echo "GO serious with '-g'!" - exit 1 -fi - -IMAGES="ldap-acl-milter" - -for IMAGE in ${IMAGES}; do - /usr/bin/docker build \ - -t "${IMAGE}:${BRANCH}" \ - -f "docker/${BASEOS}/Dockerfile" . -done diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5f6231b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +authres<2 +pymilter<2 +ldap3<3 \ No newline at end of file diff --git a/tests/miltertest-ip-conn_reuse.lua b/tests/miltertest-ip-conn_reuse.lua new file mode 100644 index 0000000..805df31 --- /dev/null +++ b/tests/miltertest-ip-conn_reuse.lua @@ -0,0 +1,90 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- CONNECTION REUSE + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip2@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "conn-reused-QID") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-ip-multiple_rcpts.lua b/tests/miltertest-ip-multiple_rcpts.lua new file mode 100644 index 0000000..0432ae4 --- /dev/null +++ b/tests/miltertest-ip-multiple_rcpts.lua @@ -0,0 +1,66 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ipx@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- FIRST 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT1-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT1-reject") +end + +-- SECOND 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT2-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT2-reject") +end + +-- SET RCPT-MACRO +mt.macro(conn, SMFIC_RCPT, "i", "some-queue-id") + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-ip.lua b/tests/miltertest-ip.lua new file mode 100644 index 0000000..49b6629 --- /dev/null +++ b/tests/miltertest-ip.lua @@ -0,0 +1,52 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-sasl.lua b/tests/miltertest-sasl.lua new file mode 100644 index 0000000..dfccb7c --- /dev/null +++ b/tests/miltertest-sasl.lua @@ -0,0 +1,53 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user1") +if mt.mailfrom(conn, "tester@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-x509.lua b/tests/miltertest-x509.lua new file mode 100644 index 0000000..6a8920a --- /dev/null +++ b/tests/miltertest-x509.lua @@ -0,0 +1,54 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x509-subject") +-- mt.macro(conn, SMFIC_MAIL, "{cert_subject}", "x509-subject") +if mt.mailfrom(conn, "tester-x509@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From f6796d06ec601a9947fd15720b431b9e388ea909 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 13 Feb 2022 02:29:42 +0100 Subject: [PATCH 02/15] more exception handling and logging --- app/ldap-acl-milter.py | 155 ++++++++++++++++++++---------------- tests/miltertest-noauth.lua | 53 ++++++++++++ 2 files changed, 140 insertions(+), 68 deletions(-) create mode 100644 tests/miltertest-noauth.lua diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index 1e3aa99..466bacb 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -3,6 +3,7 @@ import Milter from ldap3 import ( Server, Connection, NONE, set_config_parameter ) +from ldap3.core.exceptions import LDAPException import sys import traceback import os @@ -39,6 +40,16 @@ g_milter_dkim_enabled = False g_milter_trusted_authservid = None g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") +class LamException(Exception): + def __init__(self, message="General exception message"): + self.message = message + +class LamSoftException(LamException): + pass + +class LamHardException(LamException): + pass + class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): @@ -48,15 +59,15 @@ class LdapAclMilter(Milter.Base): def reset(self): self.proto_stage = 'proto-stage' self.env_from = None - self.env_from_domain = None self.sasl_user = None self.x509_subject = None self.x509_issuer = None - self.queue_id = 'qid-na' + self.queue_id = 'invalid' self.env_rcpts = [] self.hdr_from = None self.hdr_from_domain = None self.dkim_valid = False + self.dkim_aligned = False self.passed_dkim_results = [] logging.debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 @@ -90,8 +101,8 @@ class LdapAclMilter(Milter.Base): if 'message' in kwargs: message = kwargs['message'] # prepend queue-id to message if it´s already available (DATA and later) - if self.queue_id: - message = "queue_id: {0} - {1}".format(self.queue_id, message) + if self.queue_id != 'invalid': + message = " queue_id: {0} - {1}".format(self.queue_id, message) # append reason to message if 'reason' in kwargs: message = "{0} - reason: {1}".format(message, kwargs['reason']) @@ -103,29 +114,26 @@ class LdapAclMilter(Milter.Base): return smfir def check_policy(self, from_addr, rcpt_addr): - logging.debug(self.mconn_id + - " /CHECK_POLICY/{0} from={1} rcpt={2}".format( + logging.info(self.mconn_id + + "/{0} from={1} rcpt={2}".format( self.proto_stage, from_addr, rcpt_addr ) ) - if rcpt_addr in g_milter_whitelisted_rcpts: - return self.milter_action(action = 'continue') m = g_re_domain.match(from_addr) if m == None: - return self.milter_action( - action = 'tmpfail', - reason = "Could not determine domain of from={}".format(from_addr) + logging.info(self.mconn_id + + "/{0} Could not determine domain of from={1}".format( + self.proto_stage, from_addr + ) ) + raise LamSoftException() from_domain = m.group(1) logging.debug(self.mconn_id + "/{0} from_domain={1}".format(self.queue_id, from_domain) ) m = g_re_domain.match(rcpt_addr) if m == None: - return self.milter_action( - action = 'tmpfail', - reason = "Could not determine domain of rcpt={}".format(rcpt_addr) - ) + raise LamSoftException("Could not determine domain of rcpt={}".format(rcpt_addr)) rcpt_domain = m.group(1) logging.debug(self.mconn_id + "/{0} rcpt_domain={1}".format(self.queue_id, rcpt_domain) @@ -162,16 +170,14 @@ class LdapAclMilter(Milter.Base): # for proper use in LDAP queries. # In this case *@ cannot be a real address! if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): - return self.milter_action( - action = 'reject', - reason = "Literal wildcard sender (*@) is not " + - "allowed in wildcard mode!" + raise LamHardException( + "Literal wildcard sender (*@) is not " + + "allowed in wildcard mode!" ) if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): - return self.milter_action( - action = 'reject', - reason = "Literal wildcard recipient (*@) is not " + - "allowed in wildcard mode!" + raise LamHardException( + "Literal wildcard recipient (*@) is not " + + "allowed in wildcard mode!" ) g_ldap_conn.search(g_ldap_base, "(&" + @@ -214,10 +220,7 @@ class LdapAclMilter(Milter.Base): "from=" + from_addr + ", rcpt=" + rcpt_addr ) if g_milter_mode == 'reject': - return self.milter_action( - action = 'reject', - reason = "policy not found!" - ) + raise LamHardException("policy not found!") else: logging.info(self.mconn_id + " TEST_MODE " + g_milter_reject_message @@ -226,7 +229,7 @@ class LdapAclMilter(Milter.Base): # Policy found in LDAP, but which one? entry = g_ldap_conn.entries[0] logging.info(self.mconn_id + - " Policy match: " + entry.policyID.value + "/{0} Policy match: {1}".format(self.proto_stage, entry.policyID.value) ) elif len(g_ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! @@ -234,7 +237,7 @@ class LdapAclMilter(Milter.Base): "from=" + from_addr + ", rcpt=" + rcpt_addr + ", auth_method=" + auth_method ) - return self.milter_action(action = 'reject') + raise LamHardException("More than one policies found!") else: # Custom LDAP schema # 'build' a LDAP query per recipient @@ -252,20 +255,14 @@ class LdapAclMilter(Milter.Base): "from: " + from_addr + " and rcpt: " + rcpt_addr ) if g_milter_mode == 'reject': - return self.milter_action( - action = 'reject', - reason = 'policy mismatch' - ) + raise LamHardException("policy mismatch") else: logging.info(self.mconn_id + " TEST_MODE " + g_milter_reject_message ) - except LDAPOperationResult as e: + except LDAPException as e: logging.error(self.mconn_id + " LDAP: " + str(e)) - return self.milter_action(action = 'tmpfail') - except: - logging.error(self.mconn_id + " LDAP: " + traceback.format_exc()) - return self.milter_action(action = 'tmpfail') + raise LamSoftException(" LDAP: " + str(e)) from e; return self.milter_action(action = 'continue') # Not registered/used callbacks @@ -347,10 +344,6 @@ class LdapAclMilter(Milter.Base): action = 'tmpfail', reason = "Could not determine domain of 5321.from=" + self.env_from ) - self.env_from_domain = m.group(1) - logging.debug(self.mconn_id + - "/FROM 5321.from_domain={}".format(self.env_from_domain) - ) return self.milter_action(action = 'continue') def envrcpt(self, to, *str): @@ -361,13 +354,23 @@ class LdapAclMilter(Milter.Base): logging.debug(self.mconn_id + "/RCPT env_rcpt={}".format(to) ) + if to in g_milter_whitelisted_rcpts: + return self.milter_action(action = 'continue') if g_milter_dkim_enabled: # Collect all envelope-recipients for later # investigation (EOM). Do not perform any # policy action at this protocol phase. self.env_rcpts.append(to) else: - return self.check_policy(self.env_from, to) + try: + return self.check_policy(self.env_from, to) + except LamSoftException as e: + return self.milter_action(action = 'tmpfail') + except LamHardException as e: + return self.milter_action( + action = 'reject', + reason = e.message + ) return self.milter_action(action = 'continue') def header(self, hname, hval): @@ -399,13 +402,11 @@ class LdapAclMilter(Milter.Base): ) if ar.authserv_id.lower() == g_milter_trusted_authservid.lower(): for ar_result in ar.results: - if ar_result.method == 'dkim': - if ar_result.result == 'pass': - self.passed_dkim_results.append({ - "sdid": ar_result.header_d.lower() - }) + if ar_result.method.lower() == 'dkim': + if ar_result.result.lower() == 'pass': + self.passed_dkim_results.append(ar_result.header_d.lower()) logging.debug(self.mconn_id + "/" + str(self.queue_id) + - "/HDR: DKIM passed SDID {0}".format(ar_result.header_d) + "/HDR: dkim=pass sdid={0}".format(ar_result.header_d) ) self.dkim_valid = True else: @@ -420,27 +421,45 @@ class LdapAclMilter(Milter.Base): def eom(self): self.proto_stage = 'EOM' - if g_milter_dkim_enabled == True: - accept_message = True + if g_milter_dkim_enabled: + if self.dkim_valid: + # There is at least one valid DKIM signature! + # Check if one of them is also aligned + for passed_dkim_sdid in self.passed_dkim_results: + if self.hdr_from_domain.lower() == passed_dkim_sdid.lower(): + self.dkim_aligned = True + logging.info(self.mconn_id + "/" + str(self.queue_id) + + "/EOM: Found aligned DKIM signature for SDID: {0}".format( + passed_dkim_sdid + ) + ) + reject_message = False for rcpt in self.env_rcpts: - logging.debug(self.mconn_id + - "/{0}/EOM rcpt={1}".format(self.queue_id, rcpt) - ) - # Check 5321.sender against policy - ret = self.check_policy(self.env_from, rcpt) - if ret != Milter.CONTINUE: - if self.dkim_valid: - # Check 5322.sender against policy - ret = self.check_policy(self.hdr_from, rcpt) - if ret != Milter.CONTINUE: - accept_message = False + try: + # Check 5321.sender against policy + self.check_policy(self.env_from, rcpt) + except LamSoftException as e: + return self.milter_action(action = 'tmpfail') + except LamHardException as e: + if self.dkim_aligned: + try: + # Check 5322.sender against policy + self.check_policy(self.hdr_from, rcpt) + logging.info(self.mconn_id + + "/{0}/{1} from={2} authorized by DKIM signature".format( + self.queue_id, self.proto_stage, self.hdr_from + ) + ) + except LamHardException as e: + reject_message = True else: - accept_message = False - if accept_message == False: - return self.milter_action( - action = 'reject', - reason = 'sender unauthorized!' - ) + reject_message = True + + if reject_message: + return self.milter_action( + action = 'reject', + reason = 'EOM - Policy mismatch! All recipients were rejected!' + ) return self.milter_action(action = 'continue') def abort(self): diff --git a/tests/miltertest-noauth.lua b/tests/miltertest-noauth.lua new file mode 100644 index 0000000..77651ec --- /dev/null +++ b/tests/miltertest-noauth.lua @@ -0,0 +1,53 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.255.255.254") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-noauth@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then +-- if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From 2d89583a0751d09b800fa7521c49817505ed398e Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sat, 19 Feb 2022 18:48:58 +0100 Subject: [PATCH 03/15] fixed test-mode --- app/ldap-acl-milter.py | 123 +++++++++++++++++++++------------------- tests/miltertest-ip.lua | 12 ++-- 2 files changed, 72 insertions(+), 63 deletions(-) diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index 466bacb..ba38363 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -1,4 +1,3 @@ -from argparse import Action import Milter from ldap3 import ( Server, Connection, NONE, set_config_parameter @@ -29,7 +28,6 @@ g_ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' g_re_domain = re.compile(r'^\S*@(\S+)$') # http://emailregex.com/ -> Python g_re_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") -g_loglevel = logging.INFO g_milter_mode = 'test' g_milter_default_policy = 'reject' g_milter_schema = False @@ -57,7 +55,7 @@ class LdapAclMilter(Milter.Base): self.client_addr = None def reset(self): - self.proto_stage = 'proto-stage' + self.proto_stage = 'invalid' self.env_from = None self.sasl_user = None self.x509_subject = None @@ -129,14 +127,18 @@ class LdapAclMilter(Milter.Base): raise LamSoftException() from_domain = m.group(1) logging.debug(self.mconn_id + - "/{0} from_domain={1}".format(self.queue_id, from_domain) + "/{0} from_domain={1}".format(self.proto_stage, from_domain) ) m = g_re_domain.match(rcpt_addr) if m == None: - raise LamSoftException("Could not determine domain of rcpt={}".format(rcpt_addr)) + raise LamHardException( + "/{0} Could not determine domain of rcpt={1}".format( + self.proto_stage, rcpt_addr + ) + ) rcpt_domain = m.group(1) logging.debug(self.mconn_id + - "/{0} rcpt_domain={1}".format(self.queue_id, rcpt_domain) + "/{0} rcpt_domain={1}".format(self.proto_stage, rcpt_domain) ) try: if g_milter_schema == True: @@ -219,17 +221,14 @@ class LdapAclMilter(Milter.Base): logging.info(self.mconn_id + " " + "policy mismatch " "from=" + from_addr + ", rcpt=" + rcpt_addr ) - if g_milter_mode == 'reject': - raise LamHardException("policy not found!") - else: - logging.info(self.mconn_id + " TEST_MODE " + - g_milter_reject_message - ) + raise LamHardException("policy mismatch!") elif len(g_ldap_conn.entries) == 1: # Policy found in LDAP, but which one? entry = g_ldap_conn.entries[0] logging.info(self.mconn_id + - "/{0} Policy match: {1}".format(self.proto_stage, entry.policyID.value) + "/{0} Policy match: {1}".format( + self.proto_stage, entry.policyID.value + ) ) elif len(g_ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! @@ -254,12 +253,7 @@ class LdapAclMilter(Milter.Base): logging.info(self.mconn_id + " " + "policy mismatch " "from: " + from_addr + " and rcpt: " + rcpt_addr ) - if g_milter_mode == 'reject': - raise LamHardException("policy mismatch") - else: - logging.info(self.mconn_id + " TEST_MODE " + - g_milter_reject_message - ) + raise LamHardException("policy mismatch") except LDAPException as e: logging.error(self.mconn_id + " LDAP: " + str(e)) raise LamSoftException(" LDAP: " + str(e)) from e; @@ -341,7 +335,7 @@ class LdapAclMilter(Milter.Base): m = g_re_domain.match(self.env_from) if m == None: return self.milter_action( - action = 'tmpfail', + action = 'reject', reason = "Could not determine domain of 5321.from=" + self.env_from ) return self.milter_action(action = 'continue') @@ -365,12 +359,14 @@ class LdapAclMilter(Milter.Base): try: return self.check_policy(self.env_from, to) except LamSoftException as e: - return self.milter_action(action = 'tmpfail') + if g_milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') except LamHardException as e: - return self.milter_action( - action = 'reject', - reason = e.message - ) + if g_milter_mode == 'reject': + return self.milter_action( + action = 'reject', + reason = e.message + ) return self.milter_action(action = 'continue') def header(self, hname, hval): @@ -439,7 +435,8 @@ class LdapAclMilter(Milter.Base): # Check 5321.sender against policy self.check_policy(self.env_from, rcpt) except LamSoftException as e: - return self.milter_action(action = 'tmpfail') + if g_milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') except LamHardException as e: if self.dkim_aligned: try: @@ -455,10 +452,10 @@ class LdapAclMilter(Milter.Base): else: reject_message = True - if reject_message: + if reject_message and g_milter_mode == 'reject': return self.milter_action( action = 'reject', - reason = 'EOM - Policy mismatch! All recipients were rejected!' + reason = 'EOM - Policy mismatch! Message was rejected for all recipients!' ) return self.milter_action(action = 'continue') @@ -475,29 +472,31 @@ class LdapAclMilter(Milter.Base): if __name__ == "__main__": try: + log_level = logging.INFO if 'LOG_LEVEL' in os.environ: if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.INFO + log_level = logging.INFO elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.WARN + log_level = logging.WARN elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.ERROR + log_level = logging.ERROR elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): - g_loglevel = logging.DEBUG + log_level = logging.DEBUG + log_format = '%(asctime)s: %(levelname)s %(message)s ' logging.basicConfig( - filename=None, # log to stdout - format='%(asctime)s: %(levelname)s %(message)s', - level=g_loglevel + filename = None, # log to stdout + format = log_format, + level = log_level ) if 'MILTER_MODE' in os.environ: if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): - g_milter_mode = os.environ['MILTER_MODE'] + g_milter_mode = os.environ['MILTER_MODE'].lower() if 'MILTER_DEFAULT_POLICY' in os.environ: if re.match(r'^reject|permit$',os.environ['MILTER_DEFAULT_POLICY'], re.IGNORECASE): g_milter_default_policy = str(os.environ['MILTER_DEFAULT_POLICY']).lower() else: - logging.warning("MILTER_DEFAULT_POLICY invalid value: " + - os.environ['MILTER_DEFAULT_POLICY'] + logging.warning("MILTER_DEFAULT_POLICY invalid value: {}" + .format(os.environ['MILTER_DEFAULT_POLICY']) ) if 'MILTER_NAME' in os.environ: g_milter_name = os.environ['MILTER_NAME'] @@ -508,7 +507,7 @@ if __name__ == "__main__": if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): g_milter_schema_wildcard_domain = True if 'LDAP_SERVER' not in os.environ: - logging.error("Missing ENV[LDAP_SERVER], e.g. " + g_ldap_server) + logging.error("Missing ENV[LDAP_SERVER], e.g. {}".format(g_ldap_server)) sys.exit(1) g_ldap_server = os.environ['LDAP_SERVER'] if 'LDAP_BINDDN' in os.environ: @@ -516,7 +515,7 @@ if __name__ == "__main__": if 'LDAP_BINDPW' in os.environ: g_ldap_bindpw = os.environ['LDAP_BINDPW'] if 'LDAP_BASE' not in os.environ: - logging.error("Missing ENV[LDAP_BASE], e.g. " + g_ldap_base) + logging.error("Missing ENV[LDAP_BASE], e.g. {}".format(g_ldap_base)) sys.exit(1) g_ldap_base = os.environ['LDAP_BASE'] if 'LDAP_QUERY' not in os.environ: @@ -542,43 +541,49 @@ if __name__ == "__main__": for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): if g_re_email.match(whitelisted_rcpt) == None: logging.error( - "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: " + - whitelisted_rcpt + "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" + .format(whitelisted_rcpt) ) sys.exit(1) else: - logging.info("ENV[MILTER_WHITELISTED_RCPTS]: " + whitelisted_rcpt) + logging.info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( + whitelisted_rcpt + )) g_milter_whitelisted_rcpts[whitelisted_rcpt] = {} if 'MILTER_DKIM_ENABLED' in os.environ: g_milter_dkim_enabled = True if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: g_milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() - logging.info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format(g_milter_trusted_authservid)) + logging.info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format( + g_milter_trusted_authservid + )) else: logging.error("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") sys.exit(1) logging.info("ENV[MILTER_DKIM_ENABLED]: {0}".format(g_milter_dkim_enabled)) - set_config_parameter("RESTARTABLE_SLEEPTIME", 2) - set_config_parameter("RESTARTABLE_TRIES", 2) - server = Server(g_ldap_server, get_info=NONE) - g_ldap_conn = Connection(server, - g_ldap_binddn, g_ldap_bindpw, - auto_bind=True, raise_exceptions=True, - client_strategy='RESTARTABLE' - ) - logging.info("Connected to LDAP-server: " + g_ldap_server) + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", 2) + server = Server(g_ldap_server, get_info=NONE) + g_ldap_conn = Connection(server, + g_ldap_binddn, g_ldap_bindpw, + auto_bind=True, raise_exceptions=True, + client_strategy='RESTARTABLE' + ) + logging.info("Connected to LDAP-server: " + g_ldap_server) + except LDAPException as e: + raise Exception("Connection to LDAP-server failed: {}".format(str(e))) from e timeout = 600 # Register to have the Milter factory create instances of your class: Milter.factory = LdapAclMilter # Tell the MTA which features we use flags = Milter.ADDHDRS Milter.set_flags(flags) - logging.info("Startup " + g_milter_name + - "@socket: " + g_milter_socket + - " in mode: " + g_milter_mode - ) + logging.info("Starting {0}@socket: {1} in mode {2}".format( + g_milter_name, g_milter_socket, g_milter_mode + )) Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) - logging.info("Shutdown " + g_milter_name) + logging.info("Shutdown {}".format(g_milter_name)) except: - logging.error("MAIN-EXCEPTION: " + traceback.format_exc()) + logging.error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) sys.exit(1) diff --git a/tests/miltertest-ip.lua b/tests/miltertest-ip.lua index 49b6629..fdcc892 100644 --- a/tests/miltertest-ip.lua +++ b/tests/miltertest-ip.lua @@ -16,8 +16,10 @@ mt.set_timeout(60) if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") end -- 5321.RCPT+MACROS @@ -25,8 +27,10 @@ mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") end -- 5322.HEADERS From 539b65e6f8641be43170d358b472a19fc4170192 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sat, 19 Feb 2022 23:32:18 +0100 Subject: [PATCH 04/15] unified logging; more milter-tests --- app/ldap-acl-milter.py | 234 ++++++++++-------- tests/miltertest-sasl-wildcard.lua | 53 ++++ tests/miltertest-x509_5321-fail_dkim-pass.lua | 53 ++++ 3 files changed, 237 insertions(+), 103 deletions(-) create mode 100644 tests/miltertest-sasl-wildcard.lua create mode 100644 tests/miltertest-x509_5321-fail_dkim-pass.lua diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index ba38363..da31b50 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -41,6 +41,8 @@ g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") class LamException(Exception): def __init__(self, message="General exception message"): self.message = message + def __str__(self): + return self.message class LamSoftException(LamException): pass @@ -54,6 +56,41 @@ class LdapAclMilter(Milter.Base): # client_addr gets overriden on any connect() self.client_addr = None + def do_log(self, **kwargs): + if 'level' not in kwargs: + print("do_log(): 'level' arg missing!") + sys.exit(1) + if 'log_message' not in kwargs: + print("do_log(): 'log_message' arg missing!") + sys.exit(1) + log_line = '' + if hasattr(self, 'mconn_id'): + log_line = "{}".format(self.mconn_id) + if self.queue_id != 'invalid': + log_line = "{0}/{1}".format(log_line, self.queue_id) + if self.proto_stage != 'invalid': + log_line = "{0}/{1}".format(log_line, self.proto_stage) + log_line = "{0} {1}".format(log_line, kwargs['log_message']) + if kwargs['level'] == 'error': + logging.error(log_line) + elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning': + logging.warning(log_line) + elif kwargs['level'] == 'info': + logging.info(log_line) + elif kwargs['level'] == 'debug': + logging.debug(log_line) + else: + print("do_log(): invalid 'level' {}".format(kwargs['level'])) + sys.exit(1) + def log_error(self, log_message): + self.do_log(level='error', log_message=log_message) + def log_warn(self, log_message): + self.do_log(level='warn', log_message=log_message) + def log_info(self, log_message): + self.do_log(level='info', log_message=log_message) + def log_debug(self, log_message): + self.do_log(level='debug', log_message=log_message) + def reset(self): self.proto_stage = 'invalid' self.env_from = None @@ -67,7 +104,7 @@ class LdapAclMilter(Milter.Base): self.dkim_valid = False self.dkim_aligned = False self.passed_dkim_results = [] - logging.debug("reset(): {}".format(self.__dict__)) + self.log_debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 self.mconn_id = g_milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) @@ -105,41 +142,36 @@ class LdapAclMilter(Milter.Base): if 'reason' in kwargs: message = "{0} - reason: {1}".format(message, kwargs['reason']) if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': + self.log_info("milter_action={0} message={1}".format( + kwargs['action'], message + )) self.setreply(smtp_code, smtp_ecode, message) - logging.info(self.mconn_id + "/" + - self.proto_stage + ": milter_action={0} message={1}".format(kwargs['action'], message) - ) return smfir - def check_policy(self, from_addr, rcpt_addr): - logging.info(self.mconn_id + - "/{0} from={1} rcpt={2}".format( - self.proto_stage, from_addr, rcpt_addr - ) - ) + def check_policy(self, **kwargs): + from_addr = kwargs['from_addr'] + rcpt_addr = kwargs['rcpt_addr'] + from_source = kwargs['from_source'] + self.log_info("check_policy: from={0} rcpt={1} from_source={2}".format( + from_addr, rcpt_addr, from_source + )) m = g_re_domain.match(from_addr) if m == None: - logging.info(self.mconn_id + - "/{0} Could not determine domain of from={1}".format( - self.proto_stage, from_addr - ) - ) + self.log_info("Could not determine domain of from={0}".format( + from_addr + )) raise LamSoftException() from_domain = m.group(1) - logging.debug(self.mconn_id + - "/{0} from_domain={1}".format(self.proto_stage, from_domain) - ) + self.log_debug("from_domain={}".format(from_domain)) m = g_re_domain.match(rcpt_addr) if m == None: raise LamHardException( - "/{0} Could not determine domain of rcpt={1}".format( - self.proto_stage, rcpt_addr + "Could not determine domain of rcpt={0}".format( + rcpt_addr ) ) rcpt_domain = m.group(1) - logging.debug(self.mconn_id + - "/{0} rcpt_domain={1}".format(self.proto_stage, rcpt_domain) - ) + self.log_debug("rcpt_domain={}".format(rcpt_domain)) try: if g_milter_schema == True: # LDAP-ACL-Milter schema @@ -161,9 +193,7 @@ class LdapAclMilter(Milter.Base): ) else: auth_method = auth_method.replace('%X509_AUTH%','') - logging.debug(self.mconn_id + - " auth_method: " + auth_method - ) + self.log_debug("auth_method: {}".format(auth_method)) if g_milter_schema_wildcard_domain == True: # The asterisk (*) character is in term of local part # RFC5322 compliant and expected as a wildcard literal in this code. @@ -213,29 +243,25 @@ class LdapAclMilter(Milter.Base): if len(g_ldap_conn.entries) == 0: # Policy not found in LDAP if g_milter_expect_auth == True: - logging.info(self.mconn_id + " " + "policy mismatch " - "from=" + from_addr + ", rcpt=" + rcpt_addr + - ", auth_method=" + auth_method - ) + self.log_info("policy mismatch from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + )) else: - logging.info(self.mconn_id + " " + "policy mismatch " - "from=" + from_addr + ", rcpt=" + rcpt_addr - ) + self.log_info("policy mismatch from={0} rcpt={1}".format( + from_addr, rcpt_addr + )) raise LamHardException("policy mismatch!") elif len(g_ldap_conn.entries) == 1: # Policy found in LDAP, but which one? entry = g_ldap_conn.entries[0] - logging.info(self.mconn_id + - "/{0} Policy match: {1}".format( - self.proto_stage, entry.policyID.value - ) - ) + self.log_info("policy match: {}".format( + entry.policyID.value + )) elif len(g_ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! - logging.warning(self.mconn_id + " More than one policies found! "+ - "from=" + from_addr + ", rcpt=" + rcpt_addr + - ", auth_method=" + auth_method - ) + self.log_warn("More than one policies found! from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + )) raise LamHardException("More than one policies found!") else: # Custom LDAP schema @@ -247,16 +273,16 @@ class LdapAclMilter(Milter.Base): query = query.replace("%sasl_user%", self.sasl_user) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) - logging.debug(self.mconn_id + " " + query) + self.log_debug("LDAP query: {}".format(query)) g_ldap_conn.search(g_ldap_base, query) if len(g_ldap_conn.entries) == 0: - logging.info(self.mconn_id + " " + "policy mismatch " - "from: " + from_addr + " and rcpt: " + rcpt_addr - ) + self.log_info("policy mismatch from={0} rcpt={1}".format( + from_addr, rcpt_addr + )) raise LamHardException("policy mismatch") except LDAPException as e: - logging.error(self.mconn_id + " LDAP: " + str(e)) - raise LamSoftException(" LDAP: " + str(e)) from e; + self.log_error("LDAP exception: {}".format(str(e))) + raise LamSoftException("LDAP exception: " + str(e)) from e; return self.milter_action(action = 'continue') # Not registered/used callbacks @@ -271,8 +297,8 @@ class LdapAclMilter(Milter.Base): self.reset() self.proto_stage = 'CONNECT' self.client_addr = hostaddr[0] - logging.debug(self.mconn_id + - "/CONNECT client_addr=[" + self.client_addr + "]:" + str(hostaddr[1]) + self.log_debug("client_addr={0}, client_port={1}".format( + self.client_addr, hostaddr[1]) ) return self.milter_action(action = 'continue') @@ -289,29 +315,29 @@ class LdapAclMilter(Milter.Base): x509_subject = self.getsymval('{cert_subject}') if x509_subject != None: self.x509_subject = x509_subject - logging.debug(self.mconn_id + "/FROM x509_subject=" + self.x509_subject) + self.log_debug("x509_subject={}".format(self.x509_subject)) else: - logging.debug(self.mconn_id + "/FROM No x509_subject registered") + self.log_debug("No x509_subject registered") x509_issuer = self.getsymval('{cert_issuer}') if x509_issuer != None: self.x509_issuer = x509_issuer - logging.debug(self.mconn_id + "/FROM x509_issuer=" + self.x509_issuer) + self.log_debug("x509_issuer={}".format(self.x509_issuer)) else: - logging.debug(self.mconn_id + "/FROM No x509_issuer registered") + self.log_debug("No x509_issuer registered") except: - logging.error(self.mconn_id + "/FROM x509 " + traceback.format_exc()) + self.log_error("x509 exception: {}".format(traceback.format_exc())) try: # this may fail, if no SASL authentication preceded sasl_user = self.getsymval('{auth_authen}') if sasl_user != None: self.sasl_user = sasl_user - logging.debug(self.mconn_id + "/FROM sasl_user=" + self.sasl_user) + self.log_debug("sasl_user={}".format(self.sasl_user)) else: - logging.debug(self.mconn_id + "/FROM No sasl_user registered") + self.log_debug("No sasl_user registered") except: - logging.error(self.mconn_id + "/FROM sasl_user " + traceback.format_exc()) - logging.info(self.mconn_id + "/FROM auth: " + - "client_ip={0}, x509_subject={1}, x509_issuer={2}, sasl_user={3}".format( + self.log_error("sasl_user exception: {}".format(traceback.format_exc())) + self.log_info( + "auth: client_ip={0} x509_subject={1} x509_issuer={2} sasl_user={3}".format( self.client_addr, self.x509_subject, self.x509_issuer, self.sasl_user ) ) @@ -323,20 +349,16 @@ class LdapAclMilter(Milter.Base): # SRS (https://www.libsrs2.org/srs/srs.pdf) m_srs = g_re_srs.match(mailfrom) if m_srs != None: - logging.info(self.mconn_id + "/FROM " + - "Found SRS-encoded envelope-sender: " + mailfrom - ) + self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) - logging.info(self.mconn_id + "/FROM " + - "SRS envelope-sender replaced with: " + mailfrom - ) + self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) self.env_from = mailfrom.lower() - logging.debug(self.mconn_id + "/FROM 5321.from={}".format(self.env_from)) + self.log_debug("5321.from={}".format(self.env_from)) m = g_re_domain.match(self.env_from) if m == None: return self.milter_action( action = 'reject', - reason = "Could not determine domain of 5321.from=" + self.env_from + reason = "Could not determine domain of 5321.from={}".format(self.env_from) ) return self.milter_action(action = 'continue') @@ -345,9 +367,7 @@ class LdapAclMilter(Milter.Base): to = to.replace("<","") to = to.replace(">","") to = to.lower() - logging.debug(self.mconn_id + - "/RCPT env_rcpt={}".format(to) - ) + self.log_debug("5321.rcpt={}".format(to)) if to in g_milter_whitelisted_rcpts: return self.milter_action(action = 'continue') if g_milter_dkim_enabled: @@ -357,7 +377,9 @@ class LdapAclMilter(Milter.Base): self.env_rcpts.append(to) else: try: - return self.check_policy(self.env_from, to) + return self.check_policy( + from_addr=self.env_from, rcpt_addr=to, from_source='5321.from' + ) except LamSoftException as e: if g_milter_mode == 'reject': return self.milter_action(action = 'tmpfail') @@ -367,6 +389,8 @@ class LdapAclMilter(Milter.Base): action = 'reject', reason = e.message ) + else: + self.log_info("TEST-Mode: {}".format(e.message)) return self.milter_action(action = 'continue') def header(self, hname, hval): @@ -384,11 +408,9 @@ class LdapAclMilter(Milter.Base): reason = "Could not determine domain-part of 5322.from=" + self.hdr_from ) self.hdr_from_domain = m.group(1) - logging.info(self.mconn_id + "/" + str(self.queue_id) + - "/HDR: 5322.from={0}, 5322.from_domain={1}".format( - self.hdr_from, self.hdr_from_domain - ) - ) + self.log_debug("5322.from={0}, 5322.from_domain={1}".format( + self.hdr_from, self.hdr_from_domain + )) # Parse RFC-7601 Authentication-Results header elif(hname.lower() == "Authentication-Results".lower()): ar = None @@ -401,62 +423,68 @@ class LdapAclMilter(Milter.Base): if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': self.passed_dkim_results.append(ar_result.header_d.lower()) - logging.debug(self.mconn_id + "/" + str(self.queue_id) + - "/HDR: dkim=pass sdid={0}".format(ar_result.header_d) - ) + self.log_debug("dkim=pass sdid={}".format(ar_result.header_d)) self.dkim_valid = True else: - logging.debug(self.mconn_id + "/" + str(self.queue_id) + - "/HDR: Ignoring authentication results of {0}".format(ar.authserv_id) + self.log_debug("Ignoring authentication results of {}".format( + ar.authserv_id) ) except Exception as e: - logging.info(self.mconn_id + "/" + str(self.queue_id) + - "/HDR: AR-parse exception: {0}".format(str(e)) - ) + self.log_info("AR-parse exception: {0}".format(str(e))) return self.milter_action(action = 'continue') def eom(self): self.proto_stage = 'EOM' if g_milter_dkim_enabled: + self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( + self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts + )) if self.dkim_valid: # There is at least one valid DKIM signature! # Check if one of them is also aligned for passed_dkim_sdid in self.passed_dkim_results: if self.hdr_from_domain.lower() == passed_dkim_sdid.lower(): self.dkim_aligned = True - logging.info(self.mconn_id + "/" + str(self.queue_id) + - "/EOM: Found aligned DKIM signature for SDID: {0}".format( - passed_dkim_sdid - ) - ) + self.log_info("Found aligned DKIM signature for SDID: {0}".format( + passed_dkim_sdid + )) reject_message = False for rcpt in self.env_rcpts: try: - # Check 5321.sender against policy - self.check_policy(self.env_from, rcpt) + # Check 5321.from against policy + self.check_policy( + from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from' + ) except LamSoftException as e: if g_milter_mode == 'reject': return self.milter_action(action = 'tmpfail') + else: + self.log_info("TEST-Mode: {}".format(e.message)) except LamHardException as e: if self.dkim_aligned: try: - # Check 5322.sender against policy - self.check_policy(self.hdr_from, rcpt) - logging.info(self.mconn_id + - "/{0}/{1} from={2} authorized by DKIM signature".format( - self.queue_id, self.proto_stage, self.hdr_from - ) + # Check 5322.from against policy + self.check_policy( + from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from' ) + self.log_info("5322.from={} authorized by DKIM signature".format( + self.hdr_from + )) except LamHardException as e: reject_message = True else: reject_message = True - if reject_message and g_milter_mode == 'reject': - return self.milter_action( - action = 'reject', - reason = 'EOM - Policy mismatch! Message was rejected for all recipients!' - ) + if reject_message: + if g_milter_mode == 'reject': + return self.milter_action( + action = 'reject', + reason = 'policy mismatch! Message rejected for all recipients!' + ) + else: + self.log_info( + "TEST-Mode: policy mismatch! Message would be rejected for all recipients!" + ) return self.milter_action(action = 'continue') def abort(self): diff --git a/tests/miltertest-sasl-wildcard.lua b/tests/miltertest-sasl-wildcard.lua new file mode 100644 index 0000000..3e27396 --- /dev/null +++ b/tests/miltertest-sasl-wildcard.lua @@ -0,0 +1,53 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user-wild") +if mt.mailfrom(conn, "tester-invalid@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "test-wildcard-qid") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-x509_5321-fail_dkim-pass.lua b/tests/miltertest-x509_5321-fail_dkim-pass.lua new file mode 100644 index 0000000..3cf833f --- /dev/null +++ b/tests/miltertest-x509_5321-fail_dkim-pass.lua @@ -0,0 +1,53 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x509-subject") +if mt.mailfrom(conn, "tester-x509-invalid@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.mailfrom() unexpected reply" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) ~= SMFIR_CONTINUE then + error "mt.rcptto() unexpected reply" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From a516ded2c81c297c73f00ca2fe6f83ce4db23e57 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 20 Feb 2022 00:29:51 +0100 Subject: [PATCH 05/15] ENV[MILTER_MAX_RCPT]: limits number of envelope recipients --- app/ldap-acl-milter.py | 59 ++++++++++++++++++++++-------------- tests/miltertest-ip-fail.lua | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 tests/miltertest-ip-fail.lua diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index da31b50..ceaa3b0 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -10,7 +10,6 @@ import logging import string import random import re -from timeit import default_timer as timer import email.utils import authres @@ -29,7 +28,6 @@ g_re_domain = re.compile(r'^\S*@(\S+)$') # http://emailregex.com/ -> Python g_re_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") g_milter_mode = 'test' -g_milter_default_policy = 'reject' g_milter_schema = False g_milter_schema_wildcard_domain = False # works only if g_milter_schema == True g_milter_expect_auth = False @@ -37,6 +35,8 @@ g_milter_whitelisted_rcpts = {} g_milter_dkim_enabled = False g_milter_trusted_authservid = None g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") +g_milter_max_rcpt_enabled = False +g_milter_max_rcpt = 1 class LamException(Exception): def __init__(self, message="General exception message"): @@ -152,9 +152,6 @@ class LdapAclMilter(Milter.Base): from_addr = kwargs['from_addr'] rcpt_addr = kwargs['rcpt_addr'] from_source = kwargs['from_source'] - self.log_info("check_policy: from={0} rcpt={1} from_source={2}".format( - from_addr, rcpt_addr, from_source - )) m = g_re_domain.match(from_addr) if m == None: self.log_info("Could not determine domain of from={0}".format( @@ -243,19 +240,23 @@ class LdapAclMilter(Milter.Base): if len(g_ldap_conn.entries) == 0: # Policy not found in LDAP if g_milter_expect_auth == True: - self.log_info("policy mismatch from={0} rcpt={1} auth_method={2}".format( - from_addr, rcpt_addr, auth_method - )) + self.log_info( + "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( + from_addr, from_source, rcpt_addr, auth_method + ) + ) else: - self.log_info("policy mismatch from={0} rcpt={1}".format( - from_addr, rcpt_addr - )) + self.log_info( + "policy mismatch: from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + ) raise LamHardException("policy mismatch!") elif len(g_ldap_conn.entries) == 1: # Policy found in LDAP, but which one? entry = g_ldap_conn.entries[0] - self.log_info("policy match: {}".format( - entry.policyID.value + self.log_info("policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source )) elif len(g_ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! @@ -276,10 +277,15 @@ class LdapAclMilter(Milter.Base): self.log_debug("LDAP query: {}".format(query)) g_ldap_conn.search(g_ldap_base, query) if len(g_ldap_conn.entries) == 0: - self.log_info("policy mismatch from={0} rcpt={1}".format( - from_addr, rcpt_addr - )) + self.log_info( + "policy mismatch from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + ) raise LamHardException("policy mismatch") + self.log_info("policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source + )) except LDAPException as e: self.log_error("LDAP exception: {}".format(str(e))) raise LamSoftException("LDAP exception: " + str(e)) from e; @@ -435,6 +441,12 @@ class LdapAclMilter(Milter.Base): def eom(self): self.proto_stage = 'EOM' + if g_milter_max_rcpt_enabled: + if len(self.env_rcpts) > int(g_milter_max_rcpt): + if g_milter_mode == 'reject': + return self.milter_action(action='reject', reason='Too many recipients!') + else: + self.do_log("TEST-Mode: Too many recipients!") if g_milter_dkim_enabled: self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts @@ -519,13 +531,6 @@ if __name__ == "__main__": if 'MILTER_MODE' in os.environ: if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): g_milter_mode = os.environ['MILTER_MODE'].lower() - if 'MILTER_DEFAULT_POLICY' in os.environ: - if re.match(r'^reject|permit$',os.environ['MILTER_DEFAULT_POLICY'], re.IGNORECASE): - g_milter_default_policy = str(os.environ['MILTER_DEFAULT_POLICY']).lower() - else: - logging.warning("MILTER_DEFAULT_POLICY invalid value: {}" - .format(os.environ['MILTER_DEFAULT_POLICY']) - ) if 'MILTER_NAME' in os.environ: g_milter_name = os.environ['MILTER_NAME'] if 'MILTER_SCHEMA' in os.environ: @@ -589,6 +594,14 @@ if __name__ == "__main__": logging.error("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") sys.exit(1) logging.info("ENV[MILTER_DKIM_ENABLED]: {0}".format(g_milter_dkim_enabled)) + if 'MILTER_MAX_RCPT_ENABLED' in os.environ: + g_milter_max_rcpt_enabled = True + if 'MILTER_MAX_RCPT' in os.environ: + if os.environ['MILTER_MAX_RCPT'].isnumeric(): + g_milter_max_rcpt = os.environ['MILTER_MAX_RCPT'] + else: + print("ENV[MILTER_MAX_RCPT] must be numeric!") + sys.exit(1) try: set_config_parameter("RESTARTABLE_SLEEPTIME", 2) set_config_parameter("RESTARTABLE_TRIES", 2) diff --git a/tests/miltertest-ip-fail.lua b/tests/miltertest-ip-fail.lua new file mode 100644 index 0000000..fc644f8 --- /dev/null +++ b/tests/miltertest-ip-fail.lua @@ -0,0 +1,56 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.6.6.6") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-ip-fail@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=fail header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file From 3f60e09a677fe286d2600695880464cbb10d3a71 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 21 Feb 2022 00:19:53 +0100 Subject: [PATCH 06/15] code re-structured --- app/lam_config.py | 104 +++++++++++ app/lam_logger.py | 34 ++++ app/lam_policy.py | 179 +++++++++++++++++++ app/lam_rex.py | 6 + app/ldap-acl-milter.py | 383 ++++++----------------------------------- 5 files changed, 379 insertions(+), 327 deletions(-) create mode 100644 app/lam_config.py create mode 100644 app/lam_logger.py create mode 100644 app/lam_policy.py create mode 100644 app/lam_rex.py diff --git a/app/lam_config.py b/app/lam_config.py new file mode 100644 index 0000000..aa5fa91 --- /dev/null +++ b/app/lam_config.py @@ -0,0 +1,104 @@ +import re +import os +from lam_rex import rex_email +from lam_logger import log_info + +class LamConfigException(Exception): + def __init__(self, message): + self.message = message + def __str__(self): + return self.message + +class LamConfig(): + def __init__(self): + self.milter_mode = 'test' + self.milter_name = 'ldap-acl-milter' + self.milter_socket = '/socket/{}'.format(self.milter_name) + self.milter_reject_message = 'Security policy violation!' + self.milter_tmpfail_message = 'Service temporarily not available! Please try again later.' + self.ldap_server = 'ldap://127.0.0.1:389' + self.ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' + self.ldap_bindpw = 'TopSecret;-)' + self.ldap_base = 'ou=lam,ou=services,dc=example,dc=org' + self.ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' + self.milter_schema = False + self.milter_schema_wildcard_domain = False # works only if milter_schema == True + self.milter_expect_auth = False + self.milter_whitelisted_rcpts = {} + self.milter_dkim_enabled = False + self.milter_trusted_authservid = None + self.milter_max_rcpt_enabled = False + self.milter_max_rcpt = 1 + + if 'MILTER_MODE' in os.environ: + if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): + self.milter_mode = os.environ['MILTER_MODE'].lower() + if 'MILTER_NAME' in os.environ: + self.milter_name = os.environ['MILTER_NAME'] + if 'MILTER_SCHEMA' in os.environ: + if re.match(r'^true$', os.environ['MILTER_SCHEMA'], re.IGNORECASE): + self.milter_schema = True + if 'MILTER_SCHEMA_WILDCARD_DOMAIN' in os.environ: + if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): + self.milter_schema_wildcard_domain = True + if 'LDAP_SERVER' not in os.environ: + raise LamConfigException("Missing ENV[LDAP_SERVER], e.g. {}".format(self.ldap_server)) + self.ldap_server = os.environ['LDAP_SERVER'] + if 'LDAP_BINDDN' in os.environ: + self.ldap_binddn = os.environ['LDAP_BINDDN'] + if 'LDAP_BINDPW' in os.environ: + self.ldap_bindpw = os.environ['LDAP_BINDPW'] + if 'LDAP_BASE' not in os.environ: + raise LamConfigException( + "Missing ENV[LDAP_BASE], e.g. {}".format(self.ldap_base) + ) + self.ldap_base = os.environ['LDAP_BASE'] + if 'LDAP_QUERY' not in os.environ: + if self.milter_schema == False: + raise LamConfigException( + "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" + ) + if 'LDAP_QUERY' in os.environ: + self.ldap_query = os.environ['LDAP_QUERY'] + if 'MILTER_SOCKET' in os.environ: + self.milter_socket = os.environ['MILTER_SOCKET'] + if 'MILTER_REJECT_MESSAGE' in os.environ: + self.milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: + self.milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] + if 'MILTER_EXPECT_AUTH' in os.environ: + if re.match(r'^true$', os.environ['MILTER_EXPECT_AUTH'], re.IGNORECASE): + self.milter_expect_auth = True + if 'MILTER_WHITELISTED_RCPTS' in os.environ: + # A blank separated list is expected + whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] + for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): + if rex_email.match(whitelisted_rcpt) == None: + raise LamConfigException( + "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" + .format(whitelisted_rcpt) + ) + else: + log_info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( + whitelisted_rcpt + )) + self.milter_whitelisted_rcpts[whitelisted_rcpt] = {} + if 'MILTER_DKIM_ENABLED' in os.environ: + self.milter_dkim_enabled = True + if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: + self.milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() + log_info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format( + self.milter_trusted_authservid + )) + else: + raise LamConfigException("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") + log_info("ENV[MILTER_DKIM_ENABLED]: {0}".format(self.milter_dkim_enabled)) + if 'MILTER_MAX_RCPT_ENABLED' in os.environ: + self.milter_max_rcpt_enabled = True + if 'MILTER_MAX_RCPT' in os.environ: + if os.environ['MILTER_MAX_RCPT'].isnumeric(): + self.milter_max_rcpt = os.environ['MILTER_MAX_RCPT'] + else: + raise LamConfigException("ENV[MILTER_MAX_RCPT] must be numeric!") + + diff --git a/app/lam_logger.py b/app/lam_logger.py new file mode 100644 index 0000000..5e651c3 --- /dev/null +++ b/app/lam_logger.py @@ -0,0 +1,34 @@ +import logging +import re +import os + +def init_logger(): + log_level = logging.INFO + if 'LOG_LEVEL' in os.environ: + if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.INFO + elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.WARN + elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.ERROR + elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.DEBUG + log_format = '%(asctime)s: %(levelname)s %(message)s ' + logging.basicConfig( + filename = None, # log to stdout + format = log_format, + level = log_level + ) + logging.info("Logger initialized") + +def log_info(message): + logging.info(message) + +def log_warning(message): + logging.warning(message) + +def log_error(message): + logging.error(message) + +def log_debug(message): + logging.debug(message) \ No newline at end of file diff --git a/app/lam_policy.py b/app/lam_policy.py new file mode 100644 index 0000000..bcb71b2 --- /dev/null +++ b/app/lam_policy.py @@ -0,0 +1,179 @@ +import re +from lam_logger import log_info, log_debug, log_error +from lam_rex import rex_domain +from ldap3 import ( + Server, Connection, NONE, set_config_parameter +) +from ldap3.core.exceptions import LDAPException + +class LamException(Exception): + def __init__(self, message="General exception message"): + self.message = message + def __str__(self) -> str: + return self.message + +class LamSoftException(LamException): + pass + +class LamHardException(LamException): + pass + +class LamPolicyBackendException(LamException): + pass + +class LamPolicyBackend(): + def __init__(self, lam_config): + self.config = lam_config + self.ldap_conn = None + try: + set_config_parameter("RESTARTABLE_SLEEPTIME", 2) + set_config_parameter("RESTARTABLE_TRIES", 2) + server = Server(self.config.ldap_server, get_info=NONE) + self.ldap_conn = Connection(server, + self.config.ldap_binddn, self.config.ldap_bindpw, + auto_bind=True, raise_exceptions=True, + client_strategy='RESTARTABLE' + ) + log_info("Connected to LDAP-server: {}".format(self.config.ldap_server)) + except LDAPException as e: + raise LamPolicyBackendException( + "Connection to LDAP-server failed: {}".format(str(e)) + ) from e + + def check_policy(self, **kwargs) -> str: + from_addr = kwargs['from_addr'] + rcpt_addr = kwargs['rcpt_addr'] + from_source = kwargs['from_source'] + lam_session = kwargs['lam_session'] + m = rex_domain.match(from_addr) + if m == None: + log_info("Could not determine domain of from={0}".format( + from_addr + )) + raise LamSoftException() + from_domain = m.group(1) + log_debug("from_domain={}".format(from_domain)) + m = rex_domain.match(rcpt_addr) + if m == None: + raise LamHardException( + "Could not determine domain of rcpt={0}".format( + rcpt_addr + ) + ) + rcpt_domain = m.group(1) + log_debug("rcpt_domain={}".format(rcpt_domain)) + try: + if self.config.milter_schema == True: + # LDAP-ACL-Milter schema + auth_method = '' + if self.config.milter_expect_auth == True: + auth_method = "(|(allowedClientAddr="+lam_session.client_addr+")%SASL_AUTH%%X509_AUTH%)" + if lam_session.sasl_user: + auth_method = auth_method.replace( + '%SASL_AUTH%',"(allowedSaslUser="+lam_session.sasl_user+")" + ) + else: + auth_method = auth_method.replace('%SASL_AUTH%','') + if lam_session.x509_subject and lam_session.x509_issuer: + auth_method = auth_method.replace('%X509_AUTH%', + "(&"+ + "(allowedx509subject="+lam_session.x509_subject+")"+ + "(allowedx509issuer="+lam_session.x509_issuer+")"+ + ")" + ) + else: + auth_method = auth_method.replace('%X509_AUTH%','') + log_debug("auth_method: {}".format(auth_method)) + if self.config.milter_schema_wildcard_domain == True: + # The asterisk (*) character is in term of local part + # RFC5322 compliant and expected as a wildcard literal in this code. + # As the asterisk character is special in LDAP context, thus it must + # be ASCII-HEX encoded '\2a' (42 in decimal => answer to everything) + # for proper use in LDAP queries. + # In this case *@ cannot be a real address! + if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): + raise LamHardException( + "Literal wildcard sender (*@) is not " + + "allowed in wildcard mode!" + ) + if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): + raise LamHardException( + "Literal wildcard recipient (*@) is not " + + "allowed in wildcard mode!" + ) + self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(|"+ + "(allowedRcpts=" + rcpt_addr + ")"+ + "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ + "(allowedRcpts=\\2a@\\2a)"+ + ")"+ + "(|"+ + "(allowedSenders=" + from_addr + ")"+ + "(allowedSenders=\\2a@" + from_domain + ")"+ + "(allowedSenders=\\2a@\\2a)"+ + ")"+ + ")", + attributes=['policyID'] + ) + else: + # Wildcard-domain DISABLED + # Asterisk must be ASCII-HEX encoded for LDAP queries + query_from = from_addr.replace("*","\\2a") + query_to = rcpt_addr.replace("*","\\2a") + self.ldap_conn.search(self.config.ldap_base, + "(&" + + auth_method + + "(allowedRcpts=" + query_to + ")" + + "(allowedSenders=" + query_from + ")" + + ")", + attributes=['policyID'] + ) + if len(self.ldap_conn.entries) == 0: + # Policy not found in LDAP + ex = "policy mismatch: from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + if self.config.milter_expect_auth == True: + ex = "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( + from_addr, from_source, rcpt_addr, auth_method + ) + raise LamHardException(ex) + elif len(self.ldap_conn.entries) == 1: + # Policy found in LDAP, but which one? + entry = self.ldap_conn.entries[0] + return "policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source + ) + elif len(self.ldap_conn.entries) > 1: + # Something went wrong!? There shouldn´t be more than one entries! + log_error("More than one policies found! from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + )) + raise LamHardException("More than one policies found!") + else: + # Custom LDAP schema + # 'build' a LDAP query per recipient + # replace all placeholders in query templates + query = self.config.ldap_query.replace("%rcpt%", rcpt_addr) + query = query.replace("%from%", from_addr) + query = query.replace("%client_addr%", lam_session.client_addr) + query = query.replace("%sasl_user%", lam_session.sasl_user) + query = query.replace("%from_domain%", from_domain) + query = query.replace("%rcpt_domain%", rcpt_domain) + log_debug("LDAP query: {}".format(query)) + self.ldap_conn.search(self.config.ldap_base, query) + if len(self.ldap_conn.entries) == 0: + log_info( + "policy mismatch from={0} from_src={1} rcpt={2}".format( + from_addr, from_source, rcpt_addr + ) + ) + raise LamHardException("policy mismatch") + return "policy match: '{0}' from_src={1}".format( + entry.policyID.value, from_source + ) + except LDAPException as e: + log_error("LDAP exception: {}".format(str(e))) + raise LamSoftException("LDAP exception: " + str(e)) from e diff --git a/app/lam_rex.py b/app/lam_rex.py new file mode 100644 index 0000000..7ea9942 --- /dev/null +++ b/app/lam_rex.py @@ -0,0 +1,6 @@ +import re + +rex_domain = re.compile(r'^\S*@(\S+)$') +# http://emailregex.com/ -> Python +rex_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") +rex_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") \ No newline at end of file diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index ceaa3b0..74686c5 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -1,54 +1,19 @@ import Milter -from ldap3 import ( - Server, Connection, NONE, set_config_parameter -) -from ldap3.core.exceptions import LDAPException import sys import traceback -import os -import logging import string import random import re import email.utils import authres +from lam_config import LamConfig, LamConfigException +from lam_rex import rex_domain, rex_srs +from lam_logger import init_logger, log_debug, log_info, log_warning, log_error +from lam_policy import LamPolicyBackend, LamPolicyBackendException, LamSoftException, LamHardException # Globals... -g_milter_name = 'ldap-acl-milter' -g_milter_socket = '/socket/' + g_milter_name -g_milter_reject_message = 'Security policy violation!' -g_milter_tmpfail_message = 'Service temporarily not available! Please try again later.' -g_ldap_conn = None -g_ldap_server = 'ldap://127.0.0.1:389' -g_ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' -g_ldap_bindpw = 'TopSecret;-)' -g_ldap_base = 'ou=users,dc=example,dc=org' -g_ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' -g_re_domain = re.compile(r'^\S*@(\S+)$') -# http://emailregex.com/ -> Python -g_re_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") -g_milter_mode = 'test' -g_milter_schema = False -g_milter_schema_wildcard_domain = False # works only if g_milter_schema == True -g_milter_expect_auth = False -g_milter_whitelisted_rcpts = {} -g_milter_dkim_enabled = False -g_milter_trusted_authservid = None -g_re_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") -g_milter_max_rcpt_enabled = False -g_milter_max_rcpt = 1 - -class LamException(Exception): - def __init__(self, message="General exception message"): - self.message = message - def __str__(self): - return self.message - -class LamSoftException(LamException): - pass - -class LamHardException(LamException): - pass +g_config = None +g_policy_backend = None class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread @@ -57,12 +22,6 @@ class LdapAclMilter(Milter.Base): self.client_addr = None def do_log(self, **kwargs): - if 'level' not in kwargs: - print("do_log(): 'level' arg missing!") - sys.exit(1) - if 'log_message' not in kwargs: - print("do_log(): 'log_message' arg missing!") - sys.exit(1) log_line = '' if hasattr(self, 'mconn_id'): log_line = "{}".format(self.mconn_id) @@ -72,13 +31,13 @@ class LdapAclMilter(Milter.Base): log_line = "{0}/{1}".format(log_line, self.proto_stage) log_line = "{0} {1}".format(log_line, kwargs['log_message']) if kwargs['level'] == 'error': - logging.error(log_line) + log_error(log_line) elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning': - logging.warning(log_line) + log_warning(log_line) elif kwargs['level'] == 'info': - logging.info(log_line) + log_info(log_line) elif kwargs['level'] == 'debug': - logging.debug(log_line) + log_debug(log_line) else: print("do_log(): invalid 'level' {}".format(kwargs['level'])) sys.exit(1) @@ -106,7 +65,7 @@ class LdapAclMilter(Milter.Base): self.passed_dkim_results = [] self.log_debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 - self.mconn_id = g_milter_name + ': ' + ''.join( + self.mconn_id = g_config.milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) @@ -118,12 +77,12 @@ class LdapAclMilter(Milter.Base): smtp_code = None smtp_ecode = None if kwargs['action'] == 'reject': - message = g_milter_reject_message + message = g_config.milter_reject_message smtp_code = '550' smtp_ecode = '5.7.1' smfir = Milter.REJECT elif kwargs['action'] == 'tmpfail': - message = g_milter_tmpfail_message + message = g_config.milter_tmpfail_message smtp_code = '450' smtp_ecode = '4.7.1' smfir = Milter.TEMPFAIL @@ -148,149 +107,6 @@ class LdapAclMilter(Milter.Base): self.setreply(smtp_code, smtp_ecode, message) return smfir - def check_policy(self, **kwargs): - from_addr = kwargs['from_addr'] - rcpt_addr = kwargs['rcpt_addr'] - from_source = kwargs['from_source'] - m = g_re_domain.match(from_addr) - if m == None: - self.log_info("Could not determine domain of from={0}".format( - from_addr - )) - raise LamSoftException() - from_domain = m.group(1) - self.log_debug("from_domain={}".format(from_domain)) - m = g_re_domain.match(rcpt_addr) - if m == None: - raise LamHardException( - "Could not determine domain of rcpt={0}".format( - rcpt_addr - ) - ) - rcpt_domain = m.group(1) - self.log_debug("rcpt_domain={}".format(rcpt_domain)) - try: - if g_milter_schema == True: - # LDAP-ACL-Milter schema - auth_method = '' - if g_milter_expect_auth == True: - auth_method = "(|(allowedClientAddr="+self.client_addr+")%SASL_AUTH%%X509_AUTH%)" - if self.sasl_user: - auth_method = auth_method.replace( - '%SASL_AUTH%',"(allowedSaslUser="+self.sasl_user+")" - ) - else: - auth_method = auth_method.replace('%SASL_AUTH%','') - if self.x509_subject and self.x509_issuer: - auth_method = auth_method.replace('%X509_AUTH%', - "(&"+ - "(allowedx509subject="+self.x509_subject+")"+ - "(allowedx509issuer="+self.x509_issuer+")"+ - ")" - ) - else: - auth_method = auth_method.replace('%X509_AUTH%','') - self.log_debug("auth_method: {}".format(auth_method)) - if g_milter_schema_wildcard_domain == True: - # The asterisk (*) character is in term of local part - # RFC5322 compliant and expected as a wildcard literal in this code. - # As the asterisk character is special in LDAP context, thus it must - # be ASCII-HEX encoded '\2a' (42 in decimal => answer to everything) - # for proper use in LDAP queries. - # In this case *@ cannot be a real address! - if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): - raise LamHardException( - "Literal wildcard sender (*@) is not " + - "allowed in wildcard mode!" - ) - if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): - raise LamHardException( - "Literal wildcard recipient (*@) is not " + - "allowed in wildcard mode!" - ) - g_ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(|"+ - "(allowedRcpts=" + rcpt_addr + ")"+ - "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ - "(allowedRcpts=\\2a@\\2a)"+ - ")"+ - "(|"+ - "(allowedSenders=" + from_addr + ")"+ - "(allowedSenders=\\2a@" + from_domain + ")"+ - "(allowedSenders=\\2a@\\2a)"+ - ")"+ - ")", - attributes=['policyID'] - ) - else: - # Wildcard-domain DISABLED - # Asterisk must be ASCII-HEX encoded for LDAP queries - query_from = from_addr.replace("*","\\2a") - query_to = rcpt_addr.replace("*","\\2a") - g_ldap_conn.search(g_ldap_base, - "(&" + - auth_method + - "(allowedRcpts=" + query_to + ")" + - "(allowedSenders=" + query_from + ")" + - ")", - attributes=['policyID'] - ) - if len(g_ldap_conn.entries) == 0: - # Policy not found in LDAP - if g_milter_expect_auth == True: - self.log_info( - "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( - from_addr, from_source, rcpt_addr, auth_method - ) - ) - else: - self.log_info( - "policy mismatch: from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr - ) - ) - raise LamHardException("policy mismatch!") - elif len(g_ldap_conn.entries) == 1: - # Policy found in LDAP, but which one? - entry = g_ldap_conn.entries[0] - self.log_info("policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - )) - elif len(g_ldap_conn.entries) > 1: - # Something went wrong!? There shouldn´t be more than one entries! - self.log_warn("More than one policies found! from={0} rcpt={1} auth_method={2}".format( - from_addr, rcpt_addr, auth_method - )) - raise LamHardException("More than one policies found!") - else: - # Custom LDAP schema - # 'build' a LDAP query per recipient - # replace all placeholders in query templates - query = g_ldap_query.replace("%rcpt%", rcpt_addr) - query = query.replace("%from%", from_addr) - query = query.replace("%client_addr%", self.client_addr) - query = query.replace("%sasl_user%", self.sasl_user) - query = query.replace("%from_domain%", from_domain) - query = query.replace("%rcpt_domain%", rcpt_domain) - self.log_debug("LDAP query: {}".format(query)) - g_ldap_conn.search(g_ldap_base, query) - if len(g_ldap_conn.entries) == 0: - self.log_info( - "policy mismatch from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr - ) - ) - raise LamHardException("policy mismatch") - self.log_info("policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - )) - except LDAPException as e: - self.log_error("LDAP exception: {}".format(str(e))) - raise LamSoftException("LDAP exception: " + str(e)) from e; - return self.milter_action(action = 'continue') - # Not registered/used callbacks @Milter.nocallback def eoh(self): @@ -311,7 +127,7 @@ class LdapAclMilter(Milter.Base): def envfrom(self, mailfrom, *str): self.reset() self.proto_stage = 'FROM' - if g_milter_expect_auth: + if g_config.milter_expect_auth: try: # this may fail, if no x509 client certificate was used. # postfix only passes this macro to milters if the TLS connection @@ -353,14 +169,14 @@ class LdapAclMilter(Milter.Base): # Strip out Simple Private Signature (PRVS) mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) # SRS (https://www.libsrs2.org/srs/srs.pdf) - m_srs = g_re_srs.match(mailfrom) + m_srs = rex_srs.match(mailfrom) if m_srs != None: self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) self.env_from = mailfrom.lower() self.log_debug("5321.from={}".format(self.env_from)) - m = g_re_domain.match(self.env_from) + m = rex_domain.match(self.env_from) if m == None: return self.milter_action( action = 'reject', @@ -374,23 +190,24 @@ class LdapAclMilter(Milter.Base): to = to.replace(">","") to = to.lower() self.log_debug("5321.rcpt={}".format(to)) - if to in g_milter_whitelisted_rcpts: + if to in g_config.milter_whitelisted_rcpts: return self.milter_action(action = 'continue') - if g_milter_dkim_enabled: + if g_config.milter_dkim_enabled: # Collect all envelope-recipients for later # investigation (EOM). Do not perform any # policy action at this protocol phase. self.env_rcpts.append(to) else: try: - return self.check_policy( - from_addr=self.env_from, rcpt_addr=to, from_source='5321.from' + ret = g_policy_backend.check_policy( + from_addr=self.env_from, rcpt_addr=to, from_source='5321.from', lam_session=self ) + self.log_info(ret) except LamSoftException as e: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') except LamHardException as e: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action( action = 'reject', reason = e.message @@ -402,12 +219,12 @@ class LdapAclMilter(Milter.Base): def header(self, hname, hval): self.proto_stage = 'HDR' self.queue_id = self.getsymval('i') - if g_milter_dkim_enabled == True: + if g_config.milter_dkim_enabled == True: # Parse RFC-5322-From header if(hname.lower() == "From".lower()): hdr_5322_from = email.utils.parseaddr(hval) self.hdr_from = hdr_5322_from[1].lower() - m = re.match(g_re_domain, self.hdr_from) + m = re.match(rex_domain, self.hdr_from) if m is None: return self.milter_action( action = 'reject', @@ -424,7 +241,7 @@ class LdapAclMilter(Milter.Base): ar = authres.AuthenticationResultsHeader.parse( "{0}: {1}".format(hname, hval) ) - if ar.authserv_id.lower() == g_milter_trusted_authservid.lower(): + if ar.authserv_id.lower() == g_config.milter_trusted_authservid.lower(): for ar_result in ar.results: if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': @@ -441,13 +258,13 @@ class LdapAclMilter(Milter.Base): def eom(self): self.proto_stage = 'EOM' - if g_milter_max_rcpt_enabled: - if len(self.env_rcpts) > int(g_milter_max_rcpt): - if g_milter_mode == 'reject': + if g_config.milter_max_rcpt_enabled: + if len(self.env_rcpts) > int(g_config.milter_max_rcpt): + if g_config.milter_mode == 'reject': return self.milter_action(action='reject', reason='Too many recipients!') else: self.do_log("TEST-Mode: Too many recipients!") - if g_milter_dkim_enabled: + if g_config.milter_dkim_enabled: self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts )) @@ -464,21 +281,24 @@ class LdapAclMilter(Milter.Base): for rcpt in self.env_rcpts: try: # Check 5321.from against policy - self.check_policy( - from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from' + ret = g_policy_backend.check_policy( + from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from', lam_session=self ) + self.log_info(ret) except LamSoftException as e: - if g_milter_mode == 'reject': + self.log_info(str(e)) + if g_config.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: - self.log_info("TEST-Mode: {}".format(e.message)) + self.log_info("TEST-Mode - tmpfail") except LamHardException as e: if self.dkim_aligned: try: # Check 5322.from against policy - self.check_policy( - from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from' + ret = g_policy_backend.check_policy( + from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from', lam_session=self ) + self.log_info(ret) self.log_info("5322.from={} authorized by DKIM signature".format( self.hdr_from )) @@ -488,7 +308,7 @@ class LdapAclMilter(Milter.Base): reject_message = True if reject_message: - if g_milter_mode == 'reject': + if g_config.milter_mode == 'reject': return self.milter_action( action = 'reject', reason = 'policy mismatch! Message rejected for all recipients!' @@ -511,120 +331,29 @@ class LdapAclMilter(Milter.Base): return self.milter_action(action = 'continue') if __name__ == "__main__": + init_logger() try: - log_level = logging.INFO - if 'LOG_LEVEL' in os.environ: - if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.INFO - elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.WARN - elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.ERROR - elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.DEBUG - log_format = '%(asctime)s: %(levelname)s %(message)s ' - logging.basicConfig( - filename = None, # log to stdout - format = log_format, - level = log_level - ) - if 'MILTER_MODE' in os.environ: - if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): - g_milter_mode = os.environ['MILTER_MODE'].lower() - if 'MILTER_NAME' in os.environ: - g_milter_name = os.environ['MILTER_NAME'] - if 'MILTER_SCHEMA' in os.environ: - if re.match(r'^true$', os.environ['MILTER_SCHEMA'], re.IGNORECASE): - g_milter_schema = True - if 'MILTER_SCHEMA_WILDCARD_DOMAIN' in os.environ: - if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): - g_milter_schema_wildcard_domain = True - if 'LDAP_SERVER' not in os.environ: - logging.error("Missing ENV[LDAP_SERVER], e.g. {}".format(g_ldap_server)) - sys.exit(1) - g_ldap_server = os.environ['LDAP_SERVER'] - if 'LDAP_BINDDN' in os.environ: - g_ldap_binddn = os.environ['LDAP_BINDDN'] - if 'LDAP_BINDPW' in os.environ: - g_ldap_bindpw = os.environ['LDAP_BINDPW'] - if 'LDAP_BASE' not in os.environ: - logging.error("Missing ENV[LDAP_BASE], e.g. {}".format(g_ldap_base)) - sys.exit(1) - g_ldap_base = os.environ['LDAP_BASE'] - if 'LDAP_QUERY' not in os.environ: - if g_milter_schema == False: - logging.error( - "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" - ) - sys.exit(1) - if 'LDAP_QUERY' in os.environ: - g_ldap_query = os.environ['LDAP_QUERY'] - if 'MILTER_SOCKET' in os.environ: - g_milter_socket = os.environ['MILTER_SOCKET'] - if 'MILTER_REJECT_MESSAGE' in os.environ: - g_milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] - if 'MILTER_TMPFAIL_MESSAGE' in os.environ: - g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] - if 'MILTER_EXPECT_AUTH' in os.environ: - if re.match(r'^true$', os.environ['MILTER_EXPECT_AUTH'], re.IGNORECASE): - g_milter_expect_auth = True - if 'MILTER_WHITELISTED_RCPTS' in os.environ: - # A blank separated list is expected - whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] - for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): - if g_re_email.match(whitelisted_rcpt) == None: - logging.error( - "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" - .format(whitelisted_rcpt) - ) - sys.exit(1) - else: - logging.info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( - whitelisted_rcpt - )) - g_milter_whitelisted_rcpts[whitelisted_rcpt] = {} - if 'MILTER_DKIM_ENABLED' in os.environ: - g_milter_dkim_enabled = True - if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: - g_milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() - logging.info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format( - g_milter_trusted_authservid - )) - else: - logging.error("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") - sys.exit(1) - logging.info("ENV[MILTER_DKIM_ENABLED]: {0}".format(g_milter_dkim_enabled)) - if 'MILTER_MAX_RCPT_ENABLED' in os.environ: - g_milter_max_rcpt_enabled = True - if 'MILTER_MAX_RCPT' in os.environ: - if os.environ['MILTER_MAX_RCPT'].isnumeric(): - g_milter_max_rcpt = os.environ['MILTER_MAX_RCPT'] - else: - print("ENV[MILTER_MAX_RCPT] must be numeric!") - sys.exit(1) - try: - set_config_parameter("RESTARTABLE_SLEEPTIME", 2) - set_config_parameter("RESTARTABLE_TRIES", 2) - server = Server(g_ldap_server, get_info=NONE) - g_ldap_conn = Connection(server, - g_ldap_binddn, g_ldap_bindpw, - auto_bind=True, raise_exceptions=True, - client_strategy='RESTARTABLE' - ) - logging.info("Connected to LDAP-server: " + g_ldap_server) - except LDAPException as e: - raise Exception("Connection to LDAP-server failed: {}".format(str(e))) from e + g_config = LamConfig() + except LamConfigException as e: + log_info("A config error was raised: {}".format(e)) + sys.exit(1) + try: + g_policy_backend = LamPolicyBackend(g_config) + except LamPolicyBackendException as e: + log_error("An backend init error was raised: {}".format(e)) + sys.exit(1) + try: timeout = 600 # Register to have the Milter factory create instances of your class: Milter.factory = LdapAclMilter # Tell the MTA which features we use flags = Milter.ADDHDRS Milter.set_flags(flags) - logging.info("Starting {0}@socket: {1} in mode {2}".format( - g_milter_name, g_milter_socket, g_milter_mode + log_info("Starting {0}@socket: {1} in mode {2}".format( + g_config.milter_name, g_config.milter_socket, g_config.milter_mode )) - Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) - logging.info("Shutdown {}".format(g_milter_name)) + Milter.runmilter(g_config.milter_name, g_config.milter_socket, timeout, True) + log_info("Shutdown {}".format(g_config.milter_name)) except: - logging.error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) + log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) sys.exit(1) From c93916cf190aaefc46bb98d3806c40e6134d4c01 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 27 Feb 2022 00:54:42 +0100 Subject: [PATCH 07/15] more code separation --- app/{ldap-acl-milter.py => lam.py} | 51 +++----------------- app/lam_config.py | 11 ++--- app/lam_exceptions.py | 20 ++++++++ app/lam_globals.py | 26 ++++++++++ app/{lam_policy.py => lam_policy_backend.py} | 38 ++++++--------- app/lam_rex.py | 7 +-- app/run_milter.py | 35 ++++++++++++++ 7 files changed, 111 insertions(+), 77 deletions(-) rename app/{ldap-acl-milter.py => lam.py} (88%) create mode 100644 app/lam_exceptions.py create mode 100644 app/lam_globals.py rename app/{lam_policy.py => lam_policy_backend.py} (88%) create mode 100644 app/run_milter.py diff --git a/app/ldap-acl-milter.py b/app/lam.py similarity index 88% rename from app/ldap-acl-milter.py rename to app/lam.py index 74686c5..cc99450 100644 --- a/app/ldap-acl-milter.py +++ b/app/lam.py @@ -1,19 +1,14 @@ import Milter -import sys import traceback import string import random import re import email.utils import authres -from lam_config import LamConfig, LamConfigException -from lam_rex import rex_domain, rex_srs -from lam_logger import init_logger, log_debug, log_info, log_warning, log_error -from lam_policy import LamPolicyBackend, LamPolicyBackendException, LamSoftException, LamHardException - -# Globals... -g_config = None -g_policy_backend = None +from lam_globals import g_config, g_policy_backend +from lam_rex import g_rex_domain, g_rex_srs +from lam_logger import log_debug, log_info, log_warning, log_error +from lam_exceptions import LamSoftException, LamHardException class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread @@ -38,9 +33,6 @@ class LdapAclMilter(Milter.Base): log_info(log_line) elif kwargs['level'] == 'debug': log_debug(log_line) - else: - print("do_log(): invalid 'level' {}".format(kwargs['level'])) - sys.exit(1) def log_error(self, log_message): self.do_log(level='error', log_message=log_message) def log_warn(self, log_message): @@ -132,7 +124,6 @@ class LdapAclMilter(Milter.Base): # this may fail, if no x509 client certificate was used. # postfix only passes this macro to milters if the TLS connection # with the authenticating client was trusted in a x509 manner! - # http://postfix.1071664.n5.nabble.com/verification-levels-and-Milter-tp91634p91638.html # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ x509_subject = self.getsymval('{cert_subject}') if x509_subject != None: @@ -169,14 +160,14 @@ class LdapAclMilter(Milter.Base): # Strip out Simple Private Signature (PRVS) mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) # SRS (https://www.libsrs2.org/srs/srs.pdf) - m_srs = rex_srs.match(mailfrom) + m_srs = g_rex_srs.match(mailfrom) if m_srs != None: self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) self.env_from = mailfrom.lower() self.log_debug("5321.from={}".format(self.env_from)) - m = rex_domain.match(self.env_from) + m = g_rex_domain.match(self.env_from) if m == None: return self.milter_action( action = 'reject', @@ -224,7 +215,7 @@ class LdapAclMilter(Milter.Base): if(hname.lower() == "From".lower()): hdr_5322_from = email.utils.parseaddr(hval) self.hdr_from = hdr_5322_from[1].lower() - m = re.match(rex_domain, self.hdr_from) + m = re.match(g_rex_domain, self.hdr_from) if m is None: return self.milter_action( action = 'reject', @@ -329,31 +320,3 @@ class LdapAclMilter(Milter.Base): # Clean up any external resources here. self.proto_stage = 'CLOSE' return self.milter_action(action = 'continue') - -if __name__ == "__main__": - init_logger() - try: - g_config = LamConfig() - except LamConfigException as e: - log_info("A config error was raised: {}".format(e)) - sys.exit(1) - try: - g_policy_backend = LamPolicyBackend(g_config) - except LamPolicyBackendException as e: - log_error("An backend init error was raised: {}".format(e)) - sys.exit(1) - try: - timeout = 600 - # Register to have the Milter factory create instances of your class: - Milter.factory = LdapAclMilter - # Tell the MTA which features we use - flags = Milter.ADDHDRS - Milter.set_flags(flags) - log_info("Starting {0}@socket: {1} in mode {2}".format( - g_config.milter_name, g_config.milter_socket, g_config.milter_mode - )) - Milter.runmilter(g_config.milter_name, g_config.milter_socket, timeout, True) - log_info("Shutdown {}".format(g_config.milter_name)) - except: - log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) - sys.exit(1) diff --git a/app/lam_config.py b/app/lam_config.py index aa5fa91..7e57f28 100644 --- a/app/lam_config.py +++ b/app/lam_config.py @@ -1,13 +1,8 @@ import re import os -from lam_rex import rex_email from lam_logger import log_info - -class LamConfigException(Exception): - def __init__(self, message): - self.message = message - def __str__(self): - return self.message +from lam_exceptions import LamConfigException +from lam_rex import g_rex_email class LamConfig(): def __init__(self): @@ -73,7 +68,7 @@ class LamConfig(): # A blank separated list is expected whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): - if rex_email.match(whitelisted_rcpt) == None: + if g_rex_email.match(whitelisted_rcpt) == None: raise LamConfigException( "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" .format(whitelisted_rcpt) diff --git a/app/lam_exceptions.py b/app/lam_exceptions.py new file mode 100644 index 0000000..3d44cf2 --- /dev/null +++ b/app/lam_exceptions.py @@ -0,0 +1,20 @@ +class LamException(Exception): + def __init__(self, message="General exception message"): + self.message = message + def __str__(self) -> str: + return self.message + +class LamInitException(LamException): + pass + +class LamSoftException(LamException): + pass + +class LamHardException(LamException): + pass + +class LamPolicyBackendException(LamException): + pass + +class LamConfigException(LamException): + pass \ No newline at end of file diff --git a/app/lam_globals.py b/app/lam_globals.py new file mode 100644 index 0000000..b7b7595 --- /dev/null +++ b/app/lam_globals.py @@ -0,0 +1,26 @@ +import sys +from lam_exceptions import ( + LamInitException, LamPolicyBackendException, LamConfigException +) +from lam_config import LamConfig +from lam_logger import init_logger +from lam_policy_backend import LamPolicyBackend + +init_logger() + +g_config = None +try: + if g_config is None: + g_config = LamConfig() +except LamConfigException as e: + raise LamInitException(e.message) from e + +# Instantiate the LDAP policy backend and +# establish a permanent connection with the LDAP server +# which will be reused on any milter connection +g_policy_backend = None +try: + if g_policy_backend is None: + g_policy_backend = LamPolicyBackend(g_config) +except LamPolicyBackendException as e: + raise LamInitException(e.message) from e \ No newline at end of file diff --git a/app/lam_policy.py b/app/lam_policy_backend.py similarity index 88% rename from app/lam_policy.py rename to app/lam_policy_backend.py index bcb71b2..0e2b722 100644 --- a/app/lam_policy.py +++ b/app/lam_policy_backend.py @@ -1,25 +1,13 @@ import re from lam_logger import log_info, log_debug, log_error -from lam_rex import rex_domain +from lam_rex import g_rex_domain from ldap3 import ( Server, Connection, NONE, set_config_parameter ) from ldap3.core.exceptions import LDAPException - -class LamException(Exception): - def __init__(self, message="General exception message"): - self.message = message - def __str__(self) -> str: - return self.message - -class LamSoftException(LamException): - pass - -class LamHardException(LamException): - pass - -class LamPolicyBackendException(LamException): - pass +from lam_exceptions import ( + LamPolicyBackendException, LamHardException, LamSoftException +) class LamPolicyBackend(): def __init__(self, lam_config): @@ -28,11 +16,17 @@ class LamPolicyBackend(): try: set_config_parameter("RESTARTABLE_SLEEPTIME", 2) set_config_parameter("RESTARTABLE_TRIES", 2) - server = Server(self.config.ldap_server, get_info=NONE) + server = Server( + self.config.ldap_server, + connect_timeout = 3, + get_info = NONE + ) self.ldap_conn = Connection(server, - self.config.ldap_binddn, self.config.ldap_bindpw, - auto_bind=True, raise_exceptions=True, - client_strategy='RESTARTABLE' + self.config.ldap_binddn, + self.config.ldap_bindpw, + auto_bind = True, + raise_exceptions = True, + client_strategy = 'RESTARTABLE' ) log_info("Connected to LDAP-server: {}".format(self.config.ldap_server)) except LDAPException as e: @@ -45,7 +39,7 @@ class LamPolicyBackend(): rcpt_addr = kwargs['rcpt_addr'] from_source = kwargs['from_source'] lam_session = kwargs['lam_session'] - m = rex_domain.match(from_addr) + m = g_rex_domain.match(from_addr) if m == None: log_info("Could not determine domain of from={0}".format( from_addr @@ -53,7 +47,7 @@ class LamPolicyBackend(): raise LamSoftException() from_domain = m.group(1) log_debug("from_domain={}".format(from_domain)) - m = rex_domain.match(rcpt_addr) + m = g_rex_domain.match(rcpt_addr) if m == None: raise LamHardException( "Could not determine domain of rcpt={0}".format( diff --git a/app/lam_rex.py b/app/lam_rex.py index 7ea9942..45f8b1d 100644 --- a/app/lam_rex.py +++ b/app/lam_rex.py @@ -1,6 +1,7 @@ import re -rex_domain = re.compile(r'^\S*@(\S+)$') +# globaly used regex definitions +g_rex_domain = re.compile(r'^\S*@(\S+)$') # http://emailregex.com/ -> Python -rex_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") -rex_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") \ No newline at end of file +g_rex_email = re.compile(r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)") +g_rex_srs = re.compile(r"^SRS0=.+=.+=(\S+)=(\S+)\@.+$") \ No newline at end of file diff --git a/app/run_milter.py b/app/run_milter.py new file mode 100644 index 0000000..d54d77e --- /dev/null +++ b/app/run_milter.py @@ -0,0 +1,35 @@ +import Milter +import sys +import traceback +from lam_exceptions import LamInitException +from lam_logger import log_info, log_error +try: + import lam_globals +except LamInitException as e: + log_error("Init exception: {}".format(e.message)) + sys.exit(1) +from lam import LdapAclMilter + +if __name__ == "__main__": + try: + timeout = 600 + # Register to have the Milter factory create instances of your class: + Milter.factory = LdapAclMilter + # Tell the MTA which features we use + flags = Milter.ADDHDRS + Milter.set_flags(flags) + log_info("Starting {0}@socket: {1} in mode {2}".format( + lam_globals.g_config.milter_name, + lam_globals.g_config.milter_socket, + lam_globals.g_config.milter_mode + )) + Milter.runmilter( + lam_globals.g_config.milter_name, + lam_globals.g_config.milter_socket, + timeout, + True + ) + log_info("Shutdown {}".format(lam_globals.g_config.milter_name)) + except: + log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) + sys.exit(1) From f06be7b0c38abe31d48ad71fe9a45af11ebc8376 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 28 Feb 2022 23:41:30 +0100 Subject: [PATCH 08/15] refactoring; more logging --- app/lam.py | 99 ++++++++++------- app/lam_backends.py | 30 ++++++ app/{lam_config.py => lam_config_backend.py} | 100 ++++++++++++++---- app/lam_exceptions.py | 2 +- app/lam_globals.py | 26 ----- app/lam_policy_backend.py | 64 ++++++----- app/run_milter.py | 16 ++- tests/miltertest-ip-conn_reuse.lua | 12 --- tests/miltertest-ip-multiple_rcpts.lua | 3 - tests/miltertest-noauth.lua | 7 -- tests/miltertest-null_sender.lua | 0 tests/miltertest-sasl-wildcard.lua | 6 -- tests/miltertest-sasl.lua | 6 -- tests/miltertest-whitelisted_sender.lua | 0 tests/miltertest-x509.lua | 6 -- tests/miltertest-x509_5321-fail_dkim-pass.lua | 6 -- 16 files changed, 204 insertions(+), 179 deletions(-) create mode 100644 app/lam_backends.py rename app/{lam_config.py => lam_config_backend.py} (55%) delete mode 100644 app/lam_globals.py create mode 100644 tests/miltertest-null_sender.lua create mode 100644 tests/miltertest-whitelisted_sender.lua diff --git a/app/lam.py b/app/lam.py index cc99450..ceb3d33 100644 --- a/app/lam.py +++ b/app/lam.py @@ -5,7 +5,7 @@ import random import re import email.utils import authres -from lam_globals import g_config, g_policy_backend +from lam_backends import g_config_backend, g_policy_backend from lam_rex import g_rex_domain, g_rex_srs from lam_logger import log_debug, log_info, log_warning, log_error from lam_exceptions import LamSoftException, LamHardException @@ -57,7 +57,7 @@ class LdapAclMilter(Milter.Base): self.passed_dkim_results = [] self.log_debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 - self.mconn_id = g_config.milter_name + ': ' + ''.join( + self.mconn_id = g_config_backend.milter_name + ': ' + ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) @@ -69,12 +69,12 @@ class LdapAclMilter(Milter.Base): smtp_code = None smtp_ecode = None if kwargs['action'] == 'reject': - message = g_config.milter_reject_message + message = g_config_backend.milter_reject_message smtp_code = '550' smtp_ecode = '5.7.1' smfir = Milter.REJECT elif kwargs['action'] == 'tmpfail': - message = g_config.milter_tmpfail_message + message = g_config_backend.milter_tmpfail_message smtp_code = '450' smtp_ecode = '4.7.1' smfir = Milter.TEMPFAIL @@ -93,8 +93,8 @@ class LdapAclMilter(Milter.Base): if 'reason' in kwargs: message = "{0} - reason: {1}".format(message, kwargs['reason']) if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': - self.log_info("milter_action={0} message={1}".format( - kwargs['action'], message + self.log_info("{0} - milter_action={1} message={2}".format( + self.mconn_id, kwargs['action'], message )) self.setreply(smtp_code, smtp_ecode, message) return smfir @@ -119,7 +119,7 @@ class LdapAclMilter(Milter.Base): def envfrom(self, mailfrom, *str): self.reset() self.proto_stage = 'FROM' - if g_config.milter_expect_auth: + if g_config_backend.milter_expect_auth: try: # this may fail, if no x509 client certificate was used. # postfix only passes this macro to milters if the TLS connection @@ -181,24 +181,28 @@ class LdapAclMilter(Milter.Base): to = to.replace(">","") to = to.lower() self.log_debug("5321.rcpt={}".format(to)) - if to in g_config.milter_whitelisted_rcpts: + if to in g_config_backend.milter_whitelisted_rcpts: return self.milter_action(action = 'continue') - if g_config.milter_dkim_enabled: + if g_config_backend.milter_dkim_enabled: # Collect all envelope-recipients for later # investigation (EOM). Do not perform any # policy action at this protocol phase. self.env_rcpts.append(to) else: + # DKIM disabled. Policy enforcement takes place here. try: - ret = g_policy_backend.check_policy( - from_addr=self.env_from, rcpt_addr=to, from_source='5321.from', lam_session=self + g_policy_backend.check_policy( + from_addr = self.env_from, + rcpt_addr = to, + from_source = 'envelope', + lam_session = self ) - self.log_info(ret) + self.env_rcpts.append(to) except LamSoftException as e: - if g_config.milter_mode == 'reject': + if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') except LamHardException as e: - if g_config.milter_mode == 'reject': + if g_config_backend.milter_mode == 'reject': return self.milter_action( action = 'reject', reason = e.message @@ -210,7 +214,7 @@ class LdapAclMilter(Milter.Base): def header(self, hname, hval): self.proto_stage = 'HDR' self.queue_id = self.getsymval('i') - if g_config.milter_dkim_enabled == True: + if g_config_backend.milter_dkim_enabled == True: # Parse RFC-5322-From header if(hname.lower() == "From".lower()): hdr_5322_from = email.utils.parseaddr(hval) @@ -232,7 +236,7 @@ class LdapAclMilter(Milter.Base): ar = authres.AuthenticationResultsHeader.parse( "{0}: {1}".format(hname, hval) ) - if ar.authserv_id.lower() == g_config.milter_trusted_authservid.lower(): + if ar.authserv_id.lower() == g_config_backend.milter_trusted_authservid.lower(): for ar_result in ar.results: if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': @@ -249,13 +253,13 @@ class LdapAclMilter(Milter.Base): def eom(self): self.proto_stage = 'EOM' - if g_config.milter_max_rcpt_enabled: - if len(self.env_rcpts) > int(g_config.milter_max_rcpt): - if g_config.milter_mode == 'reject': + if g_config_backend.milter_max_rcpt_enabled: + if len(self.env_rcpts) > int(g_config_backend.milter_max_rcpt): + if g_config_backend.milter_mode == 'reject': return self.milter_action(action='reject', reason='Too many recipients!') else: self.do_log("TEST-Mode: Too many recipients!") - if g_config.milter_dkim_enabled: + if g_config_backend.milter_dkim_enabled: self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts )) @@ -272,42 +276,55 @@ class LdapAclMilter(Milter.Base): for rcpt in self.env_rcpts: try: # Check 5321.from against policy - ret = g_policy_backend.check_policy( - from_addr=self.env_from, rcpt_addr=rcpt, from_source='5321.from', lam_session=self + g_policy_backend.check_policy( + from_addr=self.env_from, + rcpt_addr=rcpt, + from_source='envelope', + lam_session=self + ) + self.log_info( + "action=pass 5321.from={0} 5321.rcpt={1}".format(self.env_from, rcpt) ) - self.log_info(ret) except LamSoftException as e: - self.log_info(str(e)) - if g_config.milter_mode == 'reject': + self.log_info(e.message) + if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: self.log_info("TEST-Mode - tmpfail") except LamHardException as e: + self.log_info(e.message) if self.dkim_aligned: try: # Check 5322.from against policy - ret = g_policy_backend.check_policy( - from_addr=self.hdr_from, rcpt_addr=rcpt, from_source='5322.from', lam_session=self + g_policy_backend.check_policy( + from_addr=self.hdr_from, + rcpt_addr=rcpt, + from_source='from-header', + lam_session=self + ) + self.log_info( + "action=pass 5322.from={0} 5321.rcpt={1}".format(self.hdr_from, rcpt) ) - self.log_info(ret) - self.log_info("5322.from={} authorized by DKIM signature".format( - self.hdr_from - )) except LamHardException as e: reject_message = True else: reject_message = True + if reject_message: + if g_config_backend.milter_mode == 'reject': + return self.milter_action( + action = 'reject', + reason = 'policy mismatch! Message rejected for all recipients!' + ) + else: + self.log_info( + "TEST-Mode: policy mismatch! Message would be rejected for all recipients!" + ) + else: + # * DKIM check disabled + # Iterate through all accepted envelope recipients and log + for rcpt in self.env_rcpts: + self.log_info("action=pass 5321.from={0} 5321.rcpt={1}".format(self.env_from, rcpt)) - if reject_message: - if g_config.milter_mode == 'reject': - return self.milter_action( - action = 'reject', - reason = 'policy mismatch! Message rejected for all recipients!' - ) - else: - self.log_info( - "TEST-Mode: policy mismatch! Message would be rejected for all recipients!" - ) return self.milter_action(action = 'continue') def abort(self): diff --git a/app/lam_backends.py b/app/lam_backends.py new file mode 100644 index 0000000..26384ca --- /dev/null +++ b/app/lam_backends.py @@ -0,0 +1,30 @@ +import traceback +from lam_exceptions import ( + LamInitException, LamPolicyBackendException, LamConfigBackendException +) +from lam_logger import init_logger +from lam_config_backend import LamConfigBackend +from lam_policy_backend import LamPolicyBackend + +init_logger() + +g_config_backend = None +try: + if g_config_backend is None: + g_config_backend = LamConfigBackend() +except LamConfigBackendException as e: + raise LamInitException(e.message) from e +except Exception as e: + raise LamInitException(traceback.format_exc()) from e + +# Instantiate the LDAP policy backend and +# establish a permanent connection with the LDAP server +# which will be reused on any milter connection +g_policy_backend = None +try: + if g_policy_backend is None: + g_policy_backend = LamPolicyBackend(g_config_backend) +except LamPolicyBackendException as e: + raise LamInitException(e.message) from e +except Exception as e: + raise LamInitException(traceback.format_exc()) from e \ No newline at end of file diff --git a/app/lam_config.py b/app/lam_config_backend.py similarity index 55% rename from app/lam_config.py rename to app/lam_config_backend.py index 7e57f28..31a700a 100644 --- a/app/lam_config.py +++ b/app/lam_config_backend.py @@ -1,23 +1,25 @@ import re import os from lam_logger import log_info -from lam_exceptions import LamConfigException +from lam_exceptions import LamConfigBackendException from lam_rex import g_rex_email -class LamConfig(): +class LamConfigBackend(): def __init__(self): - self.milter_mode = 'test' self.milter_name = 'ldap-acl-milter' + self.milter_mode = 'test' self.milter_socket = '/socket/{}'.format(self.milter_name) + self.milter_timeout = 60 self.milter_reject_message = 'Security policy violation!' self.milter_tmpfail_message = 'Service temporarily not available! Please try again later.' self.ldap_server = 'ldap://127.0.0.1:389' + self.ldap_server_connect_timeout = 3 self.ldap_binddn = 'cn=ldap-reader,ou=binds,dc=example,dc=org' self.ldap_bindpw = 'TopSecret;-)' self.ldap_base = 'ou=lam,ou=services,dc=example,dc=org' self.ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' self.milter_schema = False - self.milter_schema_wildcard_domain = False # works only if milter_schema == True + self.milter_schema_wildcard_domain = False self.milter_expect_auth = False self.milter_whitelisted_rcpts = {} self.milter_dkim_enabled = False @@ -25,75 +27,127 @@ class LamConfig(): self.milter_max_rcpt_enabled = False self.milter_max_rcpt = 1 + if 'MILTER_NAME' in os.environ: + self.milter_name = os.environ['MILTER_NAME'] + if 'MILTER_MODE' in os.environ: if re.match(r'^test|reject$',os.environ['MILTER_MODE'], re.IGNORECASE): self.milter_mode = os.environ['MILTER_MODE'].lower() - if 'MILTER_NAME' in os.environ: - self.milter_name = os.environ['MILTER_NAME'] + log_info("ENV[MILTER_MODE]: {}". format(self.milter_mode)) + + if 'MILTER_SOCKET' in os.environ: + self.milter_socket = os.environ['MILTER_SOCKET'] + log_info("ENV[MILTER_SOCKET]: {}".format(self.milter_socket)) + + if 'MILTER_TIMEOUT' in os.environ: + if os.environ['MILTER_TIMEOUT'].isnumeric(): + self.milter_timeout = int(os.environ['MILTER_TIMEOUT']) + else: + raise LamConfigBackendException("ENV[MILTER_TIMEOUT] must be numeric!") + log_info("ENV[MILTER_TIMEOUT]: {}".format(self.milter_timeout)) + if 'MILTER_SCHEMA' in os.environ: if re.match(r'^true$', os.environ['MILTER_SCHEMA'], re.IGNORECASE): self.milter_schema = True if 'MILTER_SCHEMA_WILDCARD_DOMAIN' in os.environ: if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARD_DOMAIN'], re.IGNORECASE): self.milter_schema_wildcard_domain = True + log_info("ENV[MILTER_SCHEMA]: {}".format(self.milter_schema)) + log_info( + "ENV[MILTER_SCHEMA_WILDCARD_DOMAIN]: {}".format( + self.milter_schema_wildcard_domain + ) + ) + if 'LDAP_SERVER' not in os.environ: - raise LamConfigException("Missing ENV[LDAP_SERVER], e.g. {}".format(self.ldap_server)) + raise LamConfigBackendException( + "Missing ENV[LDAP_SERVER], e.g. {}".format(self.ldap_server) + ) self.ldap_server = os.environ['LDAP_SERVER'] + log_info("ENV[LDAP_SERVER]: {}".format(self.ldap_server)) + + if 'LDAP_SERVER_CONNECT_TIMEOUT' in os.environ: + if not os.environ['LDAP_SERVER_CONNECT_TIMEOUT'].isnumeric(): + raise LamConfigBackendException( + "ENV[LDAP_SERVER_CONNECT_TIMEOUT] must be numeric!" + ) + self.ldap_server_connect_timeout = int(os.environ['LDAP_SERVER_CONNECT_TIMEOUT']) + log_info("ENV[LDAP_SERVER_CONNECT_TIMEOUT]: {}".format( + self.ldap_server_connect_timeout + )) + if 'LDAP_BINDDN' in os.environ: self.ldap_binddn = os.environ['LDAP_BINDDN'] if 'LDAP_BINDPW' in os.environ: self.ldap_bindpw = os.environ['LDAP_BINDPW'] + if 'LDAP_BASE' not in os.environ: - raise LamConfigException( + raise LamConfigBackendException( "Missing ENV[LDAP_BASE], e.g. {}".format(self.ldap_base) ) self.ldap_base = os.environ['LDAP_BASE'] + log_info("ENV[LDAP_BASE]: {}".format(self.ldap_base)) + if 'LDAP_QUERY' not in os.environ: if self.milter_schema == False: - raise LamConfigException( + raise LamConfigBackendException( "ENV[MILTER_SCHEMA] is disabled and ENV[LDAP_QUERY] is not set instead!" ) - if 'LDAP_QUERY' in os.environ: + else: self.ldap_query = os.environ['LDAP_QUERY'] - if 'MILTER_SOCKET' in os.environ: - self.milter_socket = os.environ['MILTER_SOCKET'] + log_info("ENV[LDAP_QUERY]: {}".format(self.ldap_query)) + if 'MILTER_REJECT_MESSAGE' in os.environ: self.milter_reject_message = os.environ['MILTER_REJECT_MESSAGE'] + log_info("ENV[MILTER_REJECT_MESSAGE]: {}".format(self.milter_reject_message)) + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: self.milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] + log_info("ENV[MILTER_TMPFAIL_MESSAGE]: {}".format(self.milter_tmpfail_message)) + if 'MILTER_EXPECT_AUTH' in os.environ: if re.match(r'^true$', os.environ['MILTER_EXPECT_AUTH'], re.IGNORECASE): self.milter_expect_auth = True + log_info("ENV[MILTER_EXPECT_AUTH]: {}".format(self.milter_expect_auth)) + if 'MILTER_WHITELISTED_RCPTS' in os.environ: # A blank separated list is expected whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): if g_rex_email.match(whitelisted_rcpt) == None: - raise LamConfigException( + raise LamConfigBackendException( "ENV[MILTER_WHITELISTED_RCPTS]: invalid email address: {}" .format(whitelisted_rcpt) ) else: - log_info("ENV[MILTER_WHITELISTED_RCPTS]: {}".format( - whitelisted_rcpt - )) self.milter_whitelisted_rcpts[whitelisted_rcpt] = {} + log_info( + "ENV[MILTER_WHITELISTED_RCPTS]: {}".format( + self.milter_whitelisted_rcpts + ) + ) + if 'MILTER_DKIM_ENABLED' in os.environ: self.milter_dkim_enabled = True if 'MILTER_TRUSTED_AUTHSERVID' in os.environ: self.milter_trusted_authservid = os.environ['MILTER_TRUSTED_AUTHSERVID'].lower() - log_info("ENV[MILTER_TRUSTED_AUTHSERVID]: {0}".format( - self.milter_trusted_authservid - )) + log_info( + "ENV[MILTER_TRUSTED_AUTHSERVID]: {}".format( + self.milter_trusted_authservid + ) + ) else: - raise LamConfigException("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") - log_info("ENV[MILTER_DKIM_ENABLED]: {0}".format(self.milter_dkim_enabled)) + raise LamConfigBackendException("ENV[MILTER_TRUSTED_AUTHSERVID] is mandatory!") + log_info("ENV[MILTER_DKIM_ENABLED]: {}".format(self.milter_dkim_enabled)) + if 'MILTER_MAX_RCPT_ENABLED' in os.environ: self.milter_max_rcpt_enabled = True if 'MILTER_MAX_RCPT' in os.environ: if os.environ['MILTER_MAX_RCPT'].isnumeric(): - self.milter_max_rcpt = os.environ['MILTER_MAX_RCPT'] + self.milter_max_rcpt = int(os.environ['MILTER_MAX_RCPT']) + log_info("ENV[MILTER_MAX_RCPT]: {}".format(self.milter_max_rcpt)) else: - raise LamConfigException("ENV[MILTER_MAX_RCPT] must be numeric!") + raise LamConfigBackendException("ENV[MILTER_MAX_RCPT] must be numeric!") + log_info("ENV[MILTER_MAX_RCPT_ENABLED]: {}".format(self.milter_max_rcpt_enabled)) diff --git a/app/lam_exceptions.py b/app/lam_exceptions.py index 3d44cf2..0f234e9 100644 --- a/app/lam_exceptions.py +++ b/app/lam_exceptions.py @@ -16,5 +16,5 @@ class LamHardException(LamException): class LamPolicyBackendException(LamException): pass -class LamConfigException(LamException): +class LamConfigBackendException(LamException): pass \ No newline at end of file diff --git a/app/lam_globals.py b/app/lam_globals.py deleted file mode 100644 index b7b7595..0000000 --- a/app/lam_globals.py +++ /dev/null @@ -1,26 +0,0 @@ -import sys -from lam_exceptions import ( - LamInitException, LamPolicyBackendException, LamConfigException -) -from lam_config import LamConfig -from lam_logger import init_logger -from lam_policy_backend import LamPolicyBackend - -init_logger() - -g_config = None -try: - if g_config is None: - g_config = LamConfig() -except LamConfigException as e: - raise LamInitException(e.message) from e - -# Instantiate the LDAP policy backend and -# establish a permanent connection with the LDAP server -# which will be reused on any milter connection -g_policy_backend = None -try: - if g_policy_backend is None: - g_policy_backend = LamPolicyBackend(g_config) -except LamPolicyBackendException as e: - raise LamInitException(e.message) from e \ No newline at end of file diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index 0e2b722..b92c5e6 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -17,8 +17,8 @@ class LamPolicyBackend(): set_config_parameter("RESTARTABLE_SLEEPTIME", 2) set_config_parameter("RESTARTABLE_TRIES", 2) server = Server( - self.config.ldap_server, - connect_timeout = 3, + host = self.config.ldap_server, + connect_timeout = self.config.ldap_server_connect_timeout, get_info = NONE ) self.ldap_conn = Connection(server, @@ -34,19 +34,19 @@ class LamPolicyBackend(): "Connection to LDAP-server failed: {}".format(str(e)) ) from e - def check_policy(self, **kwargs) -> str: + def check_policy(self, **kwargs): from_addr = kwargs['from_addr'] rcpt_addr = kwargs['rcpt_addr'] from_source = kwargs['from_source'] lam_session = kwargs['lam_session'] + mcid = "{}/Policy".format(lam_session.mconn_id) m = g_rex_domain.match(from_addr) if m == None: - log_info("Could not determine domain of from={0}".format( - from_addr - )) - raise LamSoftException() + raise LamHardException( + "Could not determine domain of from={0}".format(from_addr) + ) from_domain = m.group(1) - log_debug("from_domain={}".format(from_domain)) + log_debug("{0} from_domain={1}".format(mcid, from_domain)) m = g_rex_domain.match(rcpt_addr) if m == None: raise LamHardException( @@ -55,7 +55,7 @@ class LamPolicyBackend(): ) ) rcpt_domain = m.group(1) - log_debug("rcpt_domain={}".format(rcpt_domain)) + log_debug("{0} rcpt_domain={1}".format(mcid, rcpt_domain)) try: if self.config.milter_schema == True: # LDAP-ACL-Milter schema @@ -77,7 +77,7 @@ class LamPolicyBackend(): ) else: auth_method = auth_method.replace('%X509_AUTH%','') - log_debug("auth_method: {}".format(auth_method)) + log_debug("{0} auth_method: {1}".format(mcid, auth_method)) if self.config.milter_schema_wildcard_domain == True: # The asterisk (*) character is in term of local part # RFC5322 compliant and expected as a wildcard literal in this code. @@ -126,26 +126,28 @@ class LamPolicyBackend(): ) if len(self.ldap_conn.entries) == 0: # Policy not found in LDAP - ex = "policy mismatch: from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr - ) - if self.config.milter_expect_auth == True: - ex = "policy mismatch: from={0} from_src={1} rcpt={2} auth_method={3}".format( - from_addr, from_source, rcpt_addr, auth_method + raise LamHardException( + "mismatch: from_src={0} from={1} rcpt={2}".format( + from_source, from_addr, rcpt_addr ) - raise LamHardException(ex) + ) elif len(self.ldap_conn.entries) == 1: + if from_source == 'from-header': + log_info("{0} 5322.from={1} authorized by DKIM signature".format( + mcid, from_addr + )) # Policy found in LDAP, but which one? entry = self.ldap_conn.entries[0] - return "policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - ) + log_info("{0} match: '{1}' from_src={2}".format( + mcid, entry.policyID.value, from_source + )) elif len(self.ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! - log_error("More than one policies found! from={0} rcpt={1} auth_method={2}".format( - from_addr, rcpt_addr, auth_method - )) - raise LamHardException("More than one policies found!") + raise LamHardException( + "More than one policies found! from={0} rcpt={1} auth_method={2}".format( + from_addr, rcpt_addr, auth_method + ) + ) else: # Custom LDAP schema # 'build' a LDAP query per recipient @@ -156,18 +158,14 @@ class LamPolicyBackend(): query = query.replace("%sasl_user%", lam_session.sasl_user) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) - log_debug("LDAP query: {}".format(query)) + log_debug("{0} LDAP query: {1}".format(mcid, query)) self.ldap_conn.search(self.config.ldap_base, query) if len(self.ldap_conn.entries) == 0: - log_info( - "policy mismatch from={0} from_src={1} rcpt={2}".format( - from_addr, from_source, rcpt_addr + raise LamHardException( + "mismatch from_src={0} from={1} rcpt={2}".format( + from_source, from_addr, rcpt_addr ) ) - raise LamHardException("policy mismatch") - return "policy match: '{0}' from_src={1}".format( - entry.policyID.value, from_source - ) + log_info("{0} match from_src={1}".format(mcid, from_source)) except LDAPException as e: - log_error("LDAP exception: {}".format(str(e))) raise LamSoftException("LDAP exception: " + str(e)) from e diff --git a/app/run_milter.py b/app/run_milter.py index d54d77e..020296f 100644 --- a/app/run_milter.py +++ b/app/run_milter.py @@ -4,7 +4,7 @@ import traceback from lam_exceptions import LamInitException from lam_logger import log_info, log_error try: - import lam_globals + import lam_backends except LamInitException as e: log_error("Init exception: {}".format(e.message)) sys.exit(1) @@ -12,24 +12,22 @@ from lam import LdapAclMilter if __name__ == "__main__": try: - timeout = 600 + timeout = lam_backends.g_config_backend.milter_timeout # Register to have the Milter factory create instances of your class: Milter.factory = LdapAclMilter # Tell the MTA which features we use flags = Milter.ADDHDRS Milter.set_flags(flags) - log_info("Starting {0}@socket: {1} in mode {2}".format( - lam_globals.g_config.milter_name, - lam_globals.g_config.milter_socket, - lam_globals.g_config.milter_mode + log_info("Starting {}".format( + lam_backends.g_config_backend.milter_name )) Milter.runmilter( - lam_globals.g_config.milter_name, - lam_globals.g_config.milter_socket, + lam_backends.g_config_backend.milter_name, + lam_backends.g_config_backend.milter_socket, timeout, True ) - log_info("Shutdown {}".format(lam_globals.g_config.milter_name)) + log_info("Shutdown {}".format(lam_backends.g_config_backend.milter_name)) except: log_error("MAIN-EXCEPTION: {}".format(traceback.format_exc())) sys.exit(1) diff --git a/tests/miltertest-ip-conn_reuse.lua b/tests/miltertest-ip-conn_reuse.lua index 805df31..310cf6a 100644 --- a/tests/miltertest-ip-conn_reuse.lua +++ b/tests/miltertest-ip-conn_reuse.lua @@ -16,18 +16,12 @@ mt.set_timeout(60) if mt.mailfrom(conn, "tester-ip@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then @@ -54,18 +48,12 @@ end if mt.mailfrom(conn, "tester-ip2@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "conn-reused-QID") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then diff --git a/tests/miltertest-ip-multiple_rcpts.lua b/tests/miltertest-ip-multiple_rcpts.lua index 0432ae4..bc012dc 100644 --- a/tests/miltertest-ip-multiple_rcpts.lua +++ b/tests/miltertest-ip-multiple_rcpts.lua @@ -16,9 +16,6 @@ mt.set_timeout(60) if mt.mailfrom(conn, "tester-ipx@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- FIRST 5321.RCPT if mt.rcptto(conn, "") ~= nil then diff --git a/tests/miltertest-noauth.lua b/tests/miltertest-noauth.lua index 77651ec..16b1d7b 100644 --- a/tests/miltertest-noauth.lua +++ b/tests/miltertest-noauth.lua @@ -16,19 +16,12 @@ mt.set_timeout(60) if mt.mailfrom(conn, "tester-noauth@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then --- if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then diff --git a/tests/miltertest-null_sender.lua b/tests/miltertest-null_sender.lua new file mode 100644 index 0000000..e69de29 diff --git a/tests/miltertest-sasl-wildcard.lua b/tests/miltertest-sasl-wildcard.lua index 3e27396..6d23d79 100644 --- a/tests/miltertest-sasl-wildcard.lua +++ b/tests/miltertest-sasl-wildcard.lua @@ -17,18 +17,12 @@ mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user-wild") if mt.mailfrom(conn, "tester-invalid@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "test-wildcard-qid") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then diff --git a/tests/miltertest-sasl.lua b/tests/miltertest-sasl.lua index dfccb7c..d8966fe 100644 --- a/tests/miltertest-sasl.lua +++ b/tests/miltertest-sasl.lua @@ -17,18 +17,12 @@ mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user1") if mt.mailfrom(conn, "tester@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then diff --git a/tests/miltertest-whitelisted_sender.lua b/tests/miltertest-whitelisted_sender.lua new file mode 100644 index 0000000..e69de29 diff --git a/tests/miltertest-x509.lua b/tests/miltertest-x509.lua index 6a8920a..f9838cf 100644 --- a/tests/miltertest-x509.lua +++ b/tests/miltertest-x509.lua @@ -18,18 +18,12 @@ mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x5 if mt.mailfrom(conn, "tester-x509@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then diff --git a/tests/miltertest-x509_5321-fail_dkim-pass.lua b/tests/miltertest-x509_5321-fail_dkim-pass.lua index 3cf833f..e8fbed1 100644 --- a/tests/miltertest-x509_5321-fail_dkim-pass.lua +++ b/tests/miltertest-x509_5321-fail_dkim-pass.lua @@ -17,18 +17,12 @@ mt.macro(conn, SMFIC_MAIL, "{cert_issuer}", "x509-issuer", "{cert_subject}", "x5 if mt.mailfrom(conn, "tester-x509-invalid@test.blah") ~= nil then error "mt.mailfrom() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.mailfrom() unexpected reply" -end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") if mt.rcptto(conn, "") ~= nil then error "mt.rcptto() failed" end -if mt.getreply(conn) ~= SMFIR_CONTINUE then - error "mt.rcptto() unexpected reply" -end -- 5322.HEADERS if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then From d6af0c648d575bf254ae7dac5c8d54181290eed6 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 6 Mar 2022 00:35:23 +0100 Subject: [PATCH 09/15] more tests; null-sender switch; take denied senders/rcpts under consideration --- app/lam.py | 55 +++++++++++------- app/lam_config_backend.py | 6 +- app/lam_policy_backend.py | 58 +++++++++++-------- ...st-ip-conn_reuse.lua => ip-conn_reuse.lua} | 0 tests/{miltertest-ip-fail.lua => ip-fail.lua} | 0 ...ltiple_rcpts.lua => ip-multiple_rcpts.lua} | 0 tests/{miltertest-ip.lua => ip.lua} | 0 tests/miltertest-null_sender.lua | 0 tests/miltertest-whitelisted_sender.lua | 0 tests/{miltertest-noauth.lua => noauth.lua} | 0 tests/null_sender.lua | 43 ++++++++++++++ ...sl-wildcard.lua => sasl-wildcard-dkim.lua} | 0 tests/sasl-wildcard.lua | 47 +++++++++++++++ tests/{miltertest-sasl.lua => sasl.lua} | 0 tests/welcome-listed_rcpt.lua | 56 ++++++++++++++++++ ..._5321-fail_dkim-pass.lua => x509-dkim.lua} | 0 tests/{miltertest-x509.lua => x509.lua} | 0 17 files changed, 221 insertions(+), 44 deletions(-) rename tests/{miltertest-ip-conn_reuse.lua => ip-conn_reuse.lua} (100%) rename tests/{miltertest-ip-fail.lua => ip-fail.lua} (100%) rename tests/{miltertest-ip-multiple_rcpts.lua => ip-multiple_rcpts.lua} (100%) rename tests/{miltertest-ip.lua => ip.lua} (100%) delete mode 100644 tests/miltertest-null_sender.lua delete mode 100644 tests/miltertest-whitelisted_sender.lua rename tests/{miltertest-noauth.lua => noauth.lua} (100%) create mode 100644 tests/null_sender.lua rename tests/{miltertest-sasl-wildcard.lua => sasl-wildcard-dkim.lua} (100%) create mode 100644 tests/sasl-wildcard.lua rename tests/{miltertest-sasl.lua => sasl.lua} (100%) create mode 100644 tests/welcome-listed_rcpt.lua rename tests/{miltertest-x509_5321-fail_dkim-pass.lua => x509-dkim.lua} (100%) rename tests/{miltertest-x509.lua => x509.lua} (100%) diff --git a/app/lam.py b/app/lam.py index ceb3d33..72d2367 100644 --- a/app/lam.py +++ b/app/lam.py @@ -45,6 +45,7 @@ class LdapAclMilter(Milter.Base): def reset(self): self.proto_stage = 'invalid' self.env_from = None + self.null_sender = False self.sasl_user = None self.x509_subject = None self.x509_issuer = None @@ -154,34 +155,42 @@ class LdapAclMilter(Milter.Base): self.client_addr, self.x509_subject, self.x509_issuer, self.sasl_user ) ) - mailfrom = mailfrom.replace("<","") - mailfrom = mailfrom.replace(">","") - # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) - # Strip out Simple Private Signature (PRVS) - mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) - # SRS (https://www.libsrs2.org/srs/srs.pdf) - m_srs = g_rex_srs.match(mailfrom) - if m_srs != None: - self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) - mailfrom = m_srs.group(2) + '@' + m_srs.group(1) - self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) - self.env_from = mailfrom.lower() - self.log_debug("5321.from={}".format(self.env_from)) - m = g_rex_domain.match(self.env_from) - if m == None: - return self.milter_action( - action = 'reject', - reason = "Could not determine domain of 5321.from={}".format(self.env_from) - ) + if mailfrom == '<>': + self.null_sender = True + if g_config_backend.milter_allow_null_sender and self.null_sender: + self.log_info("Null-sender accepted - skipping policy checks") + else: + mailfrom = mailfrom.replace("<","") + mailfrom = mailfrom.replace(">","") + # BATV (https://tools.ietf.org/html/draft-levine-smtp-batv-01) + # Strip out Simple Private Signature (PRVS) + mailfrom = re.sub(r"^prvs=.{10}=", '', mailfrom) + # SRS (https://www.libsrs2.org/srs/srs.pdf) + m_srs = g_rex_srs.match(mailfrom) + if m_srs != None: + self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) + mailfrom = m_srs.group(2) + '@' + m_srs.group(1) + self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) + self.env_from = mailfrom.lower() + self.log_debug("5321.from={}".format(self.env_from)) + m = g_rex_domain.match(self.env_from) + if m == None: + return self.milter_action( + action = 'reject', + reason = "Could not determine domain of 5321.from={}".format(self.env_from) + ) return self.milter_action(action = 'continue') def envrcpt(self, to, *str): self.proto_stage = 'RCPT' + if g_config_backend.milter_allow_null_sender and self.null_sender: + return self.milter_action(action = 'continue') to = to.replace("<","") to = to.replace(">","") to = to.lower() self.log_debug("5321.rcpt={}".format(to)) if to in g_config_backend.milter_whitelisted_rcpts: + self.log_info("Welcome-listed rcpt={} - skipping policy checks".format(to)) return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled: # Collect all envelope-recipients for later @@ -214,6 +223,8 @@ class LdapAclMilter(Milter.Base): def header(self, hname, hval): self.proto_stage = 'HDR' self.queue_id = self.getsymval('i') + if g_config_backend.milter_allow_null_sender and self.null_sender: + return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled == True: # Parse RFC-5322-From header if(hname.lower() == "From".lower()): @@ -223,7 +234,7 @@ class LdapAclMilter(Milter.Base): if m is None: return self.milter_action( action = 'reject', - reason = "Could not determine domain-part of 5322.from=" + self.hdr_from + reason = "Could not determine domain-part of 5322.from={}".format(self.hdr_from) ) self.hdr_from_domain = m.group(1) self.log_debug("5322.from={0}, 5322.from_domain={1}".format( @@ -259,6 +270,8 @@ class LdapAclMilter(Milter.Base): return self.milter_action(action='reject', reason='Too many recipients!') else: self.do_log("TEST-Mode: Too many recipients!") + if g_config_backend.milter_allow_null_sender and self.null_sender: + return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled: self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts @@ -274,6 +287,8 @@ class LdapAclMilter(Milter.Base): )) reject_message = False for rcpt in self.env_rcpts: + if rcpt in g_config_backend.milter_whitelisted_rcpts: + self.log_info("Welcome-listed rcpt={}".format(rcpt)) try: # Check 5321.from against policy g_policy_backend.check_policy( diff --git a/app/lam_config_backend.py b/app/lam_config_backend.py index 31a700a..ddee5f4 100644 --- a/app/lam_config_backend.py +++ b/app/lam_config_backend.py @@ -26,6 +26,7 @@ class LamConfigBackend(): self.milter_trusted_authservid = None self.milter_max_rcpt_enabled = False self.milter_max_rcpt = 1 + self.milter_allow_null_sender = False if 'MILTER_NAME' in os.environ: self.milter_name = os.environ['MILTER_NAME'] @@ -150,4 +151,7 @@ class LamConfigBackend(): raise LamConfigBackendException("ENV[MILTER_MAX_RCPT] must be numeric!") log_info("ENV[MILTER_MAX_RCPT_ENABLED]: {}".format(self.milter_max_rcpt_enabled)) - + if 'MILTER_ALLOW_NULL_SENDER' in os.environ: + if re.match(r'^true$', os.environ['MILTER_ALLOW_NULL_SENDER'], re.IGNORECASE): + self.milter_allow_null_sender = True + log_info("ENV[MILTER_ALLOW_NULL_SENDER]: {}".format(self.milter_allow_null_sender)) diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index b92c5e6..ccde2b2 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -1,5 +1,5 @@ import re -from lam_logger import log_info, log_debug, log_error +from lam_logger import log_info, log_debug from lam_rex import g_rex_domain from ldap3 import ( Server, Connection, NONE, set_config_parameter @@ -8,9 +8,10 @@ from ldap3.core.exceptions import LDAPException from lam_exceptions import ( LamPolicyBackendException, LamHardException, LamSoftException ) +from lam_config_backend import LamConfigBackend class LamPolicyBackend(): - def __init__(self, lam_config): + def __init__(self, lam_config: LamConfigBackend): self.config = lam_config self.ldap_conn = None try: @@ -61,18 +62,18 @@ class LamPolicyBackend(): # LDAP-ACL-Milter schema auth_method = '' if self.config.milter_expect_auth == True: - auth_method = "(|(allowedClientAddr="+lam_session.client_addr+")%SASL_AUTH%%X509_AUTH%)" + auth_method = "(|(allowedClientAddr=" + lam_session.client_addr + ")%SASL_AUTH%%X509_AUTH%)" if lam_session.sasl_user: auth_method = auth_method.replace( - '%SASL_AUTH%',"(allowedSaslUser="+lam_session.sasl_user+")" + '%SASL_AUTH%',"(allowedSaslUser=" + lam_session.sasl_user + ")" ) else: auth_method = auth_method.replace('%SASL_AUTH%','') if lam_session.x509_subject and lam_session.x509_issuer: auth_method = auth_method.replace('%X509_AUTH%', "(&"+ - "(allowedx509subject="+lam_session.x509_subject+")"+ - "(allowedx509issuer="+lam_session.x509_issuer+")"+ + "(allowedx509subject=" + lam_session.x509_subject + ")" + + "(allowedx509issuer=" + lam_session.x509_issuer + ")" + ")" ) else: @@ -98,16 +99,26 @@ class LamPolicyBackend(): self.ldap_conn.search(self.config.ldap_base, "(&" + auth_method + - "(|"+ - "(allowedRcpts=" + rcpt_addr + ")"+ - "(allowedRcpts=\\2a@" + rcpt_domain + ")"+ - "(allowedRcpts=\\2a@\\2a)"+ - ")"+ - "(|"+ - "(allowedSenders=" + from_addr + ")"+ - "(allowedSenders=\\2a@" + from_domain + ")"+ - "(allowedSenders=\\2a@\\2a)"+ - ")"+ + "(|" + + "(allowedSenders=" + from_addr + ")" + + "(allowedSenders=\\2a@" + from_domain + ")" + + "(allowedSenders=\\2a@\\2a)" + + ")" + + "(&" + + "(!(deniedSenders=" + from_addr + "))" + + "(!(deniedSenders=\\2a@" + from_domain + "))" + + "(!(deniedSenders=\\2a@\\2a))" + + ")" + + "(|" + + "(allowedRcpts=" + rcpt_addr + ")" + + "(allowedRcpts=\\2a@" + rcpt_domain + ")" + + "(allowedRcpts=\\2a@\\2a)" + + ")" + + "(&" + + "(!(deniedRcpts=" + rcpt_addr + "))" + + "(!(deniedRcpts=\\2a@" + rcpt_domain + "))" + + "(!(deniedRcpts=\\2a@\\2a))" + + ")" + ")", attributes=['policyID'] ) @@ -119,8 +130,8 @@ class LamPolicyBackend(): self.ldap_conn.search(self.config.ldap_base, "(&" + auth_method + - "(allowedRcpts=" + query_to + ")" + "(allowedSenders=" + query_from + ")" + + "(allowedRcpts=" + query_to + ")" + ")", attributes=['policyID'] ) @@ -133,8 +144,8 @@ class LamPolicyBackend(): ) elif len(self.ldap_conn.entries) == 1: if from_source == 'from-header': - log_info("{0} 5322.from={1} authorized by DKIM signature".format( - mcid, from_addr + log_info("{0} 5322.from_domain={1} authorized by DKIM signature".format( + mcid, from_domain )) # Policy found in LDAP, but which one? entry = self.ldap_conn.entries[0] @@ -150,12 +161,13 @@ class LamPolicyBackend(): ) else: # Custom LDAP schema - # 'build' a LDAP query per recipient - # replace all placeholders in query templates + # replace all placeholders in query template query = self.config.ldap_query.replace("%rcpt%", rcpt_addr) query = query.replace("%from%", from_addr) - query = query.replace("%client_addr%", lam_session.client_addr) - query = query.replace("%sasl_user%", lam_session.sasl_user) + if self.config.milter_expect_auth: + query = query.replace("%client_addr%", lam_session.client_addr) + if lam_session.sasl_user is not None: + query = query.replace("%sasl_user%", lam_session.sasl_user) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) log_debug("{0} LDAP query: {1}".format(mcid, query)) diff --git a/tests/miltertest-ip-conn_reuse.lua b/tests/ip-conn_reuse.lua similarity index 100% rename from tests/miltertest-ip-conn_reuse.lua rename to tests/ip-conn_reuse.lua diff --git a/tests/miltertest-ip-fail.lua b/tests/ip-fail.lua similarity index 100% rename from tests/miltertest-ip-fail.lua rename to tests/ip-fail.lua diff --git a/tests/miltertest-ip-multiple_rcpts.lua b/tests/ip-multiple_rcpts.lua similarity index 100% rename from tests/miltertest-ip-multiple_rcpts.lua rename to tests/ip-multiple_rcpts.lua diff --git a/tests/miltertest-ip.lua b/tests/ip.lua similarity index 100% rename from tests/miltertest-ip.lua rename to tests/ip.lua diff --git a/tests/miltertest-null_sender.lua b/tests/miltertest-null_sender.lua deleted file mode 100644 index e69de29..0000000 diff --git a/tests/miltertest-whitelisted_sender.lua b/tests/miltertest-whitelisted_sender.lua deleted file mode 100644 index e69de29..0000000 diff --git a/tests/miltertest-noauth.lua b/tests/noauth.lua similarity index 100% rename from tests/miltertest-noauth.lua rename to tests/noauth.lua diff --git a/tests/null_sender.lua b/tests/null_sender.lua new file mode 100644 index 0000000..7aa7ca2 --- /dev/null +++ b/tests/null_sender.lua @@ -0,0 +1,43 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.255.255.254") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "<>") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"MAILER DAEMON') ~= nil then + error "mt.header(From) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-sasl-wildcard.lua b/tests/sasl-wildcard-dkim.lua similarity index 100% rename from tests/miltertest-sasl-wildcard.lua rename to tests/sasl-wildcard-dkim.lua diff --git a/tests/sasl-wildcard.lua b/tests/sasl-wildcard.lua new file mode 100644 index 0000000..b93d207 --- /dev/null +++ b/tests/sasl-wildcard.lua @@ -0,0 +1,47 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user-wild") +if mt.mailfrom(conn, "tester@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "test-wildcard-qid") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-sasl.lua b/tests/sasl.lua similarity index 100% rename from tests/miltertest-sasl.lua rename to tests/sasl.lua diff --git a/tests/welcome-listed_rcpt.lua b/tests/welcome-listed_rcpt.lua new file mode 100644 index 0000000..f86f279 --- /dev/null +++ b/tests/welcome-listed_rcpt.lua @@ -0,0 +1,56 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "some-sender@test.blah") ~= nil then + error "mt.mailfrom() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error("FROM-reject") +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT-reject") +end + +-- 5322.HEADERS +if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then + error "mt.header(From) failed" +end +if mt.header(conn, "Authentication-REsuLTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then + error "mt.header(Authentication-Results) failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/miltertest-x509_5321-fail_dkim-pass.lua b/tests/x509-dkim.lua similarity index 100% rename from tests/miltertest-x509_5321-fail_dkim-pass.lua rename to tests/x509-dkim.lua diff --git a/tests/miltertest-x509.lua b/tests/x509.lua similarity index 100% rename from tests/miltertest-x509.lua rename to tests/x509.lua From cc4bcc1e6999378762289156a139199aac7ff6e5 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 6 Mar 2022 11:56:42 +0100 Subject: [PATCH 10/15] LamSession init --- app/lam.py | 17 +++++++++-------- app/lam_policy_backend.py | 9 ++++++--- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/lam.py b/app/lam.py index 72d2367..54c4f9b 100644 --- a/app/lam.py +++ b/app/lam.py @@ -9,6 +9,7 @@ from lam_backends import g_config_backend, g_policy_backend from lam_rex import g_rex_domain, g_rex_srs from lam_logger import log_debug, log_info, log_warning, log_error from lam_exceptions import LamSoftException, LamHardException +from lam_session import LamSession class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread @@ -100,14 +101,6 @@ class LdapAclMilter(Milter.Base): self.setreply(smtp_code, smtp_ecode, message) return smfir - # Not registered/used callbacks - @Milter.nocallback - def eoh(self): - return self.milter_action(action = 'continue') - @Milter.nocallback - def body(self, chunk): - return self.milter_action(action = 'continue') - def connect(self, IPname, family, hostaddr): self.reset() self.proto_stage = 'CONNECT' @@ -262,6 +255,14 @@ class LdapAclMilter(Milter.Base): self.log_info("AR-parse exception: {0}".format(str(e))) return self.milter_action(action = 'continue') + # Not registered/used callbacks + @Milter.nocallback + def eoh(self): + return self.milter_action(action = 'continue') + @Milter.nocallback + def body(self, chunk): + return self.milter_action(action = 'continue') + def eom(self): self.proto_stage = 'EOM' if g_config_backend.milter_max_rcpt_enabled: diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index ccde2b2..3df2367 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -9,6 +9,7 @@ from lam_exceptions import ( LamPolicyBackendException, LamHardException, LamSoftException ) from lam_config_backend import LamConfigBackend +from lam_session import LamSession class LamPolicyBackend(): def __init__(self, lam_config: LamConfigBackend): @@ -59,7 +60,7 @@ class LamPolicyBackend(): log_debug("{0} rcpt_domain={1}".format(mcid, rcpt_domain)) try: if self.config.milter_schema == True: - # LDAP-ACL-Milter schema + # LDAP-ACL-Milter schema enabled auth_method = '' if self.config.milter_expect_auth == True: auth_method = "(|(allowedClientAddr=" + lam_session.client_addr + ")%SASL_AUTH%%X509_AUTH%)" @@ -124,14 +125,16 @@ class LamPolicyBackend(): ) else: # Wildcard-domain DISABLED - # Asterisk must be ASCII-HEX encoded for LDAP queries + # Asterisk (*) must be ASCII-HEX encoded for LDAP queries query_from = from_addr.replace("*","\\2a") query_to = rcpt_addr.replace("*","\\2a") self.ldap_conn.search(self.config.ldap_base, "(&" + auth_method + "(allowedSenders=" + query_from + ")" + + "(!(deniedSenders=" + query_from + "))" + "(allowedRcpts=" + query_to + ")" + + "(!(deniedRcpts=" + query_to + "))" + ")", attributes=['policyID'] ) @@ -149,7 +152,7 @@ class LamPolicyBackend(): )) # Policy found in LDAP, but which one? entry = self.ldap_conn.entries[0] - log_info("{0} match: '{1}' from_src={2}".format( + log_info("{0} match='{1}' from_src={2}".format( mcid, entry.policyID.value, from_source )) elif len(self.ldap_conn.entries) > 1: From 68d173fda22c9016715d84ec4a7d06365236d602 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 6 Mar 2022 23:49:45 +0100 Subject: [PATCH 11/15] more tests; LamSession() --- app/lam.py | 207 +++++++++++++++++----------------- app/lam_exceptions.py | 2 +- app/lam_policy_backend.py | 27 +++-- app/lam_session.py | 101 +++++++++++++++++ app/run_milter.py | 3 +- tests/sasl-wildcard-dkim.lua | 2 +- tests/too_many_rcpts.lua | 55 +++++++++ tests/x509-denied-rcpts.lua | 0 tests/x509-denied-senders.lua | 0 9 files changed, 275 insertions(+), 122 deletions(-) create mode 100644 app/lam_session.py create mode 100644 tests/too_many_rcpts.lua create mode 100644 tests/x509-denied-rcpts.lua create mode 100644 tests/x509-denied-senders.lua diff --git a/app/lam.py b/app/lam.py index 54c4f9b..70ad652 100644 --- a/app/lam.py +++ b/app/lam.py @@ -1,7 +1,5 @@ import Milter import traceback -import string -import random import re import email.utils import authres @@ -14,17 +12,16 @@ from lam_session import LamSession class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): - # client_addr gets overriden on any connect() - self.client_addr = None + self.session = None def do_log(self, **kwargs): log_line = '' - if hasattr(self, 'mconn_id'): - log_line = "{}".format(self.mconn_id) - if self.queue_id != 'invalid': - log_line = "{0}/{1}".format(log_line, self.queue_id) - if self.proto_stage != 'invalid': - log_line = "{0}/{1}".format(log_line, self.proto_stage) + if hasattr(self.session, 'mconn_id'): + log_line = "{}".format(self.session.get_mconn_id()) + if self.session.get_queue_id() != 'invalid': + log_line = "{0}/{1}".format(log_line, self.session.get_queue_id()) + if self.session.get_proto_stage() != 'invalid': + log_line = "{0}/{1}".format(log_line, self.session.get_proto_stage()) log_line = "{0} {1}".format(log_line, kwargs['log_message']) if kwargs['level'] == 'error': log_error(log_line) @@ -43,27 +40,7 @@ class LdapAclMilter(Milter.Base): def log_debug(self, log_message): self.do_log(level='debug', log_message=log_message) - def reset(self): - self.proto_stage = 'invalid' - self.env_from = None - self.null_sender = False - self.sasl_user = None - self.x509_subject = None - self.x509_issuer = None - self.queue_id = 'invalid' - self.env_rcpts = [] - self.hdr_from = None - self.hdr_from_domain = None - self.dkim_valid = False - self.dkim_aligned = False - self.passed_dkim_results = [] - self.log_debug("reset(): {}".format(self.__dict__)) - # https://stackoverflow.com/a/2257449 - self.mconn_id = g_config_backend.milter_name + ': ' + ''.join( - random.choice(string.ascii_lowercase + string.digits) for _ in range(8) - ) - - def milter_action(self, **kwargs): + def milter_action(self, **kwargs) -> int: if 'action' not in kwargs: raise Exception("'action' kwarg is mandatory!") message = None @@ -89,30 +66,26 @@ class LdapAclMilter(Milter.Base): if 'message' in kwargs: message = kwargs['message'] # prepend queue-id to message if it´s already available (DATA and later) - if self.queue_id != 'invalid': - message = " queue_id: {0} - {1}".format(self.queue_id, message) + if self.session.get_queue_id() != 'invalid': + message = " queue_id: {0} - {1}".format(self.session.get_queue_id(), message) # append reason to message if 'reason' in kwargs: message = "{0} - reason: {1}".format(message, kwargs['reason']) if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': self.log_info("{0} - milter_action={1} message={2}".format( - self.mconn_id, kwargs['action'], message + self.session.get_mconn_id(), kwargs['action'], message )) self.setreply(smtp_code, smtp_ecode, message) return smfir def connect(self, IPname, family, hostaddr): - self.reset() - self.proto_stage = 'CONNECT' - self.client_addr = hostaddr[0] - self.log_debug("client_addr={0}, client_port={1}".format( - self.client_addr, hostaddr[1]) - ) + self.session = LamSession(hostaddr[0], g_config_backend) + self.session.set_proto_stage('CONNECT') return self.milter_action(action = 'continue') def envfrom(self, mailfrom, *str): - self.reset() - self.proto_stage = 'FROM' + self.session.reset() + self.session.set_proto_stage('FROM') if g_config_backend.milter_expect_auth: try: # this may fail, if no x509 client certificate was used. @@ -121,14 +94,14 @@ class LdapAclMilter(Milter.Base): # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ x509_subject = self.getsymval('{cert_subject}') if x509_subject != None: - self.x509_subject = x509_subject - self.log_debug("x509_subject={}".format(self.x509_subject)) + self.session.set_x509_subject(x509_subject) + self.log_debug("x509_subject={}".format(self.session.get_x509_subject())) else: self.log_debug("No x509_subject registered") x509_issuer = self.getsymval('{cert_issuer}') if x509_issuer != None: - self.x509_issuer = x509_issuer - self.log_debug("x509_issuer={}".format(self.x509_issuer)) + self.session.set_x509_issuer(x509_issuer) + self.log_debug("x509_issuer={}".format(self.session.get_x509_issuer())) else: self.log_debug("No x509_issuer registered") except: @@ -137,20 +110,21 @@ class LdapAclMilter(Milter.Base): # this may fail, if no SASL authentication preceded sasl_user = self.getsymval('{auth_authen}') if sasl_user != None: - self.sasl_user = sasl_user - self.log_debug("sasl_user={}".format(self.sasl_user)) + self.session.set_sasl_user(sasl_user) + self.log_debug("sasl_user={}".format(self.session.get_sasl_user())) else: self.log_debug("No sasl_user registered") except: self.log_error("sasl_user exception: {}".format(traceback.format_exc())) self.log_info( - "auth: client_ip={0} x509_subject={1} x509_issuer={2} sasl_user={3}".format( - self.client_addr, self.x509_subject, self.x509_issuer, self.sasl_user + "auth: client_ip={0} sasl_user={1} x509_subject={2} x509_issuer={3}".format( + self.session.get_client_addr(), self.session.get_sasl_user(), + self.session.get_x509_subject(), self.session.get_x509_issuer() ) ) if mailfrom == '<>': - self.null_sender = True - if g_config_backend.milter_allow_null_sender and self.null_sender: + self.session.set_null_sender(True) + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): self.log_info("Null-sender accepted - skipping policy checks") else: mailfrom = mailfrom.replace("<","") @@ -164,19 +138,21 @@ class LdapAclMilter(Milter.Base): self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) - self.env_from = mailfrom.lower() - self.log_debug("5321.from={}".format(self.env_from)) - m = g_rex_domain.match(self.env_from) + self.session.set_env_from(mailfrom.lower()) + self.log_debug("5321.from={}".format(self.session.get_env_from())) + m = g_rex_domain.match(self.session.get_env_from()) if m == None: return self.milter_action( action = 'reject', - reason = "Could not determine domain of 5321.from={}".format(self.env_from) + reason = "Could not determine domain of 5321.from={}".format( + self.session.get_env_from() + ) ) return self.milter_action(action = 'continue') def envrcpt(self, to, *str): - self.proto_stage = 'RCPT' - if g_config_backend.milter_allow_null_sender and self.null_sender: + self.session.set_proto_stage('RCPT') + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): return self.milter_action(action = 'continue') to = to.replace("<","") to = to.replace(">","") @@ -189,17 +165,17 @@ class LdapAclMilter(Milter.Base): # Collect all envelope-recipients for later # investigation (EOM). Do not perform any # policy action at this protocol phase. - self.env_rcpts.append(to) + self.session.add_env_rcpt(to) else: # DKIM disabled. Policy enforcement takes place here. try: g_policy_backend.check_policy( - from_addr = self.env_from, + self.session, + from_addr = self.session.get_env_from(), rcpt_addr = to, - from_source = 'envelope', - lam_session = self + from_source = 'envelope' ) - self.env_rcpts.append(to) + self.session.add_env_rcpt(to) except LamSoftException as e: if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') @@ -214,24 +190,26 @@ class LdapAclMilter(Milter.Base): return self.milter_action(action = 'continue') def header(self, hname, hval): - self.proto_stage = 'HDR' - self.queue_id = self.getsymval('i') - if g_config_backend.milter_allow_null_sender and self.null_sender: + self.session.set_proto_stage('HDR') + self.session.set_queue_id(self.getsymval('i')) + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled == True: # Parse RFC-5322-From header if(hname.lower() == "From".lower()): hdr_5322_from = email.utils.parseaddr(hval) - self.hdr_from = hdr_5322_from[1].lower() - m = re.match(g_rex_domain, self.hdr_from) + self.session.set_hdr_from(hdr_5322_from[1].lower()) + m = re.match(g_rex_domain, self.session.get_hdr_from()) if m is None: return self.milter_action( action = 'reject', - reason = "Could not determine domain-part of 5322.from={}".format(self.hdr_from) + reason = "Could not determine domain-part of 5322.from={}".format( + self.session.get_hdr_from() + ) ) - self.hdr_from_domain = m.group(1) + self.session.set_hdr_from_domain(m.group(1)) self.log_debug("5322.from={0}, 5322.from_domain={1}".format( - self.hdr_from, self.hdr_from_domain + self.session.get_hdr_from(), self.session.get_hdr_from_domain() )) # Parse RFC-7601 Authentication-Results header elif(hname.lower() == "Authentication-Results".lower()): @@ -244,12 +222,14 @@ class LdapAclMilter(Milter.Base): for ar_result in ar.results: if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': - self.passed_dkim_results.append(ar_result.header_d.lower()) + self.session.add_passed_dkim_result(ar_result.header_d.lower()) self.log_debug("dkim=pass sdid={}".format(ar_result.header_d)) - self.dkim_valid = True + self.session.set_dkim_valid(True) else: - self.log_debug("Ignoring authentication results of {}".format( - ar.authserv_id) + self.log_debug( + "Ignoring authentication results of {}".format( + ar.authserv_id + ) ) except Exception as e: self.log_info("AR-parse exception: {0}".format(str(e))) @@ -264,63 +244,76 @@ class LdapAclMilter(Milter.Base): return self.milter_action(action = 'continue') def eom(self): - self.proto_stage = 'EOM' + self.session.set_proto_stage('EOM') if g_config_backend.milter_max_rcpt_enabled: - if len(self.env_rcpts) > int(g_config_backend.milter_max_rcpt): + if len(self.session.get_env_rcpts()) > int(g_config_backend.milter_max_rcpt): if g_config_backend.milter_mode == 'reject': return self.milter_action(action='reject', reason='Too many recipients!') else: - self.do_log("TEST-Mode: Too many recipients!") - if g_config_backend.milter_allow_null_sender and self.null_sender: + self.log_error("TEST-Mode: Too many recipients!") + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled: - self.log_info("5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( - self.env_from, self.hdr_from, self.hdr_from_domain, self.env_rcpts - )) - if self.dkim_valid: + self.log_info( + "5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( + self.session.get_env_from(), self.session.get_hdr_from(), + self.session.get_hdr_from_domain(), self.session.get_env_rcpts() + ) + ) + if self.session.is_dkim_valid(): # There is at least one valid DKIM signature! # Check if one of them is also aligned - for passed_dkim_sdid in self.passed_dkim_results: - if self.hdr_from_domain.lower() == passed_dkim_sdid.lower(): - self.dkim_aligned = True + for passed_dkim_sdid in self.session.get_passed_dkim_results(): + if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower(): + self.session.set_dkim_aligned(True) self.log_info("Found aligned DKIM signature for SDID: {0}".format( passed_dkim_sdid )) reject_message = False - for rcpt in self.env_rcpts: + for rcpt in self.session.get_env_rcpts(): if rcpt in g_config_backend.milter_whitelisted_rcpts: self.log_info("Welcome-listed rcpt={}".format(rcpt)) try: - # Check 5321.from against policy + # Check 5321.from <-> 5321.rcpt against policy g_policy_backend.check_policy( - from_addr=self.env_from, + self.session, + from_addr=self.session.get_env_from(), rcpt_addr=rcpt, - from_source='envelope', - lam_session=self + from_source='envelope' ) self.log_info( - "action=pass 5321.from={0} 5321.rcpt={1}".format(self.env_from, rcpt) + "action=pass 5321.from={0} 5321.rcpt={1}".format( + self.session.get_env_from(), rcpt + ) ) except LamSoftException as e: - self.log_info(e.message) + self.log_info(str(e)) if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: - self.log_info("TEST-Mode - tmpfail") + self.log_info("TEST-Mode - tmpfail: {}".format(str(e))) except LamHardException as e: self.log_info(e.message) - if self.dkim_aligned: + if self.session.is_dkim_aligned(): try: - # Check 5322.from against policy + # Check 5322.from <-> 5321.rcpt against policy g_policy_backend.check_policy( - from_addr=self.hdr_from, + self.session, + from_addr=self.session.get_hdr_from(), rcpt_addr=rcpt, - from_source='from-header', - lam_session=self + from_source='from-header' ) self.log_info( - "action=pass 5322.from={0} 5321.rcpt={1}".format(self.hdr_from, rcpt) + "action=pass 5322.from={0} 5321.rcpt={1}".format( + self.session.get_hdr_from(), rcpt + ) ) + except LamSoftException as e: + self.log_info(str(e)) + if g_config_backend.milter_mode == 'reject': + return self.milter_action(action = 'tmpfail') + else: + self.log_info("TEST-Mode - tmpfail: {}".format(str(e))) except LamHardException as e: reject_message = True else: @@ -337,19 +330,21 @@ class LdapAclMilter(Milter.Base): ) else: # * DKIM check disabled - # Iterate through all accepted envelope recipients and log - for rcpt in self.env_rcpts: - self.log_info("action=pass 5321.from={0} 5321.rcpt={1}".format(self.env_from, rcpt)) - + # Iterate through all accepted envelope recipients and log success + for rcpt in self.session.get_env_rcpts(): + self.log_info("action=pass 5321.from={0} 5321.rcpt={1}".format( + self.session.get_env_from(), rcpt) + ) + # No policy violations so far :-) return self.milter_action(action = 'continue') def abort(self): # Client disconnected prematurely - self.proto_stage = 'ABORT' + self.session.set_proto_stage('ABORT') return self.milter_action(action = 'continue') def close(self): # Always called, even when abort is called. # Clean up any external resources here. - self.proto_stage = 'CLOSE' + self.session.set_proto_stage('CLOSE') return self.milter_action(action = 'continue') diff --git a/app/lam_exceptions.py b/app/lam_exceptions.py index 0f234e9..d8742a0 100644 --- a/app/lam_exceptions.py +++ b/app/lam_exceptions.py @@ -17,4 +17,4 @@ class LamPolicyBackendException(LamException): pass class LamConfigBackendException(LamException): - pass \ No newline at end of file + pass diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index 3df2367..0250cc2 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -36,12 +36,11 @@ class LamPolicyBackend(): "Connection to LDAP-server failed: {}".format(str(e)) ) from e - def check_policy(self, **kwargs): + def check_policy(self, session: LamSession, **kwargs): from_addr = kwargs['from_addr'] rcpt_addr = kwargs['rcpt_addr'] from_source = kwargs['from_source'] - lam_session = kwargs['lam_session'] - mcid = "{}/Policy".format(lam_session.mconn_id) + mcid = "{}/Policy".format(session.get_mconn_id()) m = g_rex_domain.match(from_addr) if m == None: raise LamHardException( @@ -63,18 +62,22 @@ class LamPolicyBackend(): # LDAP-ACL-Milter schema enabled auth_method = '' if self.config.milter_expect_auth == True: - auth_method = "(|(allowedClientAddr=" + lam_session.client_addr + ")%SASL_AUTH%%X509_AUTH%)" - if lam_session.sasl_user: + auth_method = "(|(allowedClientAddr={})%SASL_AUTH%%X509_AUTH%)".format( + session.get_client_addr() + ) + if session.get_sasl_user(): auth_method = auth_method.replace( - '%SASL_AUTH%',"(allowedSaslUser=" + lam_session.sasl_user + ")" + '%SASL_AUTH%',"(allowedSaslUser={})".format( + session.get_sasl_user() + ) ) else: auth_method = auth_method.replace('%SASL_AUTH%','') - if lam_session.x509_subject and lam_session.x509_issuer: + if session.get_x509_subject() and session.get_x509_issuer(): auth_method = auth_method.replace('%X509_AUTH%', "(&"+ - "(allowedx509subject=" + lam_session.x509_subject + ")" + - "(allowedx509issuer=" + lam_session.x509_issuer + ")" + + "(allowedx509subject=" + session.get_x509_subject() + ")" + + "(allowedx509issuer=" + session.get_x509_issuer() + ")" + ")" ) else: @@ -168,9 +171,9 @@ class LamPolicyBackend(): query = self.config.ldap_query.replace("%rcpt%", rcpt_addr) query = query.replace("%from%", from_addr) if self.config.milter_expect_auth: - query = query.replace("%client_addr%", lam_session.client_addr) - if lam_session.sasl_user is not None: - query = query.replace("%sasl_user%", lam_session.sasl_user) + query = query.replace("%client_addr%", session.get_client_addr()) + if session.get_sasl_user() is not None: + query = query.replace("%sasl_user%", session.get_sasl_user()) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) log_debug("{0} LDAP query: {1}".format(mcid, query)) diff --git a/app/lam_session.py b/app/lam_session.py new file mode 100644 index 0000000..e47f445 --- /dev/null +++ b/app/lam_session.py @@ -0,0 +1,101 @@ +import string +import random +from lam_logger import log_debug +from lam_policy_backend import LamConfigBackend + +class LamSession(): + def __init__(self, client_addr: str, config: LamConfigBackend): + self.client_addr = client_addr + self.config = config + self.reset() + + def reset(self): + self.proto_stage = 'invalid' + self.env_from = None + self.null_sender = False + self.sasl_user = None + self.x509_subject = None + self.x509_issuer = None + self.queue_id = 'invalid' + self.env_rcpts = [] + self.hdr_from = None + self.hdr_from_domain = None + self.dkim_valid = False + self.dkim_aligned = False + self.passed_dkim_results = [] + log_debug("reset(): {}".format(self.__dict__)) + # https://stackoverflow.com/a/2257449 + self.mconn_id = self.config.milter_name + ': ' + ''.join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(8) + ) + + def get_client_addr(self) -> str: + return self.client_addr + + def set_proto_stage(self, stage: str): + self.proto_stage = stage + def get_proto_stage(self) -> str: + return self.proto_stage + + def set_env_from(self, env_from: str): + self.env_from = env_from + def get_env_from(self) -> str: + return self.env_from + + def set_null_sender(self, null_sender: bool): + self.null_sender = null_sender + def is_null_sender(self) -> bool: + return self.null_sender + + def set_sasl_user(self, sasl_user: str): + self.sasl_user = sasl_user + def get_sasl_user(self) -> str: + return self.sasl_user + + def set_x509_subject(self, x509_subject: str): + self.x509_subject = x509_subject + def get_x509_subject(self) -> str: + return self.x509_subject + + def set_x509_issuer(self, x509_issuer: str): + self.x509_issuer = x509_issuer + def get_x509_issuer(self) -> str: + return self.x509_issuer + + def set_queue_id(self, queue_id: str): + self.queue_id = queue_id + def get_queue_id(self) -> str: + return self.queue_id + + def add_env_rcpt(self, rcpt: str): + self.env_rcpts.append(rcpt) + def get_env_rcpts(self) -> list: + return self.env_rcpts + + def set_hdr_from(self, hdr_from: str): + self.hdr_from = hdr_from + def get_hdr_from(self) -> str: + return self.hdr_from + + def set_hdr_from_domain(self, hdr_from_domain: str): + self.hdr_from_domain = hdr_from_domain + def get_hdr_from_domain(self) -> str: + return self.hdr_from_domain + + def set_dkim_valid(self, dkim_valid: bool): + self.dkim_valid = dkim_valid + def is_dkim_valid(self) -> bool: + return self.dkim_valid + + def set_dkim_aligned(self, dkim_aligned: bool): + self.dkim_aligned = dkim_aligned + def is_dkim_aligned(self) -> bool: + return self.dkim_aligned + + def add_passed_dkim_result(self, dkim_result: str): + self.passed_dkim_results.append(dkim_result) + def get_passed_dkim_results(self) -> list: + return self.passed_dkim_results + + def get_mconn_id(self) -> str: + return self.mconn_id \ No newline at end of file diff --git a/app/run_milter.py b/app/run_milter.py index 020296f..a175851 100644 --- a/app/run_milter.py +++ b/app/run_milter.py @@ -12,7 +12,6 @@ from lam import LdapAclMilter if __name__ == "__main__": try: - timeout = lam_backends.g_config_backend.milter_timeout # Register to have the Milter factory create instances of your class: Milter.factory = LdapAclMilter # Tell the MTA which features we use @@ -24,7 +23,7 @@ if __name__ == "__main__": Milter.runmilter( lam_backends.g_config_backend.milter_name, lam_backends.g_config_backend.milter_socket, - timeout, + lam_backends.g_config_backend.milter_timeout, True ) log_info("Shutdown {}".format(lam_backends.g_config_backend.milter_name)) diff --git a/tests/sasl-wildcard-dkim.lua b/tests/sasl-wildcard-dkim.lua index 6d23d79..c26c7d1 100644 --- a/tests/sasl-wildcard-dkim.lua +++ b/tests/sasl-wildcard-dkim.lua @@ -6,7 +6,7 @@ conn = mt.connect(socket) if conn == nil then error "mt.connect() failed" end -if mt.conninfo(conn, "localhost", "::1") ~= nil then +if mt.conninfo(conn, "localhost", "2001:db8:dead:beef::1234") ~= nil then error "mt.conninfo() failed" end diff --git a/tests/too_many_rcpts.lua b/tests/too_many_rcpts.lua new file mode 100644 index 0000000..6408857 --- /dev/null +++ b/tests/too_many_rcpts.lua @@ -0,0 +1,55 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "blubb-ip.host", "127.128.129.130") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM +if mt.mailfrom(conn, "tester-sender@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- FIRST 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT1-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT1-reject") +end + +-- SECOND 5321.RCPT +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("RCPT2-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("RCPT2-reject") +end + +-- SET RCPT-MACRO +mt.macro(conn, SMFIC_RCPT, "i", "some-queue-id") + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/x509-denied-rcpts.lua b/tests/x509-denied-rcpts.lua new file mode 100644 index 0000000..e69de29 diff --git a/tests/x509-denied-senders.lua b/tests/x509-denied-senders.lua new file mode 100644 index 0000000..e69de29 From b49a98751278039fc2f5fa1ca96e6bbf2fbfac33 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 14 Mar 2022 00:12:18 +0100 Subject: [PATCH 12/15] decoupling of logging backend --- app/lam.py | 169 +++++++++++++++++++++----------------- app/lam_backends.py | 4 +- app/lam_config_backend.py | 2 +- app/lam_log_backend.py | 56 +++++++++++++ app/lam_logger.py | 34 -------- app/lam_policy_backend.py | 40 +++++---- app/lam_session.py | 8 +- app/run_milter.py | 2 +- 8 files changed, 177 insertions(+), 138 deletions(-) create mode 100644 app/lam_log_backend.py delete mode 100644 app/lam_logger.py diff --git a/app/lam.py b/app/lam.py index 70ad652..b145599 100644 --- a/app/lam.py +++ b/app/lam.py @@ -5,7 +5,7 @@ import email.utils import authres from lam_backends import g_config_backend, g_policy_backend from lam_rex import g_rex_domain, g_rex_srs -from lam_logger import log_debug, log_info, log_warning, log_error +from lam_log_backend import log_debug, log_info, log_error from lam_exceptions import LamSoftException, LamHardException from lam_session import LamSession @@ -14,32 +14,6 @@ class LdapAclMilter(Milter.Base): def __init__(self): self.session = None - def do_log(self, **kwargs): - log_line = '' - if hasattr(self.session, 'mconn_id'): - log_line = "{}".format(self.session.get_mconn_id()) - if self.session.get_queue_id() != 'invalid': - log_line = "{0}/{1}".format(log_line, self.session.get_queue_id()) - if self.session.get_proto_stage() != 'invalid': - log_line = "{0}/{1}".format(log_line, self.session.get_proto_stage()) - log_line = "{0} {1}".format(log_line, kwargs['log_message']) - if kwargs['level'] == 'error': - log_error(log_line) - elif kwargs['level'] == 'warn' or kwargs['level'] == 'warning': - log_warning(log_line) - elif kwargs['level'] == 'info': - log_info(log_line) - elif kwargs['level'] == 'debug': - log_debug(log_line) - def log_error(self, log_message): - self.do_log(level='error', log_message=log_message) - def log_warn(self, log_message): - self.do_log(level='warn', log_message=log_message) - def log_info(self, log_message): - self.do_log(level='info', log_message=log_message) - def log_debug(self, log_message): - self.do_log(level='debug', log_message=log_message) - def milter_action(self, **kwargs) -> int: if 'action' not in kwargs: raise Exception("'action' kwarg is mandatory!") @@ -72,14 +46,15 @@ class LdapAclMilter(Milter.Base): if 'reason' in kwargs: message = "{0} - reason: {1}".format(message, kwargs['reason']) if kwargs['action'] == 'reject' or kwargs['action'] == 'tmpfail': - self.log_info("{0} - milter_action={1} message={2}".format( - self.session.get_mconn_id(), kwargs['action'], message - )) + log_info( + "milter_action={0} message={1}".format(kwargs['action'], message), + self.session + ) self.setreply(smtp_code, smtp_ecode, message) return smfir def connect(self, IPname, family, hostaddr): - self.session = LamSession(hostaddr[0], g_config_backend) + self.session = LamSession(hostaddr[0]) self.session.set_proto_stage('CONNECT') return self.milter_action(action = 'continue') @@ -95,37 +70,53 @@ class LdapAclMilter(Milter.Base): x509_subject = self.getsymval('{cert_subject}') if x509_subject != None: self.session.set_x509_subject(x509_subject) - self.log_debug("x509_subject={}".format(self.session.get_x509_subject())) + log_debug( + "x509_subject={}".format(self.session.get_x509_subject()), + self.session + ) else: - self.log_debug("No x509_subject registered") + log_debug("No x509_subject registered", self.session) x509_issuer = self.getsymval('{cert_issuer}') if x509_issuer != None: self.session.set_x509_issuer(x509_issuer) - self.log_debug("x509_issuer={}".format(self.session.get_x509_issuer())) + log_debug( + "x509_issuer={}".format(self.session.get_x509_issuer()), + self.session + ) else: - self.log_debug("No x509_issuer registered") + log_debug("No x509_issuer registered", self.session) except: - self.log_error("x509 exception: {}".format(traceback.format_exc())) + log_error( + "x509 exception: {}".format(traceback.format_exc()), + self.session + ) try: # this may fail, if no SASL authentication preceded sasl_user = self.getsymval('{auth_authen}') if sasl_user != None: self.session.set_sasl_user(sasl_user) - self.log_debug("sasl_user={}".format(self.session.get_sasl_user())) + log_debug( + "sasl_user={}".format(self.session.get_sasl_user()), + self.session + ) else: - self.log_debug("No sasl_user registered") + log_debug("No sasl_user registered", self.session) except: - self.log_error("sasl_user exception: {}".format(traceback.format_exc())) - self.log_info( + log_error( + "sasl_user exception: {}".format(traceback.format_exc()), + self.session + ) + log_info( "auth: client_ip={0} sasl_user={1} x509_subject={2} x509_issuer={3}".format( self.session.get_client_addr(), self.session.get_sasl_user(), self.session.get_x509_subject(), self.session.get_x509_issuer() - ) + ), + self.session ) if mailfrom == '<>': self.session.set_null_sender(True) if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): - self.log_info("Null-sender accepted - skipping policy checks") + log_info("Null-sender accepted - skipping policy checks", self.session) else: mailfrom = mailfrom.replace("<","") mailfrom = mailfrom.replace(">","") @@ -135,11 +126,17 @@ class LdapAclMilter(Milter.Base): # SRS (https://www.libsrs2.org/srs/srs.pdf) m_srs = g_rex_srs.match(mailfrom) if m_srs != None: - self.log_info("Found SRS-encoded envelope-sender: {}".format(mailfrom)) + log_info( + "Found SRS-encoded envelope-sender: {}".format(mailfrom), + self.session + ) mailfrom = m_srs.group(2) + '@' + m_srs.group(1) - self.log_info("SRS envelope-sender replaced with: {}".format(mailfrom)) + log_info( + "SRS envelope-sender replaced with: {}".format(mailfrom), + self.session + ) self.session.set_env_from(mailfrom.lower()) - self.log_debug("5321.from={}".format(self.session.get_env_from())) + log_debug("5321.from={}".format(self.session.get_env_from()), self.session) m = g_rex_domain.match(self.session.get_env_from()) if m == None: return self.milter_action( @@ -157,9 +154,12 @@ class LdapAclMilter(Milter.Base): to = to.replace("<","") to = to.replace(">","") to = to.lower() - self.log_debug("5321.rcpt={}".format(to)) + log_debug("5321.rcpt={}".format(to), self.session) if to in g_config_backend.milter_whitelisted_rcpts: - self.log_info("Welcome-listed rcpt={} - skipping policy checks".format(to)) + log_info( + "Welcome-listed rcpt={} - skipping policy checks".format(to), + self.session + ) return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled: # Collect all envelope-recipients for later @@ -186,7 +186,7 @@ class LdapAclMilter(Milter.Base): reason = e.message ) else: - self.log_info("TEST-Mode: {}".format(e.message)) + log_info("TEST-Mode: {}".format(e.message), self.session) return self.milter_action(action = 'continue') def header(self, hname, hval): @@ -208,9 +208,12 @@ class LdapAclMilter(Milter.Base): ) ) self.session.set_hdr_from_domain(m.group(1)) - self.log_debug("5322.from={0}, 5322.from_domain={1}".format( - self.session.get_hdr_from(), self.session.get_hdr_from_domain() - )) + log_debug( + "5322.from={0}, 5322.from_domain={1}".format( + self.session.get_hdr_from(), self.session.get_hdr_from_domain() + ), + self.session + ) # Parse RFC-7601 Authentication-Results header elif(hname.lower() == "Authentication-Results".lower()): ar = None @@ -223,16 +226,18 @@ class LdapAclMilter(Milter.Base): if ar_result.method.lower() == 'dkim': if ar_result.result.lower() == 'pass': self.session.add_passed_dkim_result(ar_result.header_d.lower()) - self.log_debug("dkim=pass sdid={}".format(ar_result.header_d)) + log_debug( + "dkim=pass sdid={}".format(ar_result.header_d), + self.session + ) self.session.set_dkim_valid(True) else: - self.log_debug( - "Ignoring authentication results of {}".format( - ar.authserv_id - ) + log_debug( + "Ignoring authentication results of {}".format(ar.authserv_id), + self.session ) except Exception as e: - self.log_info("AR-parse exception: {0}".format(str(e))) + log_info("AR-parse exception: {0}".format(str(e)), self.session) return self.milter_action(action = 'continue') # Not registered/used callbacks @@ -250,15 +255,16 @@ class LdapAclMilter(Milter.Base): if g_config_backend.milter_mode == 'reject': return self.milter_action(action='reject', reason='Too many recipients!') else: - self.log_error("TEST-Mode: Too many recipients!") + log_error("TEST-Mode: Too many recipients!", self.session) if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled: - self.log_info( + log_info( "5321.from={0} 5322.from={1} 5322.from_domain={2} 5321.rcpt={3}".format( self.session.get_env_from(), self.session.get_hdr_from(), self.session.get_hdr_from_domain(), self.session.get_env_rcpts() - ) + ), + self.session ) if self.session.is_dkim_valid(): # There is at least one valid DKIM signature! @@ -266,13 +272,16 @@ class LdapAclMilter(Milter.Base): for passed_dkim_sdid in self.session.get_passed_dkim_results(): if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower(): self.session.set_dkim_aligned(True) - self.log_info("Found aligned DKIM signature for SDID: {0}".format( - passed_dkim_sdid - )) + log_info( + "Found aligned DKIM signature for SDID: {0}".format( + passed_dkim_sdid + ), + self.session + ) reject_message = False for rcpt in self.session.get_env_rcpts(): if rcpt in g_config_backend.milter_whitelisted_rcpts: - self.log_info("Welcome-listed rcpt={}".format(rcpt)) + log_info("Welcome-listed rcpt={}".format(rcpt), self.session) try: # Check 5321.from <-> 5321.rcpt against policy g_policy_backend.check_policy( @@ -281,19 +290,20 @@ class LdapAclMilter(Milter.Base): rcpt_addr=rcpt, from_source='envelope' ) - self.log_info( + log_info( "action=pass 5321.from={0} 5321.rcpt={1}".format( self.session.get_env_from(), rcpt - ) + ), + self.session ) except LamSoftException as e: - self.log_info(str(e)) + log_info(str(e), self.session) if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: - self.log_info("TEST-Mode - tmpfail: {}".format(str(e))) + log_info("TEST-Mode - tmpfail: {}".format(str(e)), self.session) except LamHardException as e: - self.log_info(e.message) + log_info(e.message, self.session) if self.session.is_dkim_aligned(): try: # Check 5322.from <-> 5321.rcpt against policy @@ -303,17 +313,18 @@ class LdapAclMilter(Milter.Base): rcpt_addr=rcpt, from_source='from-header' ) - self.log_info( + log_info( "action=pass 5322.from={0} 5321.rcpt={1}".format( self.session.get_hdr_from(), rcpt - ) + ), + self.session ) except LamSoftException as e: - self.log_info(str(e)) + log_info(str(e), self.session) if g_config_backend.milter_mode == 'reject': return self.milter_action(action = 'tmpfail') else: - self.log_info("TEST-Mode - tmpfail: {}".format(str(e))) + log_info("TEST-Mode - tmpfail: {}".format(str(e)), self.session) except LamHardException as e: reject_message = True else: @@ -325,15 +336,19 @@ class LdapAclMilter(Milter.Base): reason = 'policy mismatch! Message rejected for all recipients!' ) else: - self.log_info( - "TEST-Mode: policy mismatch! Message would be rejected for all recipients!" + log_info( + "TEST-Mode: policy mismatch! Message would be rejected for all recipients!", + self.session ) else: # * DKIM check disabled # Iterate through all accepted envelope recipients and log success for rcpt in self.session.get_env_rcpts(): - self.log_info("action=pass 5321.from={0} 5321.rcpt={1}".format( - self.session.get_env_from(), rcpt) + log_info( + "action=pass 5321.from={0} 5321.rcpt={1}".format( + self.session.get_env_from(), rcpt + ), + self.session ) # No policy violations so far :-) return self.milter_action(action = 'continue') diff --git a/app/lam_backends.py b/app/lam_backends.py index 26384ca..7ef940c 100644 --- a/app/lam_backends.py +++ b/app/lam_backends.py @@ -2,11 +2,11 @@ import traceback from lam_exceptions import ( LamInitException, LamPolicyBackendException, LamConfigBackendException ) -from lam_logger import init_logger +from lam_log_backend import init_log_backend from lam_config_backend import LamConfigBackend from lam_policy_backend import LamPolicyBackend -init_logger() +init_log_backend() g_config_backend = None try: diff --git a/app/lam_config_backend.py b/app/lam_config_backend.py index ddee5f4..fde5685 100644 --- a/app/lam_config_backend.py +++ b/app/lam_config_backend.py @@ -1,8 +1,8 @@ import re import os -from lam_logger import log_info from lam_exceptions import LamConfigBackendException from lam_rex import g_rex_email +from lam_log_backend import log_info class LamConfigBackend(): def __init__(self): diff --git a/app/lam_log_backend.py b/app/lam_log_backend.py new file mode 100644 index 0000000..0ee9550 --- /dev/null +++ b/app/lam_log_backend.py @@ -0,0 +1,56 @@ +import logging +import re +import os +from typing import Optional +from lam_session import LamSession + +def init_log_backend(): + log_level = logging.INFO + if 'LOG_LEVEL' in os.environ: + if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.INFO + elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.WARN + elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.ERROR + elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): + log_level = logging.DEBUG + log_format = '%(asctime)s: %(levelname)s %(message)s ' + logging.basicConfig( + filename = None, # log to stdout + format = log_format, + level = log_level + ) + logging.info("Logger initialized") + +def do_log(level: str, log_message: str, session: Optional[LamSession] = None): + log_line = '' + if session is not None: + if hasattr(session, 'mconn_id'): + log_line = "{}".format(session.get_mconn_id()) + if session is not None: + if session.get_queue_id() != 'invalid': + log_line = "{0}/{1}".format(log_line, session.get_queue_id()) + if session is not None and session.get_proto_stage() != 'invalid': + log_line = "{0}/{1}".format(log_line, session.get_proto_stage()) + log_line = "{0}{1}".format(log_line, log_message) + if level == 'error': + logging.error(log_line) + elif level == 'warn' or level == 'warning': + logging.warning(log_line) + elif level == 'info': + logging.info(log_line) + elif level == 'debug': + logging.debug(log_line) + +def log_error(log_message: str, session: Optional[LamSession] = None): + do_log('error', log_message, session) + +def log_warning(log_message: str, session: Optional[LamSession] = None): + do_log('warn', log_message, session) + +def log_info(log_message: str, session: Optional[LamSession] = None): + do_log('info', log_message, session) + +def log_debug(log_message: str, session: Optional[LamSession] = None): + do_log('debug', log_message, session) diff --git a/app/lam_logger.py b/app/lam_logger.py deleted file mode 100644 index 5e651c3..0000000 --- a/app/lam_logger.py +++ /dev/null @@ -1,34 +0,0 @@ -import logging -import re -import os - -def init_logger(): - log_level = logging.INFO - if 'LOG_LEVEL' in os.environ: - if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.INFO - elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.WARN - elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.ERROR - elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): - log_level = logging.DEBUG - log_format = '%(asctime)s: %(levelname)s %(message)s ' - logging.basicConfig( - filename = None, # log to stdout - format = log_format, - level = log_level - ) - logging.info("Logger initialized") - -def log_info(message): - logging.info(message) - -def log_warning(message): - logging.warning(message) - -def log_error(message): - logging.error(message) - -def log_debug(message): - logging.debug(message) \ No newline at end of file diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index 0250cc2..c6c89b2 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -1,5 +1,4 @@ import re -from lam_logger import log_info, log_debug from lam_rex import g_rex_domain from ldap3 import ( Server, Connection, NONE, set_config_parameter @@ -10,6 +9,7 @@ from lam_exceptions import ( ) from lam_config_backend import LamConfigBackend from lam_session import LamSession +from lam_log_backend import log_info, log_debug class LamPolicyBackend(): def __init__(self, lam_config: LamConfigBackend): @@ -23,7 +23,8 @@ class LamPolicyBackend(): connect_timeout = self.config.ldap_server_connect_timeout, get_info = NONE ) - self.ldap_conn = Connection(server, + self.ldap_conn = Connection( + server, self.config.ldap_binddn, self.config.ldap_bindpw, auto_bind = True, @@ -40,23 +41,22 @@ class LamPolicyBackend(): from_addr = kwargs['from_addr'] rcpt_addr = kwargs['rcpt_addr'] from_source = kwargs['from_source'] - mcid = "{}/Policy".format(session.get_mconn_id()) m = g_rex_domain.match(from_addr) if m == None: raise LamHardException( - "Could not determine domain of from={0}".format(from_addr) - ) + "Could not determine domain of from={}".format(from_addr) + ) from_domain = m.group(1) - log_debug("{0} from_domain={1}".format(mcid, from_domain)) + log_debug("from_domain={}".format(from_domain), session) m = g_rex_domain.match(rcpt_addr) if m == None: raise LamHardException( - "Could not determine domain of rcpt={0}".format( + "Could not determine domain of rcpt={}".format( rcpt_addr ) ) rcpt_domain = m.group(1) - log_debug("{0} rcpt_domain={1}".format(mcid, rcpt_domain)) + log_debug("rcpt_domain={}".format(rcpt_domain), session) try: if self.config.milter_schema == True: # LDAP-ACL-Milter schema enabled @@ -82,7 +82,7 @@ class LamPolicyBackend(): ) else: auth_method = auth_method.replace('%X509_AUTH%','') - log_debug("{0} auth_method: {1}".format(mcid, auth_method)) + log_debug("auth_method: {}".format(auth_method), session) if self.config.milter_schema_wildcard_domain == True: # The asterisk (*) character is in term of local part # RFC5322 compliant and expected as a wildcard literal in this code. @@ -150,14 +150,20 @@ class LamPolicyBackend(): ) elif len(self.ldap_conn.entries) == 1: if from_source == 'from-header': - log_info("{0} 5322.from_domain={1} authorized by DKIM signature".format( - mcid, from_domain - )) + log_info( + "5322.from_domain={} authorized by DKIM signature".format( + from_domain + ), + session + ) # Policy found in LDAP, but which one? entry = self.ldap_conn.entries[0] - log_info("{0} match='{1}' from_src={2}".format( - mcid, entry.policyID.value, from_source - )) + log_info( + "match='{0}' from_src={1}".format( + entry.policyID.value, from_source + ), + session + ) elif len(self.ldap_conn.entries) > 1: # Something went wrong!? There shouldn´t be more than one entries! raise LamHardException( @@ -176,7 +182,7 @@ class LamPolicyBackend(): query = query.replace("%sasl_user%", session.get_sasl_user()) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) - log_debug("{0} LDAP query: {1}".format(mcid, query)) + log_debug("LDAP query: {}".format(query), session) self.ldap_conn.search(self.config.ldap_base, query) if len(self.ldap_conn.entries) == 0: raise LamHardException( @@ -184,6 +190,6 @@ class LamPolicyBackend(): from_source, from_addr, rcpt_addr ) ) - log_info("{0} match from_src={1}".format(mcid, from_source)) + log_info("match from_src={}".format(from_source), session) except LDAPException as e: raise LamSoftException("LDAP exception: " + str(e)) from e diff --git a/app/lam_session.py b/app/lam_session.py index e47f445..a7eeaae 100644 --- a/app/lam_session.py +++ b/app/lam_session.py @@ -1,12 +1,9 @@ import string import random -from lam_logger import log_debug -from lam_policy_backend import LamConfigBackend class LamSession(): - def __init__(self, client_addr: str, config: LamConfigBackend): + def __init__(self, client_addr: str): self.client_addr = client_addr - self.config = config self.reset() def reset(self): @@ -23,9 +20,8 @@ class LamSession(): self.dkim_valid = False self.dkim_aligned = False self.passed_dkim_results = [] - log_debug("reset(): {}".format(self.__dict__)) # https://stackoverflow.com/a/2257449 - self.mconn_id = self.config.milter_name + ': ' + ''.join( + self.mconn_id = ''.join( random.choice(string.ascii_lowercase + string.digits) for _ in range(8) ) diff --git a/app/run_milter.py b/app/run_milter.py index a175851..103a7a1 100644 --- a/app/run_milter.py +++ b/app/run_milter.py @@ -2,7 +2,7 @@ import Milter import sys import traceback from lam_exceptions import LamInitException -from lam_logger import log_info, log_error +from lam_log_backend import log_info, log_error try: import lam_backends except LamInitException as e: From 672d5d6355660f796647a497e784ebb42db3faa4 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sun, 4 Jun 2023 19:35:35 +0200 Subject: [PATCH 13/15] refinement --- app/lam.py | 34 +++++++++++++++++++------- app/lam_log_backend.py | 2 +- tests/null_sender.lua | 5 ++++ tests/{sasl.lua => sasl-dkim-pass.lua} | 0 tests/x509.lua | 3 --- 5 files changed, 31 insertions(+), 13 deletions(-) rename tests/{sasl.lua => sasl-dkim-pass.lua} (100%) diff --git a/app/lam.py b/app/lam.py index b145599..5f2ce39 100644 --- a/app/lam.py +++ b/app/lam.py @@ -5,7 +5,7 @@ import email.utils import authres from lam_backends import g_config_backend, g_policy_backend from lam_rex import g_rex_domain, g_rex_srs -from lam_log_backend import log_debug, log_info, log_error +from lam_log_backend import log_debug, log_info, log_warning, log_error from lam_exceptions import LamSoftException, LamHardException from lam_session import LamSession @@ -164,7 +164,7 @@ class LdapAclMilter(Milter.Base): if g_config_backend.milter_dkim_enabled: # Collect all envelope-recipients for later # investigation (EOM). Do not perform any - # policy action at this protocol phase. + # policy action in this protocol stage. self.session.add_env_rcpt(to) else: # DKIM disabled. Policy enforcement takes place here. @@ -188,25 +188,37 @@ class LdapAclMilter(Milter.Base): else: log_info("TEST-Mode: {}".format(e.message), self.session) return self.milter_action(action = 'continue') + + def data(self): + self.session.set_proto_stage('DATA') + if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): + return self.milter_action(action = 'continue') + self.session.set_queue_id(self.getsymval('i')) + log_debug( + "Queue-id: {}".format(self.session.get_queue_id()), + self.session + ) + return self.milter_action(action = 'continue') def header(self, hname, hval): self.session.set_proto_stage('HDR') - self.session.set_queue_id(self.getsymval('i')) if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): return self.milter_action(action = 'continue') if g_config_backend.milter_dkim_enabled == True: # Parse RFC-5322-From header - if(hname.lower() == "From".lower()): + if(hname.lower() == "from"): + log_debug("hname={0}, hval={1}".format(hname, hval), self.session) hdr_5322_from = email.utils.parseaddr(hval) self.session.set_hdr_from(hdr_5322_from[1].lower()) m = re.match(g_rex_domain, self.session.get_hdr_from()) if m is None: - return self.milter_action( - action = 'reject', - reason = "Could not determine domain-part of 5322.from={}".format( + log_warning( + "Could not determine domain part of 5322.from={}".format( self.session.get_hdr_from() - ) + ), + self.session ) + return self.milter_action(action = 'continue') self.session.set_hdr_from_domain(m.group(1)) log_debug( "5322.from={0}, 5322.from_domain={1}".format( @@ -215,7 +227,11 @@ class LdapAclMilter(Milter.Base): self.session ) # Parse RFC-7601 Authentication-Results header - elif(hname.lower() == "Authentication-Results".lower()): + elif(hname.lower() == "authentication-results"): + if not self.session.get_hdr_from_domain(): + log_debug("DKIM validation impossible - no 5321.from_domain", self.session) + return self.milter_action(action = 'continue') + log_debug("hname={0}, hval={1}".format(hname, hval), self.session) ar = None try: ar = authres.AuthenticationResultsHeader.parse( diff --git a/app/lam_log_backend.py b/app/lam_log_backend.py index 0ee9550..615f20b 100644 --- a/app/lam_log_backend.py +++ b/app/lam_log_backend.py @@ -33,7 +33,7 @@ def do_log(level: str, log_message: str, session: Optional[LamSession] = None): log_line = "{0}/{1}".format(log_line, session.get_queue_id()) if session is not None and session.get_proto_stage() != 'invalid': log_line = "{0}/{1}".format(log_line, session.get_proto_stage()) - log_line = "{0}{1}".format(log_line, log_message) + log_line = "{0} {1}".format(log_line, log_message) if level == 'error': logging.error(log_line) elif level == 'warn' or level == 'warning': diff --git a/tests/null_sender.lua b/tests/null_sender.lua index 7aa7ca2..8b284cd 100644 --- a/tests/null_sender.lua +++ b/tests/null_sender.lua @@ -16,6 +16,11 @@ mt.set_timeout(60) if mt.mailfrom(conn, "<>") ~= nil then error "mt.mailfrom() failed" end +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("FROM-continue - null_sender allowed") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + error "FROM-reject - disconnect" +end -- 5321.RCPT+MACROS mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") diff --git a/tests/sasl.lua b/tests/sasl-dkim-pass.lua similarity index 100% rename from tests/sasl.lua rename to tests/sasl-dkim-pass.lua diff --git a/tests/x509.lua b/tests/x509.lua index f9838cf..2d25108 100644 --- a/tests/x509.lua +++ b/tests/x509.lua @@ -29,9 +29,6 @@ end if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then error "mt.header(From) failed" end -if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then - error "mt.header(Authentication-Results) failed" -end -- EOM if mt.eom(conn) ~= nil then From 08eaee66b572d3c205c0652d94039bcb55eb62d9 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Mon, 5 Jun 2023 15:57:16 +0200 Subject: [PATCH 14/15] ldap3 thread-safe + refactoring --- README.md | 29 +++++++++++++------ app/lam.py | 36 ++++++++++-------------- app/lam_config_backend.py | 2 +- app/lam_log_backend.py | 2 +- app/lam_policy_backend.py | 59 ++++++++++++++++++++------------------- tests/sasl-fail.lua | 39 ++++++++++++++++++++++++++ tests/sasl-wildcard.lua | 3 -- 7 files changed, 107 insertions(+), 63 deletions(-) create mode 100644 tests/sasl-fail.lua diff --git a/README.md b/README.md index b8a7f8c..6ca53be 100644 --- a/README.md +++ b/README.md @@ -62,14 +62,14 @@ services: #LDAP_QUERY: (&(mail=%rcpt%)(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%))) # LDAP_QUERY: (&(|(mail=%rcpt%)(mail=*@%rcpt_domain%))(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%))) # This enables the use of own ldap-acl-milter LDAP schema. Default: False - # Setting MILTER_SCHEMA: True disables the LDAP_QUERY parameter! - MILTER_SCHEMA: 'True' - # If MILTER_SCHEMA_WILDCARD_DOMAIN is set to True, the milter allows *@domain + # Setting MILTER_SCHEMA: true disables the LDAP_QUERY parameter! + #MILTER_SCHEMA: 'true' + # If MILTER_SCHEMA_WILDCARD_DOMAIN is set to true, the milter allows *@domain # as valid sender/recipient addresses in LDAP. # This only works if MILTER_SCHEMA is enabled! MILTER_SCHEMA_WILDCARD_DOMAIN: 'False' # default: test. Possible: test, reject - MILTER_MODE: 'reject' + #MILTER_MODE: 'reject' MILTER_NAME: some-another-milter-name # Default: UNIX-socket located under /socket/ldap-acl-milter # https://pythonhosted.org/pymilter/namespacemilter.html#a266a6e09897499d8b1ae0e20f0d2be73 @@ -79,10 +79,23 @@ services: # Expect authentication information from LDAP like allowedClientAddr, # allowedSaslUser or allowedx509CN. This is usefull if the milter handles # outbound email traffic, where senders must authenticate before submission. - # Default: False (inbound mode) - MILTER_EXPECT_AUTH: 'True' - # Blank or comma separated list of valid email recipients to whitelist, - MILTER_WHITELISTED_RCPTS: 'postmaster@example.com,hostmaster@example.org' + # Default: false (inbound mode) + #MILTER_EXPECT_AUTH: 'true' + # Blank or comma separated list of valid email recipients to whitelist (default: empty) + #MILTER_WHITELISTED_RCPTS: 'postmaster@example.com,hostmaster@example.org' + # Allow null-sender (<>) for bounces/DSNs (default: disabled) + #MILTER_ALLOW_NULL_SENDER: 'true' + # Enable recipient count limits (default: disabled) + #MILTER_MAX_RCPT_ENABLED: 'true' + #MILTER_MAX_RCPT: 1 + # Enable DKIM checks (default: disabled). + # This enables the milter to use the + # sender address placed in the 5322.from header + # within policy checks, unless the DKIM authentication results + # are invalid. + # Enabling this feature also requires a DKIM validating milter + # BEFORE the ldap-acl-milter! + #MILTER_DKIM_ENABLED: 'true' hostname: ldap-acl-milter volumes: - "lam_socket:/socket/:rw" diff --git a/app/lam.py b/app/lam.py index 5f2ce39..4d84890 100644 --- a/app/lam.py +++ b/app/lam.py @@ -65,26 +65,22 @@ class LdapAclMilter(Milter.Base): try: # this may fail, if no x509 client certificate was used. # postfix only passes this macro to milters if the TLS connection - # with the authenticating client was trusted in a x509 manner! + # with the authenticating client was trusted in a x509 manner (CA trust)! # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/ x509_subject = self.getsymval('{cert_subject}') if x509_subject != None: self.session.set_x509_subject(x509_subject) - log_debug( - "x509_subject={}".format(self.session.get_x509_subject()), - self.session - ) - else: - log_debug("No x509_subject registered", self.session) + log_debug( + "x509_subject={}".format(self.session.get_x509_subject()), + self.session + ) x509_issuer = self.getsymval('{cert_issuer}') if x509_issuer != None: self.session.set_x509_issuer(x509_issuer) - log_debug( - "x509_issuer={}".format(self.session.get_x509_issuer()), - self.session - ) - else: - log_debug("No x509_issuer registered", self.session) + log_debug( + "x509_issuer={}".format(self.session.get_x509_issuer()), + self.session + ) except: log_error( "x509 exception: {}".format(traceback.format_exc()), @@ -95,12 +91,10 @@ class LdapAclMilter(Milter.Base): sasl_user = self.getsymval('{auth_authen}') if sasl_user != None: self.session.set_sasl_user(sasl_user) - log_debug( - "sasl_user={}".format(self.session.get_sasl_user()), - self.session - ) - else: - log_debug("No sasl_user registered", self.session) + log_debug( + "sasl_user={}".format(self.session.get_sasl_user()), + self.session + ) except: log_error( "sasl_user exception: {}".format(traceback.format_exc()), @@ -253,7 +247,7 @@ class LdapAclMilter(Milter.Base): self.session ) except Exception as e: - log_info("AR-parse exception: {0}".format(str(e)), self.session) + log_warning("AR-parse exception: {0}".format(str(e)), self.session) return self.milter_action(action = 'continue') # Not registered/used callbacks @@ -289,7 +283,7 @@ class LdapAclMilter(Milter.Base): if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower(): self.session.set_dkim_aligned(True) log_info( - "Found aligned DKIM signature for SDID: {0}".format( + "Found aligned DKIM signature for SDID={0}".format( passed_dkim_sdid ), self.session diff --git a/app/lam_config_backend.py b/app/lam_config_backend.py index fde5685..f2f9b6c 100644 --- a/app/lam_config_backend.py +++ b/app/lam_config_backend.py @@ -112,7 +112,7 @@ class LamConfigBackend(): log_info("ENV[MILTER_EXPECT_AUTH]: {}".format(self.milter_expect_auth)) if 'MILTER_WHITELISTED_RCPTS' in os.environ: - # A blank separated list is expected + # A blank or comma separated list is expected whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS'] for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): if g_rex_email.match(whitelisted_rcpt) == None: diff --git a/app/lam_log_backend.py b/app/lam_log_backend.py index 615f20b..944b297 100644 --- a/app/lam_log_backend.py +++ b/app/lam_log_backend.py @@ -24,7 +24,7 @@ def init_log_backend(): logging.info("Logger initialized") def do_log(level: str, log_message: str, session: Optional[LamSession] = None): - log_line = '' + log_line = '-' if session is not None: if hasattr(session, 'mconn_id'): log_line = "{}".format(session.get_mconn_id()) diff --git a/app/lam_policy_backend.py b/app/lam_policy_backend.py index c6c89b2..df67a79 100644 --- a/app/lam_policy_backend.py +++ b/app/lam_policy_backend.py @@ -1,12 +1,13 @@ import re -from lam_rex import g_rex_domain from ldap3 import ( - Server, Connection, NONE, set_config_parameter + Server, Connection, NONE, set_config_parameter, + SAFE_RESTARTABLE ) from ldap3.core.exceptions import LDAPException from lam_exceptions import ( LamPolicyBackendException, LamHardException, LamSoftException ) +from lam_rex import g_rex_domain from lam_config_backend import LamConfigBackend from lam_session import LamSession from lam_log_backend import log_info, log_debug @@ -29,12 +30,12 @@ class LamPolicyBackend(): self.config.ldap_bindpw, auto_bind = True, raise_exceptions = True, - client_strategy = 'RESTARTABLE' + client_strategy = SAFE_RESTARTABLE ) - log_info("Connected to LDAP-server: {}".format(self.config.ldap_server)) + log_info("policy: connected to LDAP-server: {}".format(self.config.ldap_server)) except LDAPException as e: raise LamPolicyBackendException( - "Connection to LDAP-server failed: {}".format(str(e)) + "policy: Connection to LDAP-server failed: {}".format(str(e)) ) from e def check_policy(self, session: LamSession, **kwargs): @@ -44,19 +45,19 @@ class LamPolicyBackend(): m = g_rex_domain.match(from_addr) if m == None: raise LamHardException( - "Could not determine domain of from={}".format(from_addr) + "policy: Could not determine domain of from={}".format(from_addr) ) from_domain = m.group(1) - log_debug("from_domain={}".format(from_domain), session) + log_debug("policy: from_domain={}".format(from_domain), session) m = g_rex_domain.match(rcpt_addr) if m == None: raise LamHardException( - "Could not determine domain of rcpt={}".format( + "policy: Could not determine domain of rcpt={}".format( rcpt_addr ) ) rcpt_domain = m.group(1) - log_debug("rcpt_domain={}".format(rcpt_domain), session) + log_debug("policy: rcpt_domain={}".format(rcpt_domain), session) try: if self.config.milter_schema == True: # LDAP-ACL-Milter schema enabled @@ -82,7 +83,7 @@ class LamPolicyBackend(): ) else: auth_method = auth_method.replace('%X509_AUTH%','') - log_debug("auth_method: {}".format(auth_method), session) + log_debug("policy: auth_method: {}".format(auth_method), session) if self.config.milter_schema_wildcard_domain == True: # The asterisk (*) character is in term of local part # RFC5322 compliant and expected as a wildcard literal in this code. @@ -92,15 +93,15 @@ class LamPolicyBackend(): # In this case *@ cannot be a real address! if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): raise LamHardException( - "Literal wildcard sender (*@) is not " + + "policy: Literal wildcard sender (*@) is not " + "allowed in wildcard mode!" ) if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): raise LamHardException( - "Literal wildcard recipient (*@) is not " + + "policy: Literal wildcard recipient (*@) is not " + "allowed in wildcard mode!" ) - self.ldap_conn.search(self.config.ldap_base, + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, "(&" + auth_method + "(|" + @@ -131,7 +132,7 @@ class LamPolicyBackend(): # Asterisk (*) must be ASCII-HEX encoded for LDAP queries query_from = from_addr.replace("*","\\2a") query_to = rcpt_addr.replace("*","\\2a") - self.ldap_conn.search(self.config.ldap_base, + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, "(&" + auth_method + "(allowedSenders=" + query_from + ")" + @@ -141,33 +142,33 @@ class LamPolicyBackend(): ")", attributes=['policyID'] ) - if len(self.ldap_conn.entries) == 0: + if len(ldap_response) == 0: # Policy not found in LDAP raise LamHardException( - "mismatch: from_src={0} from={1} rcpt={2}".format( + "policy: mismatch: from_src={0} from={1} rcpt={2}".format( from_source, from_addr, rcpt_addr ) ) - elif len(self.ldap_conn.entries) == 1: + elif len(ldap_response) == 1: if from_source == 'from-header': log_info( - "5322.from_domain={} authorized by DKIM signature".format( + "policy: 5322.from_domain={} authorized by DKIM signature".format( from_domain ), session ) # Policy found in LDAP, but which one? - entry = self.ldap_conn.entries[0] + entry = ldap_response[0]['attributes'] log_info( - "match='{0}' from_src={1}".format( - entry.policyID.value, from_source + "policy: match='{0}' from_src={1}".format( + entry['PolicyID'][0], from_source ), session ) - elif len(self.ldap_conn.entries) > 1: + elif len(ldap_response) > 1: # Something went wrong!? There shouldn´t be more than one entries! raise LamHardException( - "More than one policies found! from={0} rcpt={1} auth_method={2}".format( + "policy: More than one policies found! from={0} rcpt={1} auth_method={2}".format( from_addr, rcpt_addr, auth_method ) ) @@ -182,14 +183,14 @@ class LamPolicyBackend(): query = query.replace("%sasl_user%", session.get_sasl_user()) query = query.replace("%from_domain%", from_domain) query = query.replace("%rcpt_domain%", rcpt_domain) - log_debug("LDAP query: {}".format(query), session) - self.ldap_conn.search(self.config.ldap_base, query) - if len(self.ldap_conn.entries) == 0: + log_debug("policy: LDAP query: {}".format(query), session) + _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, query) + if len(ldap_response) == 0: raise LamHardException( - "mismatch from_src={0} from={1} rcpt={2}".format( + "policy: mismatch from_src={0} from={1} rcpt={2}".format( from_source, from_addr, rcpt_addr ) ) - log_info("match from_src={}".format(from_source), session) + log_info("policy: match from_src={}".format(from_source), session) except LDAPException as e: - raise LamSoftException("LDAP exception: " + str(e)) from e + raise LamSoftException("policy: LDAP exception: " + str(e)) from e diff --git a/tests/sasl-fail.lua b/tests/sasl-fail.lua new file mode 100644 index 0000000..6a4ed42 --- /dev/null +++ b/tests/sasl-fail.lua @@ -0,0 +1,39 @@ +-- https://mopano.github.io/sendmail-filter-api/constant-values.html#com.sendmail.milter.MilterConstants +-- http://www.opendkim.org/miltertest.8.html + +-- socket must be defined as miltertest global variable (-D) +conn = mt.connect(socket) +if conn == nil then + error "mt.connect() failed" +end +if mt.conninfo(conn, "localhost", "::1") ~= nil then + error "mt.conninfo() failed" +end + +mt.set_timeout(60) + +-- 5321.FROM+MACROS +mt.macro(conn, SMFIC_MAIL, "{auth_authen}", "blubb-user1") +if mt.mailfrom(conn, "tester-fail@test.blah") ~= nil then + error "mt.mailfrom() failed" +end + +-- 5321.RCPT+MACROS +mt.macro(conn, SMFIC_RCPT, "i", "4CgSNs5Q9sz7SllQ") +if mt.rcptto(conn, "") ~= nil then + error "mt.rcptto() failed" +end + +-- EOM +if mt.eom(conn) ~= nil then + error "mt.eom() failed" +end +mt.echo("EOM: " .. mt.getreply(conn)) +if mt.getreply(conn) == SMFIR_CONTINUE then + mt.echo("EOM-continue") +elseif mt.getreply(conn) == SMFIR_REPLYCODE then + mt.echo("EOM-reject") +end + +-- DISCONNECT +mt.disconnect(conn) \ No newline at end of file diff --git a/tests/sasl-wildcard.lua b/tests/sasl-wildcard.lua index b93d207..5059075 100644 --- a/tests/sasl-wildcard.lua +++ b/tests/sasl-wildcard.lua @@ -28,9 +28,6 @@ end if mt.header(conn, "fRoM", '"Blah Blubb" ') ~= nil then error "mt.header(From) failed" end -if mt.header(conn, "Authentication-RESULTS", "my-auth-serv-id;\n dkim=pass header.d=test.blah header.s=selector1-test-blah header.b=mumble") ~= nil then - error "mt.header(Authentication-Results) failed" -end -- EOM if mt.eom(conn) ~= nil then From f6e6408c8e8376c28351fd0967936edc741ed489 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 7 Jun 2023 18:02:14 +0200 Subject: [PATCH 15/15] DATA stage - unnecessary if-statement --- app/lam.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/lam.py b/app/lam.py index 4d84890..55ac681 100644 --- a/app/lam.py +++ b/app/lam.py @@ -185,8 +185,6 @@ class LdapAclMilter(Milter.Base): def data(self): self.session.set_proto_stage('DATA') - if g_config_backend.milter_allow_null_sender and self.session.is_null_sender(): - return self.milter_action(action = 'continue') self.session.set_queue_id(self.getsymval('i')) log_debug( "Queue-id: {}".format(self.session.get_queue_id()),