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