From 0966cf327b14dd0b6937d27a63a1b09650079b2b Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sat, 27 Apr 2019 23:39:54 +0200 Subject: [PATCH] wildcard domain support --- README.md | 12 +-- app/ldap-acl-milter.py | 168 ++++++++++++++++++++++++++++------------- 2 files changed, 123 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index ff89508..77333ae 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # ldap-acl-milter -A lightweight, fast and thread-safe python3 [milter](http://www.postfix.org/MILTER_README.html) on top of [sdgathman/pymilter](https://github.com/sdgathman/pymilter) for basic Access Control (ACL) scenarios. The milter consumes policies from LDAP based on custom queries with trivial templating support: +A lightweight, fast and thread-safe python3 [milter](http://www.postfix.org/MILTER_README.html) on top of [sdgathman/pymilter](https://github.com/sdgathman/pymilter) for basic Access Control (ACL) scenarios. The milter consumes policies from LDAP based on a specialized [schema](https://github.com/chillout2k/ldap-acl-milter/LDAP/ldap-acl-milter.schema). Alternatively an already present schema can be uses as well. In this case custom queries with trivial templating support must be used: * %client_addr% = IPv(4|6) address of SMTP client * %sasl_user% = user name of SASL authenticated user @@ -8,7 +8,7 @@ A lightweight, fast and thread-safe python3 [milter](http://www.postfix.org/MILT * %rcpt% = RFC5321.rcpt * %rcpt_domain% RFC5321.rcpt_domain -In the case, one already has a LDAP server running with the [amavis schema](https://www.ijs.si/software/amavisd/LDAP.schema.txt), the 'amavisWhitelistSender' attribute could be reused. The filtering direction (inbound or outbound) can be simply controlled by swapping the %from% and %rcpt% placeholders within the LDAP query template. Please have a look at the docker-compose.yml example below. +In the case, one already has a LDAP server running with the [amavis schema](https://www.ijs.si/software/amavisd/LDAP.schema.txt), for e.g. the 'amavisWhitelistSender' attribute could be reused. The filtering direction (inbound or outbound) can be simply controlled by swapping the %from% and %rcpt% placeholders within the LDAP query template. Please have a look at the docker-compose.yml example below. The connection to the LDAP server is always persistent: one LDAP-bind is shared among all milter-threads, which makes it more efficient due to less communication overhead (which also implies transport encryption with TLS). Thus, LDAP interactions with 2 msec. and less are realistic, depending on your environment like network round-trip-times or the load of your LDAP server. A very swag LDAP setup is to use a local read-only LDAP replica, which syncs over network with a couple of LDAP masters: [OpenLDAP does it for free!](https://www.openldap.org/doc/admin24/replication.html). On the one hand, this aproach eliminates network round trip times while reading from a UNIX-socket, and on the other, performance bottlenecks on a shared, centralized and (heavy) utilized LDAP server. @@ -64,10 +64,10 @@ services: # 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_WILDCARDS is set to True, the milter gets all valid senders - # per recipient and checks binary as well as per wildcard (*) if the senders - # matches. This only works if MILTER_SCHEMA is enabled! - MILTER_SCHEMA_WILDCARDS: 'False' + # 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_NAME: some-another-milter-name diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index 32dc315..ddcddd4 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -3,6 +3,7 @@ from ldap3 import ( Server,ServerPool,Connection,NONE,LDAPOperationResult ) import sys +import traceback import os import logging import string @@ -22,11 +23,12 @@ 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+)$') +g_re_domain = re.compile(r'^\S*@(\S+)$') g_loglevel = logging.INFO g_milter_mode = 'test' +g_milter_default_policy = 'reject' g_milter_schema = False -g_milter_schema_wildcards = False # works only if g_milter_schema == True +g_milter_schema_wildcard_domain = False # works only if g_milter_schema == True class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread @@ -36,8 +38,8 @@ class LdapAclMilter(Milter.Base): self.client_addr = None self.env_from = None self.env_from_domain = None - self.sasl_user = 'not_authenticated' - self.x509_cn = 'not_authenticated' + self.sasl_user = None + self.x509_cn = None # recipients list self.env_rcpts = [] # https://stackoverflow.com/a/2257449 @@ -67,27 +69,35 @@ class LdapAclMilter(Milter.Base): return Milter.CONTINUE def envfrom(self, mailfrom, *str): + try: + # this may fail, if no x509 client certificate was used + x509cn = self.getsymval('{cert_subject}') + if x509cn != None: + self.x509_cn = x509cn + logging.info(self.mconn_id + "/FROM x509_cn=" + self.x509_cn) + except: + logging.error(self.mconn_id + "/FROM " + 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) + logging.info(self.mconn_id + "/FROM sasl_user=" + self.sasl_user) except: - logging.error(self.mconn_id + "/FROM " + str(sys.exc_info())) + logging.error(self.mconn_id + "/FROM " + traceback.format_exc()) mailfrom = mailfrom.replace("<","") mailfrom = mailfrom.replace(">","") self.env_from = mailfrom m = g_re_domain.match(self.env_from) if m == None: logging.error(self.mconn_id + "/FROM " + - "Could not determine domain of 5321.from: " + self.env_from + "Could not determine domain of 5321.from=" + self.env_from ) self.setreply('450','4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL self.env_from_domain = m.group(1) logging.debug(self.mconn_id + - "/FROM env_from_domain: " + self.env_from_domain + "/FROM env_from_domain=" + self.env_from_domain ) return Milter.CONTINUE @@ -104,46 +114,92 @@ class LdapAclMilter(Milter.Base): return Milter.TEMPFAIL rcpt_domain = m.group(1) logging.debug(self.mconn_id + - "/RCPT rcpt_domain: " + rcpt_domain + "/RCPT rcpt_domain=" + rcpt_domain ) time_end = None try: if g_milter_schema == True: - # LDAP-ACL-Milter own schema - if g_milter_schema_wildcards == True: - # TODO! - pass - else: - # Authentication hierarchy! - # 1. x509 client certificate - # 2. SASL authenticated - # if auth_type sasl_user, check if authenticated user matches sasl_user and - # check if sender/recipient pair match - # 3. Client-IP authenticated - # if auth_type client_addr, check if client-ip matches client_addr - # check if sender/recipient pair match - # 4. not authenticated - # ldap-search with excluded sasl_user and client_addr attributes! + # LDAP-ACL-Milter schema + auth_method = '' + # Authentication order! + # 1. x509 client certificate + # 2. SASL authenticated + # if authType sasl_user, check if authenticated user matches sasl_user and + # check if sender/recipient pair match + # 3. Client-IP authenticated + # if authType client_addr, check if client-ip matches client_addr + # check if sender/recipient pair match + # 4. not authenticated + # ldap-search with excluded sasl_user and client_addr attributes! + 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'^\*@.+$', self.env_from, re.IGNORECASE): + logging.info(self.mconn_id + "/RCPT REJECT " + + "Wildcard sender (*@) is not allowed in wildcard mode!" + ) + self.setreply('550','5.7.1', + g_milter_reject_message + ' (' + self.mconn_id + ')' + ) + return Milter.REJECT + if re.match(r'^\*@.+$', to, re.IGNORECASE): + logging.info(self.mconn_id + "/RCPT REJECT " + + "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, - "(&(allowedRcpts="+to+")(allowedSenders="+self.env_from+"))" + "(&" + + auth_method + + "(|"+ + "(allowedRcpts="+to+")"+ + "(allowedRcpts=\\2a@"+rcpt_domain+")"+ + ")"+ + "(|"+ + "(allowedSenders="+self.env_from+")"+ + "(allowedSenders=\\2a@"+self.env_from_domain+")"+ + ")"+ + ")" ) - time_end = timer() - if len(self.ldap_conn.entries) == 0: - self.env_rcpts.append({ - "rcpt": to, "reason": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) - if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT " + g_milter_reject_message) - 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 " + - g_milter_reject_message - ) - return Milter.CONTINUE + else: + # 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, + "(&" + + auth_method + + "(allowedRcpts="+query_to+")" + + "(allowedSenders="+query_from+")" + + ")" + ) + time_end = timer() + if len(self.ldap_conn.entries) == 0: + self.env_rcpts.append({ + "rcpt": to, "reason": 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 + ", 5321.rcpt=" + to + ) + 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 Milter.REJECT + else: + logging.info(self.mconn_id + "/RCPT TEST_MODE " + + g_milter_reject_message + ) + return Milter.CONTINUE else: # Custom LDAP schema # 'build' a LDAP query per recipient @@ -162,8 +218,11 @@ class LdapAclMilter(Milter.Base): "rcpt": to, "reason": 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 + ) if g_milter_mode == 'reject': - logging.info(self.mconn_id + "/RCPT " + g_milter_reject_message) + logging.info(self.mconn_id + "/RCPT REJECT " + g_milter_reject_message) self.setreply('550','5.7.1', g_milter_reject_message + ' (' + self.mconn_id + ')' ) @@ -190,13 +249,13 @@ class LdapAclMilter(Milter.Base): 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'] + "> reason: " + rcpt['reason'] + - " duration: " + str(duration) + " sec." + ": 5321.from=" + self.env_from + " 5321.rcpt=" + + rcpt['rcpt'] + " reason=" + rcpt['reason'] + + " duration=" + str(duration) + "sec." ) except: logging.warn(self.mconn_id + "/DATA " + self.queue_id + - ": " + str(sys.exc_info())) + ": " + traceback.format_exc()) self.setreply('451', '4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL return Milter.CONTINUE @@ -238,14 +297,21 @@ 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'] + 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.warn("MILTER_DEFAULT_POLICY invalid value: " + + os.environ['MILTER_DEFAULT_POLICY'] + ) 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_WILDCARDS' in os.environ: - if re.match(r'^true$', os.environ['MILTER_SCHEMA_WILDCARDS'], re.IGNORECASE): - g_milter_schema_wildcards = 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. " + g_ldap_server) sys.exit(1) @@ -296,4 +362,4 @@ if __name__ == "__main__": Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) logging.info("Shutdown " + g_milter_name) except: - logging.error("MAIN-EXCEPTION: " + str(sys.exc_info())) + logging.error("MAIN-EXCEPTION: " + traceback.format_exc())