ldap3 thread-safe + refactoring

This commit is contained in:
Dominik Chilla 2023-06-05 15:57:16 +02:00
parent 672d5d6355
commit 08eaee66b5
7 changed files with 107 additions and 63 deletions

View File

@ -62,14 +62,14 @@ services:
#LDAP_QUERY: (&(mail=%rcpt%)(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%))) #LDAP_QUERY: (&(mail=%rcpt%)(|(amavisWhitelistSender=*@%from_domain%)(amavisWhitelistSender=%from%)))
# LDAP_QUERY: (&(|(mail=%rcpt%)(mail=*@%rcpt_domain%))(|(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 # This enables the use of own ldap-acl-milter LDAP schema. Default: False
# Setting MILTER_SCHEMA: True disables the LDAP_QUERY parameter! # Setting MILTER_SCHEMA: true disables the LDAP_QUERY parameter!
MILTER_SCHEMA: 'True' #MILTER_SCHEMA: 'true'
# If MILTER_SCHEMA_WILDCARD_DOMAIN is set to True, the milter allows *@domain # If MILTER_SCHEMA_WILDCARD_DOMAIN is set to true, the milter allows *@domain
# as valid sender/recipient addresses in LDAP. # as valid sender/recipient addresses in LDAP.
# This only works if MILTER_SCHEMA is enabled! # This only works if MILTER_SCHEMA is enabled!
MILTER_SCHEMA_WILDCARD_DOMAIN: 'False' MILTER_SCHEMA_WILDCARD_DOMAIN: 'False'
# default: test. Possible: test, reject # default: test. Possible: test, reject
MILTER_MODE: 'reject' #MILTER_MODE: 'reject'
MILTER_NAME: some-another-milter-name MILTER_NAME: some-another-milter-name
# Default: UNIX-socket located under /socket/ldap-acl-milter # Default: UNIX-socket located under /socket/ldap-acl-milter
# https://pythonhosted.org/pymilter/namespacemilter.html#a266a6e09897499d8b1ae0e20f0d2be73 # https://pythonhosted.org/pymilter/namespacemilter.html#a266a6e09897499d8b1ae0e20f0d2be73
@ -79,10 +79,23 @@ services:
# Expect authentication information from LDAP like allowedClientAddr, # Expect authentication information from LDAP like allowedClientAddr,
# allowedSaslUser or allowedx509CN. This is usefull if the milter handles # allowedSaslUser or allowedx509CN. This is usefull if the milter handles
# outbound email traffic, where senders must authenticate before submission. # outbound email traffic, where senders must authenticate before submission.
# Default: False (inbound mode) # Default: false (inbound mode)
MILTER_EXPECT_AUTH: 'True' #MILTER_EXPECT_AUTH: 'true'
# Blank or comma separated list of valid email recipients to whitelist, # Blank or comma separated list of valid email recipients to whitelist (default: empty)
MILTER_WHITELISTED_RCPTS: 'postmaster@example.com,hostmaster@example.org' #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 hostname: ldap-acl-milter
volumes: volumes:
- "lam_socket:/socket/:rw" - "lam_socket:/socket/:rw"

View File

@ -65,26 +65,22 @@ class LdapAclMilter(Milter.Base):
try: try:
# this may fail, if no x509 client certificate was used. # this may fail, if no x509 client certificate was used.
# postfix only passes this macro to milters if the TLS connection # 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 :-/ # Unfortunately, postfix only passes the CN-field of the subject/issuer DN :-/
x509_subject = self.getsymval('{cert_subject}') x509_subject = self.getsymval('{cert_subject}')
if x509_subject != None: if x509_subject != None:
self.session.set_x509_subject(x509_subject) self.session.set_x509_subject(x509_subject)
log_debug( log_debug(
"x509_subject={}".format(self.session.get_x509_subject()), "x509_subject={}".format(self.session.get_x509_subject()),
self.session self.session
) )
else:
log_debug("No x509_subject registered", self.session)
x509_issuer = self.getsymval('{cert_issuer}') x509_issuer = self.getsymval('{cert_issuer}')
if x509_issuer != None: if x509_issuer != None:
self.session.set_x509_issuer(x509_issuer) self.session.set_x509_issuer(x509_issuer)
log_debug( log_debug(
"x509_issuer={}".format(self.session.get_x509_issuer()), "x509_issuer={}".format(self.session.get_x509_issuer()),
self.session self.session
) )
else:
log_debug("No x509_issuer registered", self.session)
except: except:
log_error( log_error(
"x509 exception: {}".format(traceback.format_exc()), "x509 exception: {}".format(traceback.format_exc()),
@ -95,12 +91,10 @@ class LdapAclMilter(Milter.Base):
sasl_user = self.getsymval('{auth_authen}') sasl_user = self.getsymval('{auth_authen}')
if sasl_user != None: if sasl_user != None:
self.session.set_sasl_user(sasl_user) self.session.set_sasl_user(sasl_user)
log_debug( log_debug(
"sasl_user={}".format(self.session.get_sasl_user()), "sasl_user={}".format(self.session.get_sasl_user()),
self.session self.session
) )
else:
log_debug("No sasl_user registered", self.session)
except: except:
log_error( log_error(
"sasl_user exception: {}".format(traceback.format_exc()), "sasl_user exception: {}".format(traceback.format_exc()),
@ -253,7 +247,7 @@ class LdapAclMilter(Milter.Base):
self.session self.session
) )
except Exception as e: 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') return self.milter_action(action = 'continue')
# Not registered/used callbacks # Not registered/used callbacks
@ -289,7 +283,7 @@ class LdapAclMilter(Milter.Base):
if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower(): if self.session.get_hdr_from_domain().lower() == passed_dkim_sdid.lower():
self.session.set_dkim_aligned(True) self.session.set_dkim_aligned(True)
log_info( log_info(
"Found aligned DKIM signature for SDID: {0}".format( "Found aligned DKIM signature for SDID={0}".format(
passed_dkim_sdid passed_dkim_sdid
), ),
self.session self.session

View File

@ -112,7 +112,7 @@ class LamConfigBackend():
log_info("ENV[MILTER_EXPECT_AUTH]: {}".format(self.milter_expect_auth)) log_info("ENV[MILTER_EXPECT_AUTH]: {}".format(self.milter_expect_auth))
if 'MILTER_WHITELISTED_RCPTS' in os.environ: 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'] whitelisted_rcpts_str = os.environ['MILTER_WHITELISTED_RCPTS']
for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str): for whitelisted_rcpt in re.split(',|\s', whitelisted_rcpts_str):
if g_rex_email.match(whitelisted_rcpt) == None: if g_rex_email.match(whitelisted_rcpt) == None:

View File

@ -24,7 +24,7 @@ def init_log_backend():
logging.info("Logger initialized") logging.info("Logger initialized")
def do_log(level: str, log_message: str, session: Optional[LamSession] = None): def do_log(level: str, log_message: str, session: Optional[LamSession] = None):
log_line = '' log_line = '-'
if session is not None: if session is not None:
if hasattr(session, 'mconn_id'): if hasattr(session, 'mconn_id'):
log_line = "{}".format(session.get_mconn_id()) log_line = "{}".format(session.get_mconn_id())

View File

@ -1,12 +1,13 @@
import re import re
from lam_rex import g_rex_domain
from ldap3 import ( from ldap3 import (
Server, Connection, NONE, set_config_parameter Server, Connection, NONE, set_config_parameter,
SAFE_RESTARTABLE
) )
from ldap3.core.exceptions import LDAPException from ldap3.core.exceptions import LDAPException
from lam_exceptions import ( from lam_exceptions import (
LamPolicyBackendException, LamHardException, LamSoftException LamPolicyBackendException, LamHardException, LamSoftException
) )
from lam_rex import g_rex_domain
from lam_config_backend import LamConfigBackend from lam_config_backend import LamConfigBackend
from lam_session import LamSession from lam_session import LamSession
from lam_log_backend import log_info, log_debug from lam_log_backend import log_info, log_debug
@ -29,12 +30,12 @@ class LamPolicyBackend():
self.config.ldap_bindpw, self.config.ldap_bindpw,
auto_bind = True, auto_bind = True,
raise_exceptions = 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: except LDAPException as e:
raise LamPolicyBackendException( raise LamPolicyBackendException(
"Connection to LDAP-server failed: {}".format(str(e)) "policy: Connection to LDAP-server failed: {}".format(str(e))
) from e ) from e
def check_policy(self, session: LamSession, **kwargs): def check_policy(self, session: LamSession, **kwargs):
@ -44,19 +45,19 @@ class LamPolicyBackend():
m = g_rex_domain.match(from_addr) m = g_rex_domain.match(from_addr)
if m == None: if m == None:
raise LamHardException( 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) 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) m = g_rex_domain.match(rcpt_addr)
if m == None: if m == None:
raise LamHardException( raise LamHardException(
"Could not determine domain of rcpt={}".format( "policy: Could not determine domain of rcpt={}".format(
rcpt_addr rcpt_addr
) )
) )
rcpt_domain = m.group(1) rcpt_domain = m.group(1)
log_debug("rcpt_domain={}".format(rcpt_domain), session) log_debug("policy: rcpt_domain={}".format(rcpt_domain), session)
try: try:
if self.config.milter_schema == True: if self.config.milter_schema == True:
# LDAP-ACL-Milter schema enabled # LDAP-ACL-Milter schema enabled
@ -82,7 +83,7 @@ class LamPolicyBackend():
) )
else: else:
auth_method = auth_method.replace('%X509_AUTH%','') 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: if self.config.milter_schema_wildcard_domain == True:
# The asterisk (*) character is in term of local part # The asterisk (*) character is in term of local part
# RFC5322 compliant and expected as a wildcard literal in this code. # RFC5322 compliant and expected as a wildcard literal in this code.
@ -92,15 +93,15 @@ class LamPolicyBackend():
# In this case *@<domain> cannot be a real address! # In this case *@<domain> cannot be a real address!
if re.match(r'^\*@.+$', from_addr, re.IGNORECASE): if re.match(r'^\*@.+$', from_addr, re.IGNORECASE):
raise LamHardException( raise LamHardException(
"Literal wildcard sender (*@<domain>) is not " + "policy: Literal wildcard sender (*@<domain>) is not " +
"allowed in wildcard mode!" "allowed in wildcard mode!"
) )
if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE): if re.match(r'^\*@.+$', rcpt_addr, re.IGNORECASE):
raise LamHardException( raise LamHardException(
"Literal wildcard recipient (*@<domain>) is not " + "policy: Literal wildcard recipient (*@<domain>) is not " +
"allowed in wildcard mode!" "allowed in wildcard mode!"
) )
self.ldap_conn.search(self.config.ldap_base, _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base,
"(&" + "(&" +
auth_method + auth_method +
"(|" + "(|" +
@ -131,7 +132,7 @@ class LamPolicyBackend():
# 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_from = from_addr.replace("*","\\2a")
query_to = rcpt_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 + auth_method +
"(allowedSenders=" + query_from + ")" + "(allowedSenders=" + query_from + ")" +
@ -141,33 +142,33 @@ class LamPolicyBackend():
")", ")",
attributes=['policyID'] attributes=['policyID']
) )
if len(self.ldap_conn.entries) == 0: if len(ldap_response) == 0:
# Policy not found in LDAP # Policy not found in LDAP
raise LamHardException( 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 from_source, from_addr, rcpt_addr
) )
) )
elif len(self.ldap_conn.entries) == 1: elif len(ldap_response) == 1:
if from_source == 'from-header': if from_source == 'from-header':
log_info( log_info(
"5322.from_domain={} authorized by DKIM signature".format( "policy: 5322.from_domain={} authorized by DKIM signature".format(
from_domain from_domain
), ),
session session
) )
# Policy found in LDAP, but which one? # Policy found in LDAP, but which one?
entry = self.ldap_conn.entries[0] entry = ldap_response[0]['attributes']
log_info( log_info(
"match='{0}' from_src={1}".format( "policy: match='{0}' from_src={1}".format(
entry.policyID.value, from_source entry['PolicyID'][0], from_source
), ),
session session
) )
elif len(self.ldap_conn.entries) > 1: elif len(ldap_response) > 1:
# Something went wrong!? There shouldn´t be more than one entries! # Something went wrong!? There shouldn´t be more than one entries!
raise LamHardException( 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 from_addr, rcpt_addr, auth_method
) )
) )
@ -182,14 +183,14 @@ class LamPolicyBackend():
query = query.replace("%sasl_user%", session.get_sasl_user()) query = query.replace("%sasl_user%", session.get_sasl_user())
query = query.replace("%from_domain%", from_domain) query = query.replace("%from_domain%", from_domain)
query = query.replace("%rcpt_domain%", rcpt_domain) query = query.replace("%rcpt_domain%", rcpt_domain)
log_debug("LDAP query: {}".format(query), session) log_debug("policy: LDAP query: {}".format(query), session)
self.ldap_conn.search(self.config.ldap_base, query) _, _, ldap_response, _ = self.ldap_conn.search(self.config.ldap_base, query)
if len(self.ldap_conn.entries) == 0: if len(ldap_response) == 0:
raise LamHardException( 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 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: except LDAPException as e:
raise LamSoftException("LDAP exception: " + str(e)) from e raise LamSoftException("policy: LDAP exception: " + str(e)) from e

39
tests/sasl-fail.lua Normal file
View File

@ -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, "<rcpt-fail@test.blubb>") ~= 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)

View File

@ -28,9 +28,6 @@ end
if mt.header(conn, "fRoM", '"Blah Blubb" <tester@test.blah>') ~= nil then if mt.header(conn, "fRoM", '"Blah Blubb" <tester@test.blah>') ~= nil then
error "mt.header(From) failed" error "mt.header(From) failed"
end 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 -- EOM
if mt.eom(conn) ~= nil then if mt.eom(conn) ~= nil then