diff --git a/Milter/config.py b/Milter/config.py new file mode 100644 index 0000000000000000000000000000000000000000..9347c9a620b9df51735eb246b23dbba1accdcf36 --- /dev/null +++ b/Milter/config.py @@ -0,0 +1,59 @@ +from ConfigParser import ConfigParser + +class MilterConfigParser(ConfigParser): + + def __init__(self,defaults={}): + ConfigParser.__init__(self) + self.defaults = defaults + + # The defaults provided by ConfigParser show up in all sections, + # which screws up iterating over all options in a section. + # Worse, passing "defaults" with vars= overrides the config file! + # So we roll our own defaults. + def get(self,sect,opt): + if not self.has_option(sect,opt) and opt in self.defaults: + return self.defaults[opt] + return ConfigParser.get(self,sect,opt) + + def getlist(self,sect,opt): + if self.has_option(sect,opt): + return [q.strip() for q in self.get(sect,opt).split(',')] + return [] + + def getaddrset(self,sect,opt): + if not self.has_option(sect,opt): + return {} + s = self.get(sect,opt) + d = {} + for q in s.split(','): + q = q.strip() + if q.startswith('file:'): + domain = q[5:].lower() + d[domain] = d.setdefault(domain,[]) + open(domain,'r').read().split() + else: + user,domain = q.split('@') + d.setdefault(domain.lower(),[]).append(user) + return d + + def getaddrdict(self,sect,opt): + if not self.has_option(sect,opt): + return {} + d = {} + for q in self.get(sect,opt).split(','): + q = q.strip() + if self.has_option(sect,q): + l = self.get(sect,q) + for addr in l.split(','): + addr = addr.strip() + if addr.startswith('file:'): + fname = addr[5:] + for a in open(fname,'r').read().split(): + d[a] = q + else: + d[addr] = q + return d + + def getdefault(self,sect,opt,default=None): + if self.has_option(sect,opt): + return self.get(sect,opt) + return default diff --git a/bms.py b/bms.py index 9a430a59abb369f8e410d27c10eec7233e9fd305..3c61d83ead8e27262ee46e07574e534477383c34 100644 --- a/bms.py +++ b/bms.py @@ -1,6 +1,9 @@ #!/usr/bin/env python # A simple milter that has grown quite a bit. # $Log$ +# Revision 1.81 2007/01/05 23:33:55 customdesigned +# Make blacklist an AddrCache +# # Revision 1.80 2007/01/05 23:12:12 customdesigned # Move parse_addr, iniplist, ip4re to Milter.utils # @@ -49,7 +52,6 @@ import mime import email.Errors import Milter import tempfile -from ConfigParser import ConfigParser import time import socket import struct @@ -60,6 +62,7 @@ import anydbm import Milter.dsn as dsn from Milter.dynip import is_dynip as dynip from Milter.utils import iniplist,parse_addr,ip4re +from Milter.config import MilterConfigParser from fnmatch import fnmatchcase from email.Header import decode_header @@ -167,64 +170,6 @@ milter_log = logging.getLogger('milter') if gossip: gossip_node = Gossip('gossip4.db',120) -class MilterConfigParser(ConfigParser): - - def __init__(self,defaults): - ConfigParser.__init__(self) - self.defaults = defaults - - # The defaults provided by ConfigParser show up in all sections, - # which screws up iterating over all options in a section. - # Worse, passing "defaults" with vars= overrides the config file! - # So we roll our own defaults. - def get(self,sect,opt): - if not self.has_option(sect,opt) and opt in self.defaults: - return self.defaults[opt] - return ConfigParser.get(self,sect,opt) - - def getlist(self,sect,opt): - if self.has_option(sect,opt): - return [q.strip() for q in self.get(sect,opt).split(',')] - return [] - - def getaddrset(self,sect,opt): - if not self.has_option(sect,opt): - return {} - s = self.get(sect,opt) - d = {} - for q in s.split(','): - q = q.strip() - if q.startswith('file:'): - domain = q[5:].lower() - d[domain] = d.setdefault(domain,[]) + open(domain,'r').read().split() - else: - user,domain = q.split('@') - d.setdefault(domain.lower(),[]).append(user) - return d - - def getaddrdict(self,sect,opt): - if not self.has_option(sect,opt): - return {} - d = {} - for q in self.get(sect,opt).split(','): - q = q.strip() - if self.has_option(sect,q): - l = self.get(sect,q) - for addr in l.split(','): - addr = addr.strip() - if addr.startswith('file:'): - fname = addr[5:] - for a in open(fname,'r').read().split(): - d[a] = q - else: - d[addr] = q - return d - - def getdefault(self,sect,opt,default=None): - if self.has_option(sect,opt): - return self.get(sect,opt) - return default - def read_config(list): cp = MilterConfigParser({ 'tempdir': "/var/log/milter/save", @@ -393,7 +338,7 @@ def parse_header(val): return val class SPFPolicy(object): - "Get SPF policy by result, defaulting to classic policy from pymilter.cfg" + "Get SPF policy by result from sendmail style access file." def __init__(self,sender): self.sender = sender self.domain = sender.split('@')[-1].lower() diff --git a/milter.cfg b/milter.cfg index 306b7992b3347b4e2b3520f10dd4e7adfa193e27..0553fb9ca704c8c408bd0fa9bb4bfc2c27f77d08 100644 --- a/milter.cfg +++ b/milter.cfg @@ -78,7 +78,7 @@ reject_spoofed = 0 # refuses mail from user names commonly abused in that way. ;banned_users = postmaster, mailer-daemon, clamav -# See http://spf.pobox.com for more info on SPF. +# See http://www.openspf.com for more info on SPF. [spf] # namespace where SPF records can be supplied for domains without one # records are searched for under _spf.domain.com diff --git a/spfmilter.cfg b/spfmilter.cfg new file mode 100644 index 0000000000000000000000000000000000000000..3a2a7c2d057eedc05b0fea01b331480e7f7b6791 --- /dev/null +++ b/spfmilter.cfg @@ -0,0 +1,20 @@ +[milter] +# The socket used to communicate with sendmail +socketname = /tmp/spfmiltersock +# Name of the milter given to sendmail +name = pyspffilter +# Trusted relays such as secondary MXes that should not have SPF checked. +;trusted_relay = +# Internal networks that should not have SPF checked. +internal_connect = 127.0.0.1,192.168.0.0/16 + +# See http://www.openspf.com for more info on SPF. +[spf] +# Use sendmail access map or similar format for detailed spf policy. +# SPF entries in the access map will override defaults. +;access_file = /etc/mail/access.db +# Connections that get an SPF pass for a pretend MAIL FROM of +# postmaster@sometrustedforwarder.com skip SPF checks for the real MAIL FROM. +# This is for non-SRS forwarders. It is a simple implementation that +# is inefficient for more than a few entries. +;trusted_forwarder = careerbuilder.com diff --git a/spfmilter.py b/spfmilter.py index b04b3175b991328f2434b144f9774d5ac46dfa44..c3617dfae704b0d2d7c3b4948d908c3b2d982b49 100644 --- a/spfmilter.py +++ b/spfmilter.py @@ -13,26 +13,55 @@ import spf import struct import socket import syslog - +from Milter.config import MilterConfigParser from Milter.utils import iniplist,parse_addr syslog.openlog('spfmilter',0,syslog.LOG_MAIL) -# list of trusted forwarder domains. An SPF record for a forwarder -# domain lists IP addresses from which forwarded mail is accepted. -trusted_forwarder = [] -# list of internal LAN ips. No SPF check is done for these. -internal_connect = ['127.0.0.1','192.168.0.0/16'] -# list of trusted relays. These are typically secondary MXes, and -# no SPF check is done for these. -trusted_relay = [] - -socketname = "/var/run/milter/spfmiltersock" -#socketname = os.getenv("HOME") + "/pythonsock" -miltername = "pyspffilter" - +class Config(object): + "Hold configuration options." + pass + +def read_config(list): + "Return new config object." + cp = MilterConfigParser() + cp.read(list) + conf = Config() + conf.socketname = cp.getdefault('milter','socketname', '/tmp/spfmiltersock') + conf.miltername = cp.getdefault('milter','name','pyspffilter') + conf.trusted_relay = cp.getlist('milter','trusted_relay') + conf.internal_connect = cp.getlist('milter','internal_connect') + conf.trusted_forwarder = cp.getlist('spf','trusted_relay') + conf.access_file = cp.getdefault('spf','access_file',None) + return conf + +class SPFPolicy(object): + "Get SPF policy by result from sendmail style access file." + def __init__(self,sender): + self.sender = sender + self.domain = sender.split('@')[-1].lower() + if access_file: + try: acf = anydbm.open(access_file,'r') + except: acf = None + else: acf = None + self.acf = acf + + def getPolicy(self,pfx): + acf = self.acf + if not acf: return None + try: + return acf[pfx + self.sender] + except KeyError: + try: + return acf[pfx + self.domain] + except KeyError: + try: + return acf[pfx] + except KeyError: + return None + class spfMilter(Milter.Milter): - "Milter to check SPF." + "Milter to check SPF. Each connection gets its own instance." def log(self,*msg): syslog.syslog('[%d] %s' % (self.id,' '.join([str(m) for m in msg]))) @@ -40,6 +69,8 @@ class spfMilter(Milter.Milter): def __init__(self): self.mailfrom = None self.id = Milter.uniqueID() + # we don't want config used to change during a connection + self.conf = config # addheader can only be called from eom(). This accumulates added headers # which can then be applied by alter_headers() @@ -55,9 +86,9 @@ class spfMilter(Milter.Milter): self.receiver = self.getsymval('j').strip() if hostaddr and len(hostaddr) > 0: ipaddr = hostaddr[0] - if iniplist(ipaddr,internal_connect): + if iniplist(ipaddr,self.conf.internal_connect): self.internal_connection = True - if iniplist(ipaddr,trusted_relay): + if iniplist(ipaddr,self.conf.trusted_relay): self.trusted_relay = True else: ipaddr = '' self.connectip = ipaddr @@ -112,7 +143,7 @@ class spfMilter(Milter.Milter): def check_spf(self): receiver = self.receiver - for tf in trusted_forwarder: + for tf in self.conf.trusted_forwarder: q = spf.query(self.connectip,'',tf,receiver=receiver,strict=False) res,code,txt = q.check() if res == 'pass': @@ -141,25 +172,58 @@ class spfMilter(Milter.Milter): else: hres,hcode,htxt = res,code,txt else: hres = None + + p = SPFPolicy(q.s) + if res == 'fail': - self.log('REJECT: SPF %s %i %s' % (res,code,txt)) - self.setreply(str(code),'5.7.1',txt) - # A proper SPF fail error message would read: - # forger.biz [1.2.3.4] is not allowed to send mail with the domain - # "forged.org" in the sender address. Contact <postmaster@forged.org>. - return Milter.REJECT - if res == 'permerror': - self.log('REJECT: SPF %s %i %s' % (res,code,txt)) - # latest SPF draft recommends 5.5.2 instead of 5.7.1 - self.setreply(str(code),'5.5.2',txt, - 'There is a fatal syntax error in the SPF record for %s' % q.o, - 'We cannot accept mail from %s until this is corrected.' % q.o - ) - return Milter.REJECT - if res == 'temperror': - self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt)) - self.setreply(str(code),'4.3.0',txt) - return Milter.TEMPFAIL + policy = p.getPolicy('spf-fail:') + if not policy or policy == 'REJECT': + self.log('REJECT: SPF %s %i %s' % (res,code,txt)) + self.setreply(str(code),'5.7.1',txt) + # A proper SPF fail error message would read: + # forger.biz [1.2.3.4] is not allowed to send mail with the domain + # "forged.org" in the sender address. Contact <postmaster@forged.org>. + return Milter.REJECT + if res == 'softfail': + policy = p.getPolicy('spf-softfail:') + if policy and policy == 'REJECT': + self.log('REJECT: SPF %s %i %s' % (res,code,txt)) + self.setreply(str(code),'5.7.1',txt) + # A proper SPF fail error message would read: + # forger.biz [1.2.3.4] is not allowed to send mail with the domain + # "forged.org" in the sender address. Contact <postmaster@forged.org>. + return Milter.REJECT + elif res == 'permerror': + policy = p.getPolicy('spf-permerror:') + if not policy or policy == 'REJECT': + self.log('REJECT: SPF %s %i %s' % (res,code,txt)) + # latest SPF draft recommends 5.5.2 instead of 5.7.1 + self.setreply(str(code),'5.5.2',txt, + 'There is a fatal syntax error in the SPF record for %s' % q.o, + 'We cannot accept mail from %s until this is corrected.' % q.o + ) + return Milter.REJECT + elif res == 'temperror': + policy = p.getPolicy('spf-temperror:') + if not policy or policy == 'REJECT': + self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt)) + self.setreply(str(code),'4.3.0',txt) + return Milter.TEMPFAIL + elif res == 'neutral' or res == 'none': + policy = p.getPolicy('spf-neutral:') + if policy and policy == 'REJECT': + self.log('REJECT NEUTRAL:',q.s) + self.setreply('550','5.7.1', + "%s requires and SPF PASS to accept mail from %s. [http://openspf.org]" + % (receiver,q.s)) + return Milter.REJECT + elif res == 'pass': + policy = p.getPolicy('spf-pass:') + if policy and policy == 'REJECT': + self.log('REJECT PASS:',q.s) + self.setreply('550','5.7.1', + "%s has been blacklisted by %s." % (q.s,receiver)) + return Milter.REJECT self.add_header('Received-SPF',q.get_header(res,receiver),0) if hres and q.h != q.o: self.add_header('X-Hello-SPF',hres,0) @@ -168,6 +232,10 @@ class spfMilter(Milter.Milter): if __name__ == "__main__": Milter.factory = spfMilter Milter.set_flags(Milter.CHGHDRS + Milter.ADDHDRS) + global config + config = read_config(['spfmilter.cfg','/etc/mail/spfmilter.cfg']) + miltername = config.miltername + socketname = config.socketname print """To use this with sendmail, add the following to sendmail.cf: O InputMailFilters=%s