From 5de8c5c8d95fb388a7f23554bf23c9d38b2ce55d Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 27 Mar 2019 20:12:34 +0100 Subject: [PATCH 1/6] ldap-acl-milter LDAP-schema --- LDAP/ldap-acl-milter.schema | 89 ++++++++++++++ README.md | 36 +++++- app/ldap-acl-milter.py | 228 +++++++++++++++++++++++++++--------- docker-build.sh | 4 +- 4 files changed, 293 insertions(+), 64 deletions(-) create mode 100644 LDAP/ldap-acl-milter.schema diff --git a/LDAP/ldap-acl-milter.schema b/LDAP/ldap-acl-milter.schema new file mode 100644 index 0000000..1c289d4 --- /dev/null +++ b/LDAP/ldap-acl-milter.schema @@ -0,0 +1,89 @@ +# ldap-acl-milter https://github.com/chillout2k/ldap-acl-milter +# +# https://www.iana.org/assignments/enterprise-numbers/enterprise-numbers +# DC IT-Consulting +# Dominik Chilla +# +# OID prefix: 1.3.6.1.4.1.53501 +# +# Attributes: 1.3.6.1.4.1.53501.1.1 + +attributetype ( 1.3.6.1.4.1.53501.1.1.1 + NAME 'policyID' + DESC 'Policy ID' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} + SINGLE-VALUE) + +attributetype ( 1.3.6.1.4.1.53501.1.1.2 + NAME 'authType' + DESC 'Authentication type: sasl_user, client_addr, none' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} + SINGLE-VALUE) + +attributetype ( 1.3.6.1.4.1.53501.1.1.3 + NAME 'allowedSenders' + DESC 'Allowed RFC5321.from' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{254}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.4 + NAME 'allowedRcpts' + DESC 'Denied RFC5321.to' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{254}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.5 + NAME 'deniedSenders' + DESC 'Allowed RFC5321.from' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{254}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.6 + NAME 'deniedRcpts' + DESC 'Denied RFC5321.to' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{254}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.7 + NAME 'allowedClientAddr' + DESC 'Allowed client IPv4/IPv6 address' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{64}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.8 + NAME 'deniedClientAddr' + DESC 'Denied client IPv4/IPv6 address' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{64}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.9 + NAME 'allowedSaslUser' + DESC 'Allowed SASL authentication user' + EQUALITY caseExactIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{64}) + +attributetype ( 1.3.6.1.4.1.53501.1.1.10 + NAME 'extBLOB' + DESC 'placeholder for binary extensions' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{4096}) + +# +# Objects: 1.3.6.1.4.1.53501.1.2 +# +objectclass ( 1.3.6.1.4.1.53501.1.2.1 + NAME 'lamPolicy' + DESC 'ldap-acl-milter policy' + SUP top + STRUCTURAL + MUST ( policyID $ allowedRcpts $ allowedSenders ) + MAY ( authType $ deniedSenders $ deniedRcpts $ allowedClientAddr $ deniedClientAddr $ allowedSaslUser $ extBLOB )) diff --git a/README.md b/README.md index c04c499..ff89508 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,23 @@ # 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 (%from% = RFC5321.from; %rcpt% = RFC5321.rcpt). +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: -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. +* %client_addr% = IPv(4|6) address of SMTP client +* %sasl_user% = user name of SASL authenticated user +* %from% = RFC5321.from +* %from_domain% = RFC5321.from_domain +* %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. 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. +### ldap-acl-milter´s own LDAP-schema +Yes, there´s one! Check out the LDAP-directory ;-) + +### Uniqueness of LDAP attributes +In case of OpenLDAP, just use the [unique overlay](https://www.openldap.org/doc/admin24/overlays.html). Another aproach to ensure attribute uniqueness is to do it in some programmatic way which is more complex but more flexible. + ### Deployment paradigm The intention of this project is to deploy the milter ALWAYS AND ONLY as an [OCI compliant](https://www.opencontainers.org) container. In this case it´s [docker](https://www.docker.com). The main reason is that I´m not interested (and familiar with) in building distribution packages like .rpm, .deb, etc.. Furthermore I´m not really a fan of 'wild and uncontrollable' software deployments like: get the code, compile it and finaly install the results 'somewhere' in the filesystem. In terms of software deployment docker provides wonderful possibilities, which I don´t want to miss anymore... No matter if in development, QA or production stage. @@ -35,6 +48,8 @@ services: image: "ldap-acl-milter/debian:19.02_master" restart: unless-stopped environment: + # default: info. Possible: info, warning, error and debug + LOG_LEVEL: debug #LDAP_SERVER: ldap://ldap-slave.example.local:389 LDAP_SERVER: ldapi:///socket//slapd//slapd LDAP_BINDDN: uid=lam,ou=applications,dc=example,dc=org @@ -43,11 +58,24 @@ services: # This example LDAP query is for inbound filtering # where the 'mail' attribute equals to the recipient # and the 'amavisWhitelistSender' attribute the eligible sender - LDAP_QUERY: (&(mail=%rcpt%)(amavisWhitelistSender=%from%)) + #LDAP_QUERY: (&(mail=%rcpt%)(amavisWhitelistSender=%from%)) + #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_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' + # default: test. Possible: test, 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 #MILTER_SOCKET: inet6:8020 - MILTER_REJECT_MESSAGE: Message rejected due to security policy + #MILTER_REJECT_MESSAGE: Message rejected due to security policy + #MILTER_TMPFAIL_MESSAGE: Message temporary rejected. Please try again later ;) hostname: ldap-acl-milter volumes: - "lam_socket:/socket/:rw" diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index b44afd3..af1712f 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -7,90 +7,175 @@ import os import logging import string import random +import re from timeit import default_timer as timer +# Globals... g_milter_name = 'ldap-acl-milter' g_milter_socket = '/socket/' + g_milter_name -g_milter_reject_message = 'Absender/Empfaenger passen nicht!' +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;-)' g_ldap_base = 'ou=users,dc=example,dc=org' g_ldap_query = '(&(mail=%rcpt%)(allowedEnvelopeSender=%from%))' -logging.basicConfig( - filename=None, # log to stdout - format='%(asctime)s: %(levelname)s %(message)s', - level=logging.INFO -) +g_re_domain = re.compile(r'^\S+@(\S+)$') +g_loglevel = logging.INFO +g_milter_mode = 'test' +g_milter_schema = False +g_milter_schema_wildcards = False # works only if g_milter_schema == True class LdapAclMilter(Milter.Base): # Each new connection is handled in an own thread def __init__(self): self.time_start = timer() - self.id = Milter.uniqueID() self.ldap_conn = g_ldap_conn - self.R = [] + self.client_addr = None + self.env_from = None + self.env_from_domain = None + self.sasl_user = 'not_authenticated' + # recipients list + self.env_rcpts = [] # https://stackoverflow.com/a/2257449 - self.mconn_id = ''.join( + 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 connect(self, IPname, family, hostaddr): - return self.CONTINUE - @Milter.nocallback def hello(self, heloname): - return self.CONTINUE + return Milter.CONTINUE @Milter.nocallback def header(self, name, hval): - return self.CONTINUE + return Milter.CONTINUE @Milter.nocallback def eoh(self): - return self.CONTINUE + return Milter.CONTINUE @Milter.nocallback def body(self, chunk): - return self.CONTINUE + return Milter.CONTINUE + + def connect(self, IPname, family, hostaddr): + self.client_addr = hostaddr[0] + logging.debug(self.mconn_id + + "/CONNECT client_addr=[" + self.client_addr + "]:" + str(hostaddr[1]) + ) + return Milter.CONTINUE def envfrom(self, mailfrom, *str): - if mailfrom == '<>': - ex = str(self.mconn_id + '/FROM Envelope null-sender not allowed!') - logging.error(ex) - self.setreply('550','5.7.1',ex) - Milter.REJECT + 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) + except: + logging.error(self.mconn_id + "/FROM " + str(sys.exc_info())) mailfrom = mailfrom.replace("<","") mailfrom = mailfrom.replace(">","") - self.F = mailfrom + 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 + ) + 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 + ) return Milter.CONTINUE def envrcpt(self, to, *str): time_start = timer() to = to.replace("<","") to = to.replace(">","") + m = g_re_domain.match(to) + if m == None: + logging.error(self.mconn_id + "/RCPT " + + "Could not determine domain of 5321.to: " + to + ) + 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 + ) time_end = None try: - query = g_ldap_query.replace("%rcpt%",to) - query = query.replace("%from%", self.F) - self.ldap_conn.search(g_ldap_base, query) - time_end = timer() - if len(self.ldap_conn.entries) == 0: - self.R.append({ - "rcpt": to, "reason": g_milter_reject_message, - "time_start":time_start, "time_end":time_end - }) - self.setreply('550','5.7.1', - 'Sender does not comply with recipients policy!' - ) - logging.info(self.mconn_id + "/RCPT " + g_milter_reject_message) - return Milter.REJECT + if g_milter_schema == True: + # LDAP-ACL-Milter own schema + if g_milter_schema_wildcards == True: + # TODO! + pass + else: + # Authentication hierarchy! + # 1. SASL authenticated + # TODO: if auth_type sasl_user, check if authenticated user matches sasl_user and + # TODO: check if sender/recipient pair match + # 2. Client-IP authenticated + # TODO: if auth_type client_addr, check if client-ip matches client_addr + # TODO: check if sender/recipient pair match + # 3. not authenticated + # TODO: ldap-search with excluded sasl_user and client_addr attributes! + self.ldap_conn.search(g_ldap_base, + "(&(allowedRcpts="+to+")(allowedSenders="+self.env_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 + }) + 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: + # 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 = 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("%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, "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 except LDAPOperationResult as e: - logging.warn(self.mconn_id + "/RCPT LDAP Exception (envrcpt): " + str(e)) - self.setreply('451','4.7.1', - 'Service temporarily not available! Please try again later.' - ) + logging.warn(self.mconn_id + "/RCPT LDAP: " + str(e)) + self.setreply('451', '4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL - self.R.append({ + self.env_rcpts.append({ "rcpt": to, "reason":'pass',"time_start":time_start,"time_end":time_end }) return Milter.CONTINUE @@ -100,19 +185,17 @@ class LdapAclMilter(Milter.Base): # and therefore not available until DATA command self.queue_id = self.getsymval('i') try: - for rcpt in self.R: + 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.F + "> 5321.rcpt=<" + + ": 5321.from=<" + self.env_from + "> 5321.rcpt=<" + rcpt['rcpt'] + "> reason: " + rcpt['reason'] + " duration: " + str(duration) + " sec." ) except: - ex = str(self.mconn_id + "/DATA " + self.queue_id + - ": Exception (data): " + sys.exc_info() - ) - logging.warn(ex) - self.setreply('451','4.7.1', ex) + logging.warn(self.mconn_id + "/DATA " + self.queue_id + + ": " + str(sys.exc_info())) + self.setreply('451', '4.7.1', g_milter_tmpfail_message) return Milter.TEMPFAIL return Milter.CONTINUE @@ -121,7 +204,7 @@ class LdapAclMilter(Milter.Base): time_end = timer() duration = time_end - self.time_start logging.info(self.mconn_id + "/EOM " + self.queue_id + - " processing: " + str(duration) + " sec." + " processed in " + str(duration) + " sec." ) return Milter.CONTINUE @@ -136,6 +219,31 @@ class LdapAclMilter(Milter.Base): if __name__ == "__main__": try: + if 'LOG_LEVEL' in os.environ: + if re.match(r'^info$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.INFO + elif re.match(r'^warn|warning$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.WARN + elif re.match(r'^error$', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.ERROR + elif re.match(r'debug', os.environ['LOG_LEVEL'], re.IGNORECASE): + g_loglevel = logging.DEBUG + logging.basicConfig( + filename=None, # log to stdout + format='%(asctime)s: %(levelname)s %(message)s', + level=g_loglevel + ) + 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_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 'LDAP_SERVER' not in os.environ: logging.error("Missing ENV[LDAP_SERVER], e.g. " + g_ldap_server) sys.exit(1) @@ -149,19 +257,20 @@ if __name__ == "__main__": sys.exit(1) g_ldap_base = os.environ['LDAP_BASE'] if 'LDAP_QUERY' not in os.environ: - logging.error("Missing ENV[LDAP_QUERY], e.g. " + g_ldap_query) - sys.exit(1) - g_ldap_query = os.environ['LDAP_QUERY'] + 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'] - #server_pool = ServerPool(None, pool_strategy='ROUND_ROBIN', active=False, exhaust=False) + if 'MILTER_TMPFAIL_MESSAGE' in os.environ: + g_milter_tmpfail_message = os.environ['MILTER_TMPFAIL_MESSAGE'] server = Server(g_ldap_server, get_info=NONE) - #server_pool.add(server) - #server2 = Server('ldap://ldap-master-zdf.zwackl.local:389', get_info=NONE) - #server_pool.add(server2) - #g_ldap_conn = Connection(server_pool, g_ldap_conn = Connection(server, g_ldap_binddn, g_ldap_bindpw, auto_bind=True, raise_exceptions=True, @@ -178,7 +287,10 @@ if __name__ == "__main__": # Tell the MTA which features we use flags = Milter.ADDHDRS Milter.set_flags(flags) - logging.info("Startup " + g_milter_name + "@socket: " + g_milter_socket) + logging.info("Startup " + g_milter_name + + "@socket: " + g_milter_socket + + " in mode: " + g_milter_mode + ) Milter.runmilter(g_milter_name,g_milter_socket,timeout,True) logging.info("Shutdown " + g_milter_name) except: diff --git a/docker-build.sh b/docker-build.sh index 9caa72b..302a080 100755 --- a/docker-build.sh +++ b/docker-build.sh @@ -22,9 +22,9 @@ fi IMAGES="ldap-acl-milter" for IMAGE in ${IMAGES}; do +# --build-arg http_proxy=http://wprx-zdf.zwackl.local:3128 \ +# --build-arg https_proxy=http://wprx-zdf.zwackl.local:3128 \ /usr/bin/docker build \ - --build-arg http_proxy=http://wprx-zdf.zwackl.local:3128 \ - --build-arg https_proxy=http://wprx-zdf.zwackl.local:3128 \ -t "${IMAGE}/${BASEOS}:${VERSION}_${BRANCH}" \ -f "docker/${BASEOS}/Dockerfile" . # /usr/bin/docker tag "${IMAGE}/${BASEOS}:${VERSION}_${BRANCH}" "${REGISTRY}/${IMAGE}/${BASEOS}:${VERSION}_${BRANCH}" From 4fb6c425a9fbcbbfb69f646f7c13a14db8b81e83 Mon Sep 17 00:00:00 2001 From: Dominik Chilla <43314918+chillout2k@users.noreply.github.com> Date: Wed, 27 Mar 2019 21:44:27 +0100 Subject: [PATCH 2/6] Create README.md --- LDAP/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 LDAP/README.md diff --git a/LDAP/README.md b/LDAP/README.md new file mode 100644 index 0000000..a805247 --- /dev/null +++ b/LDAP/README.md @@ -0,0 +1 @@ +# ldap-acl-milter LDAP-schema From 275e584bed9af8f5ed68b282b1fb0e056ee09623 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 17 Apr 2019 23:19:08 +0200 Subject: [PATCH 3/6] not worth a push --- app/ldap-acl-milter.py | 17 +++++++++-------- docker/debian/Dockerfile | 1 + 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index af1712f..eeae860 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -114,14 +114,15 @@ class LdapAclMilter(Milter.Base): pass else: # Authentication hierarchy! - # 1. SASL authenticated - # TODO: if auth_type sasl_user, check if authenticated user matches sasl_user and - # TODO: check if sender/recipient pair match - # 2. Client-IP authenticated - # TODO: if auth_type client_addr, check if client-ip matches client_addr - # TODO: check if sender/recipient pair match - # 3. not authenticated - # TODO: ldap-search with excluded sasl_user and client_addr attributes! + # 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! self.ldap_conn.search(g_ldap_base, "(&(allowedRcpts="+to+")(allowedSenders="+self.env_from+"))" ) diff --git a/docker/debian/Dockerfile b/docker/debian/Dockerfile index f1032b9..b077732 100644 --- a/docker/debian/Dockerfile +++ b/docker/debian/Dockerfile @@ -2,6 +2,7 @@ ARG http_proxy ARG https_proxy FROM debian LABEL maintainer="Dominik Chilla " +LABEL git_repo="https://github.com/chillout2k/ldap-acl-milter/tree/devel" ENV DEBIAN_FRONTEND=noninteractive \ TZ=Europe/Berlin From 5c999c62df8191f45b650cc21410e8e0dd8cad45 Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Wed, 17 Apr 2019 23:27:26 +0200 Subject: [PATCH 4/6] not worth a push --- app/ldap-acl-milter.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/ldap-acl-milter.py b/app/ldap-acl-milter.py index eeae860..32dc315 100644 --- a/app/ldap-acl-milter.py +++ b/app/ldap-acl-milter.py @@ -37,6 +37,7 @@ class LdapAclMilter(Milter.Base): self.env_from = None self.env_from_domain = None self.sasl_user = 'not_authenticated' + self.x509_cn = 'not_authenticated' # recipients list self.env_rcpts = [] # https://stackoverflow.com/a/2257449 From 0966cf327b14dd0b6937d27a63a1b09650079b2b Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sat, 27 Apr 2019 23:39:54 +0200 Subject: [PATCH 5/6] 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()) From 7b5916fd0005da0d0832cd2144436d49af2943bd Mon Sep 17 00:00:00 2001 From: Dominik Chilla Date: Sat, 27 Apr 2019 23:40:31 +0200 Subject: [PATCH 6/6] Release 19.04 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 55b939b..d18f0e6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -19.02 +19.04