diff --git a/MANIFEST.in b/MANIFEST.in
index b7fed2c..a7faa28 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -20,3 +20,4 @@ include start.sh
include milter.rc
include milter.rc7
include milter.cfg
+include rhsbl.m4
diff --git a/NEWS b/NEWS
index 6f82caf..3b44bf7 100644
--- a/NEWS
+++ b/NEWS
@@ -1,5 +1,14 @@
Here is a history of user visible changes to Python milter.
+0.7.2 Return unknown for invalid ip address in mechanism
+ Recognize dynamic PTR names, and don't count them as authentication.
+ Three strikes and yer out rule.
+ Block softfail by default when no PTR or HELO
+ Return unknown for null mechanism
+ Return unknown for invalid ip address in mechanism
+ Try best guess on HELO also
+ Expand setreply for common errors
+ make rhsbl.m4 hack available for sendmail.mc
0.7.1 Handle modifying mislabeled multipart messages without an exception
Support setbacklog, setmlreply
Allow multi-recipient CBV
diff --git a/TODO b/TODO
index cc99aff..2fae047 100644
--- a/TODO
+++ b/TODO
@@ -1,10 +1,30 @@
+Checking in mime.py;
+/bms/cvs/milter/mime.py,v <-- mime.py
+new revision: 1.56; previous revision: 1.55
+done
+Checking in spf.py;
+/bms/cvs/milter/spf.py,v <-- spf.py
+new revision: 1.18; previous revision: 1.17
+done
+Checking in testmime.py;
+/bms/cvs/milter/testmime.py,v <-- testmime.py
+new revision: 1.19; previous revision: 1.18
+
+Auto whitelist based on outgoing email - perhaps with magic subject
+or recipient prefix.
+
+Can't output messages with malformed rfc822 attachments.
+
+Use python exceptions in SPF to cleanly handle unknown and error results.
+
+Example malformed SPF:
+onvunvuvvx.usafisnews.org text "v=spf1 mx ptr ip4:207.44.199.970 -all"
+
Move milter,Milter,mime,spf modules to pymilter
milter package will have bms.py application
-spf.py has no recursion bound on CNAME lookup
Support SMTP AUTH and disable SPF checks when connection is authorized.
Web admin interface
-RHSBL
Check valid domains allowed by internal senders to detect PCs infected
with spam trojans.
Do CBV (callback verification) for mail with no published SPF record.
@@ -52,3 +72,4 @@ Wrap smfi_setbacklog(int) - but it is only available in sendmail >= 8.12.3,
Need a test module to feed sample messages to a milter though a live
sendmail and SMTP. The mockup currently used is probably not very accurate,
and doesn't test the threading code.
+
diff --git a/bms.py b/bms.py
index fffecf3..0c04474 100644
--- a/bms.py
+++ b/bms.py
@@ -1,6 +1,30 @@
#!/usr/bin/env python
# A simple milter.
# $Log$
+# Revision 1.126 2004/11/24 14:39:38 stuart
+# Also accept softfail if valid PTR or HELO.
+#
+# Revision 1.125 2004/11/19 16:40:14 stuart
+# Block softfail except for listed domains.
+#
+# Revision 1.124 2004/11/19 06:18:04 stuart
+# block softfail for configured domains only
+#
+# Revision 1.123 2004/11/18 20:36:49 stuart
+# Recognize more dynamic hosts. Ignore dynamic PTR for best_guess.
+#
+# Revision 1.122 2004/11/18 17:16:10 stuart
+# Recognize more dynamic ips.
+#
+# Revision 1.121 2004/11/09 22:37:48 stuart
+# Don't accept helo names which are dynamic IP addresses.
+#
+# Revision 1.120 2004/11/09 20:33:50 stuart
+# Recognize more dynamic PTR variations.
+#
+# Revision 1.118 2004/08/30 21:19:50 stuart
+# Try best guess for HELO, expand setreply for common errors
+#
# Revision 1.117 2004/08/23 02:27:53 stuart
# Allow multi rcpt CBV. Add some multiline replies.
#
@@ -292,6 +316,7 @@ srs = None
srs_reject_spoofed = False
srs_fwdomain = None
spf_reject_neutral = ()
+spf_accept_softfail = ()
spf_best_guess = False
spf_reject_noptr = False
timeout = 600
@@ -401,6 +426,7 @@ def read_config(list):
global dspam_dict, dspam_users, dspam_userdir, dspam_exempt
global dspam_screener,dspam_whitelist,dspam_reject,dspam_sizelimit
global spf_reject_neutral,spf_best_guess,SRS,spf_reject_noptr
+ global spf_accept_softfail
dspam_dict = cp.getdefault('dspam','dspam_dict')
dspam_exempt = cp.getaddrset('dspam','dspam_exempt')
dspam_whitelist = cp.getaddrset('dspam','dspam_whitelist')
@@ -414,6 +440,7 @@ def read_config(list):
if spf:
spf.DELEGATE = cp.getdefault('spf','delegate')
spf_reject_neutral = cp.getlist('spf','reject_neutral')
+ spf_accept_softfail = cp.getlist('spf','accept_softfail')
spf_best_guess = cp.getboolean('spf','best_guess')
spf_reject_noptr = cp.getboolean('spf','reject_noptr')
srs_config = cp.getdefault('srs','config')
@@ -461,6 +488,43 @@ def parse_header(val):
except LookupError: pass
return val
+ip3 = re.compile('([0-9]{1,3})[.-]([0-9]{1,3})[.-]([0-9]{1,3})')
+rehmac = re.compile('h[0-9a-f]{12}[.]|pcp[0-9]{6,10}pcs[.]|no-reverse')
+
+def dynip(host,addr):
+ """Return True if hostname is for a dynamic ip.
+ Examples:
+
+ >>> is_dynip('post3.fabulousdealz.com','69.60.99.112')
+ False
+ >>> is_dynip('adsl-69-208-201-177.dsl.emhril.ameritech.net','69.208.201.177')
+ True
+ """
+ if host.startswith('[') and host.endswith(']'):
+ return True
+ if addr:
+ if host.find(addr) >= 0: return True
+ a = addr.split('.')
+ m = ip3.search(host)
+ if m:
+ g = list(m.groups())
+ if g == a[1:] or g == a[:3]: return True
+ g.reverse()
+ if g == a[1:] or g == a[:3]: return True
+ if rehmac.search(host): return True
+ if host.find("-%s." % '-'.join(a[2:])) >= 0: return True
+ if host.find("w%s." % '-'.join(a[:2])) >= 0: return True
+ if host.find(''.join(a[:3])) >= 0: return True
+ if host.find(''.join(a[1:])) >= 0: return True
+ x = "%02x%02x%02x%02x" % tuple(map(int,a))
+ if host.lower().find(x) >= 0: return True
+ z = [n.zfill(3) for n in a]
+ if host.find('-'.join(z)) >= 0: return True
+ if host.find("-%s." % '-'.join(z[2:])) >= 0: return True
+ if host.find("%s." % ''.join(z[2:])) >= 0: return True
+ if host.find(''.join(z)) >= 0: return True
+ return False
+
class bmsMilter(Milter.Milter):
"""Milter to replace attachments poisonous to Windows with a WARNING message,
check SPF, and other anti-forgery features, and implement wiretapping
@@ -500,7 +564,6 @@ class bmsMilter(Milter.Milter):
self.log('%s: %s' % (name,val))
def connect(self,hostname,unused,hostaddr):
- self.missing_ptr = hostname.startswith('[') and hostname.endswith(']')
self.internal_connection = False
self.trusted_relay = False
self.receiver = self.getsymval('j')
@@ -517,6 +580,7 @@ class bmsMilter(Milter.Milter):
self.connectip = ipaddr
else:
self.connectip = None
+ self.missing_ptr = dynip(hostname,self.connectip)
for pat in internal_connect:
if fnmatchcase(hostname,pat):
self.internal_connection = True
@@ -602,47 +666,61 @@ class bmsMilter(Milter.Milter):
q.set_default_explanation('SPF fail: see http://spf.pobox.com/why.html')
res,code,txt = q.check()
receiver = self.receiver
- if res == 'none':
+ if res in ('none', 'softfail'):
if self.mailfrom != '<>':
# check hello name via spf
- hres,hcode,htxt = spf.check(self.connectip,'',self.hello_name)
+ h = spf.query(self.connectip,'',self.hello_name)
+ hres,hcode,htxt = h.check()
if hres in ('deny','fail','neutral','softfail'):
self.log('REJECT: hello SPF: %s 550 %s' % (hres,htxt))
self.setreply('550','5.7.1',htxt,
"The hostname given in your MTA's HELO response is not listed",
- "as a legitimate MTA in the SPF records for your domain.",
- "If you get this bounce, the message was not in fact a forgery,",
- "and you should notify your email administrator of the problem."
+ "as a legitimate MTA in the SPF records for your domain. If you",
+ "get this bounce, the message was not in fact a forgery, and you",
+ "should IMMEDIATELY notify your email administrator of the problem."
)
return Milter.REJECT
- if spf_best_guess:
+ if hres == 'none' and spf_best_guess \
+ and not dynip(self.hello_name,self.connectip):
+ hres,hcode,htxt = h.best_guess()
+ else: hres = res
+ if spf_best_guess and res == 'none':
#self.log('SPF: no record published, guessing')
q.set_default_explanation(
'SPF guess: see http://spf.pobox.com/why.html')
# best_guess should not result in fail
- res,code,txt = q.best_guess()
+ if self.missing_ptr:
+ # ignore dynamic PTR for best guess
+ res,code,txt = q.best_guess('v=spf1 a/24 mx/24')
+ else:
+ res,code,txt = q.best_guess()
receiver += ': guessing'
- if self.missing_ptr and res in ('neutral', 'none') and spf_reject_noptr:
- self.log('REJECT: no PTR or SPF')
+ if self.missing_ptr and res in ('neutral', 'none') \
+ and spf_reject_noptr and hres != 'pass':
+ self.log('REJECT: no PTR, HELO or SPF')
self.setreply('550','5.7.1',
- 'You must have a reverse lookup or publish SPF: http://spf.pobox.com'
+ 'You must have a reverse lookup or publish SPF: http://spf.pobox.com',
+ 'Contact your mail administrator IMMEDIATELY! Your mail server is',
+ 'severely misconfigured. It has no PTR record (dynamic PTR records',
+ "that contain your IP don't count), an invalid HELO, and no SPF record."
)
return Milter.REJECT
if res in ('deny', 'fail'):
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
self.setreply(str(code),'5.7.1',txt)
return Milter.REJECT
- if res == 'softfail':
- self.log('TEMPFAIL: SPF %s 450 %s' % (res,txt))
- self.setreply('450','4.3.0',
- 'SPF softfail: will keep trying until your SPF record is fixed.',
- 'If you get this Delivery Status Notice, your email was probably',
- 'legitimate. Your administrator has published SPF records in a',
- 'testing mode. The SPF record reported your email as a forgery,',
- 'which is a mistake if you are reading this. Please notify your',
- 'administrator of the problem.'
- )
- return Milter.TEMPFAIL
+ if res == 'softfail' and not q.o in spf_accept_softfail:
+ if self.missing_ptr and spf_reject_noptr and hres != 'pass':
+ self.log('TEMPFAIL: SPF %s 450 %s' % (res,txt))
+ self.setreply('450','4.3.0',
+ 'SPF softfail: will keep trying until your SPF record is fixed.',
+ 'If you get this Delivery Status Notice, your email was probably',
+ 'legitimate. Your administrator has published SPF records in a',
+ 'testing mode. The SPF record reported your email as a forgery,',
+ 'which is a mistake if you are reading this. Please notify your',
+ 'administrator of the problem immediately.'
+ )
+ return Milter.TEMPFAIL
if res == 'neutral' and q.o in spf_reject_neutral:
self.log('REJECT: SPF neutral for',q.s)
self.setreply('550','5.7.1',
@@ -655,6 +733,11 @@ class bmsMilter(Milter.Milter):
)
return Milter.REJECT
if res == 'error':
+ if code >= 500:
+ self.log('REJECT: SPF %s %i %s' % (res,code,txt))
+ self.setreply(str(code),'5.7.1',txt)
+ return Milter.REJECT
+ self.log('TEMPFAIL: SPF %s %i %s' % (res,code,txt))
self.setreply(str(code),'4.3.0',txt)
return Milter.TEMPFAIL
self.add_header('Received-SPF',q.get_header(res,receiver))
@@ -985,6 +1068,9 @@ class bmsMilter(Milter.Milter):
self.tempname = None
if exc_type == email.Errors.BoundaryError:
self.log("MALFORMED: %s" % fname) # log filename
+ if self.internal_connection:
+ # accept anyway for now
+ return Milter.ACCEPT
self.setreply('554','5.7.7',
'Boundary error in your message, are you a spammer?')
return Milter.REJECT
diff --git a/milter.cfg b/milter.cfg
index e5f8d52..adbd0ed 100644
--- a/milter.cfg
+++ b/milter.cfg
@@ -20,10 +20,11 @@ log_headers = 0
;check_user = joe@mycorp.com, mary@mycorp.com, file:bigcorp.com
# reject mail with these case insensitive strings in the subject
porn_words = penis, breast, pussy, horse cock, porn, xenical, diet pill, d1ck,
- vi*gra, vi-a-gra, viag, tits, p0rn, hunza, horny, sexy, c0ck,
+ vi*gra, vi-a-gra, viag, tits, p0rn, hunza, horny, sexy, c0ck, xanaax,
p-e-n-i-s, hydrocodone, vicodin, xanax, vicod1n, x@nax, diazepam,
v1@gra, xan@x, cialis, ci@lis, frëe, xănax, valíum, vălium, via-gra,
- x@n3x, vicod3n, penís, c0d1n, phentermine, en1arge, dip1oma, v1codin
+ x@n3x, vicod3n, penís, c0d1n, phentermine, en1arge, dip1oma, v1codin,
+ valium, rolex
# reject mail with these case sensitive strings in the subject
spam_words = $$$, !!!, XXX, FREE, HGH
@@ -68,6 +69,8 @@ reject_spoofed = 0
;best_guess = 0
# reject senders that have neither PTR nor SPF records
;reject_noptr = 0
+# always accept softfail from these domains
+;accept_softfail = bounces.amazon.com
# features intended to clean up outgoing mail
[scrub]
diff --git a/milter.html b/milter.html
index 34c03e0..3b9a8d5 100644
--- a/milter.html
+++ b/milter.html
@@ -24,7 +24,7 @@ ALT="Viewable With Any Browser" BORDER="0">
Stuart D. Gathman
This web page is written by Stuart D. Gathman
and
sponsored by
Business Management Systems, Inc.
-Last updated Aug 06, 2004
+Last updated Nov 24, 2004
See the FAQ | Download now |
Subscribe to mailing list |
@@ -43,14 +43,52 @@ separation features to enhance security.
I recommend upgrading.
The RPM for release 0.7.0 moves the config file and socket locations to /etc/mail and /var/run/milter respectively. We now parse Microsoft CID records -- but only hotmail.com uses them. They seem to have a patent on the brilliant -idea of examining the mail headers to see who the message is from. -We aren't doing that here, so not to worry - but I am not a lawyer, so if you -are worried, change spf.py around line 626 to return None instead of +- but only hotmail.com uses them. They seem to have applied for a patent on +the brilliant idea of examining the mail headers to see who the message is +from. We aren't doing that here, so not to worry - but I am not a lawyer, so +if you are worried, change spf.py around line 626 to return None instead of calling CIDParser(). There is a new option to reject mail with no PTR and no SPF. +
+Microsoft is pushing an anti-opensource license for their pending patent +along with their sender-ID proposal before the IETF. +It is royalty free - but requires anyone distributing a binary they've +compiled from source to sign a license agreement. The Apache Software +Foundation explains +the problem with sender-ID, and Debian concurs. Since +the Microsoft license is +incompatible with free +software in general and the GPL in +particular, Python milter will not be able to implement sender-ID in its +current form. This was, no doubt, Microsoft's intent all along. +
+Sender-ID attempts to do for RFC2822 headers what SPF does for RFC2821 headers. +Unlike SPF, it has never been tried, and is encumbered by a stupid patent. I +recommend ignoring it and continuing to implement and improve SPF until a +working and unencumbered proposal for RFC2822 headers surfaces. +
@@ -168,13 +206,40 @@ in the sample config file for the bms.py milter.
-The latest version is 0.7.0-1. See the Change Log. +The latest version is 0.7.2-2. See the Change Log. +PLEASE NOTE - if you are using the modules, but not the bms milter application, +then ignore the RPMs and milter.spec. Use 'python setup.py bdist_rpm' to +build source and binary rpms that do not include the milter application. +
+I want to split the bms milter application to a new project once I figure +out the renaming. The current plan is to rename 'milter' to 'pymilter', which +will have the Python modules. The bms milter application will still be named +'milter' and depend on pymilter (so that my installs won't notice anything). +
+Stable + +milter-0.7.2.tar.gz Three strikes and your out policy. Some SPF fixes. +Recognizes PTR records for dynamic IPs. +
+
+milter-0.7.1.tar.gz Support setmlreply, handle some more exceptions
+for malformed spam. Compiling pymilter with sendmail-8.12.10, requires
+sendmail-devel with _FFR_MULTILINE set. The binary will work with older
+sendmails. The _FFR_MULTILINE option only affects libmilter.a.
+
+
+milter-0.7.1-1.i386.rpm Binary RPM for Redhat 7.x, now requires
+ sendmail-8.12 and
+ python2.3.
+
+
+milter-0.7.1-1.src.rpm Source RPM for Redhat 9,7.x.
Stable
@@ -191,6 +256,9 @@ milter-0.7.0-1rh9.i386.rpm Binary RPM for Redhat 9, requires
sendmail-8.12 and
python2.3.
+
+milter-0.7.0-1.ppc.rpm Binary RPM for AIX, requires sendmail-8.13.1.
+
milter-0.7.0-1.src.rpm Source RPM for Redhat 9,7.x.
diff --git a/milter.spec b/milter.spec
index d9babc2..0bb7fe3 100644
--- a/milter.spec
+++ b/milter.spec
@@ -1,6 +1,6 @@
%define name milter
-%define version 0.7.1
-%define release 1
+%define version 0.7.2
+%define release 2
# Redhat 7.x and earlier (multiple ps lines per thread)
%define sysvinit milter.rc7
# RH9, other systems (single ps line per process)
@@ -25,6 +25,9 @@ Vendor: Stuart D. Gathman , from a client with ip address i.
h is the HELO/EHLO domain name.
@@ -329,6 +358,7 @@ class query(object):
self.cache = {}
self.exps = dict(EXPLANATIONS)
self.local = local # local policy
+ self.lookups = 0
def set_default_explanation(self,exp):
exps = self.exps
@@ -357,27 +387,32 @@ class query(object):
return ('pass', 250, 'local connections always pass')
try:
+ self.lookups = 0
if not spf:
spf = self.dns_spf(self.d)
if self.local and spf:
spf += ' ' + self.local
return self.check1(spf, self.d, 0)
- except DNS.DNSError:
- return ('error', 450, 'SPF DNS Error')
+ except DNS.DNSError,x:
+ return ('error', 450, 'SPF DNS Error: ' + str(x))
+ except TempError,x:
+ return ('error', 450, 'SPF Temporary Error: ' + str(x))
+ except PermError,x:
+ return ('error', 550, 'SPF Permanent Error: ' + str(x))
def check1(self, spf, domain, recursion):
# spf rfc: 3.7 Processing Limits
#
- if recursion > 20:
- self.prob = 'Mechanisms used too many DNS lookups'
+ if recursion > MAX_RECURSION:
+ self.prob = 'Too many levels of recursion'
return ('unknown', 250, 'SPF recursion limit exceeded')
try:
tmp, self.d = self.d, domain
- return self.check0(spf, recursion)
+ return self.check0(spf,recursion)
finally:
self.d = tmp
- def check0(self, spf, recursion):
+ def check0(self, spf,recursion):
"""Test this query information against SPF text.
Returns (result, mta-status-code, explanation) where
@@ -425,35 +460,30 @@ class query(object):
m, arg, cidrlength = parse_mechanism(mech, self.d)
# map '?' '+' or '-' to 'unknown' 'pass' or 'fail'
- result = RESULTS.get(m[0])
- if result:
+ if m:
+ result = RESULTS.get(m[0])
+ if result:
# eat '?' '+' or '-'
m = m[1:]
- else:
+ else:
# default pass
result = 'pass'
- if m in ['a', 'mx', 'ptr', 'exists', 'include']:
+ if m in ['a', 'mx', 'ptr', 'prt', 'exists', 'include']:
arg = self.expand(arg)
if m == 'include':
- if arg != self.d:
- res,code,txt = self.check1(self.dns_spf(arg),
- arg, recursion + 1)
- if res == 'pass':
- break
- if res in ('fail','neutral','softfail'):
- continue
- if res == 'none':
- self.prob = \
- 'Could not find a valid SPF record'
- res = 'unknown'
- return res,code,txt
- else:
- self.prob = 'Required option is missing'
- self.mech.append(mech)
- return ('unknown', 250, 'missing SPF option')
-
+ if arg != self.d:
+ res,code,txt = self.check1(self.dns_spf(arg),
+ arg, recursion + 1)
+ if res == 'pass':
+ break
+ if res == 'none':
+ raise PermError(
+ 'No valid SPF record for included domain')
+ continue
+ else:
+ raise PermError('include mechanism missing domain')
elif m == 'all':
break
@@ -472,9 +502,15 @@ class query(object):
break
elif m in ('ip4', 'ipv4', 'ip') and arg != self.d:
+ try:
if cidrmatch(self.i, [arg], cidrlength):
- break
- elif m == 'ip6':
+ break
+ except socket.error:
+ self.mech.append(mech)
+ self.prob = 'Bad mechanism syntax found'
+ return ('unknown',250,'SPF mechanism syntax error')
+
+ elif m in ('ip6', 'ipv6'):
# Until we support IPV6, we should never
# get an IPv6 connection. So this mech
# will never match.
@@ -486,17 +522,14 @@ class query(object):
break
else:
- # unknown mechanisms cause immediate unknown
- # abort results
- self.mech.append(mech)
- self.prob = 'Unknown mechanism found'
- return ('unknown',250,'unknown SPF mechanism')
-
+ # unknown mechanisms cause immediate unknown
+ # abort results
+ raise PermError('Unknown mechanism found: ' + mech)
else:
# no matches
if redirect:
return self.check1(self.dns_spf(redirect),
- redirect, recursion+1)
+ redirect, recursion + 1)
else:
result = default
@@ -630,7 +663,7 @@ class query(object):
def dns_txt(self, domainname):
"Get a list of TXT records for a domain name."
if domainname:
- return [t for a in self.dns(domainname, 'TXT') for t in a]
+ return [''.join(a) for a in self.dns(domainname, 'TXT')]
return []
def dns_mx(self, domainname):
@@ -672,6 +705,9 @@ class query(object):
pre: qtype in ['A', 'AAAA', 'MX', 'PTR', 'TXT', 'SPF']
post: isinstance(__return__, types.ListType)
"""
+ self.lookups += 1
+ if self.lookups > MAX_LOOKUP:
+ raise PermError('Too many DNS lookups')
result = self.cache.get( (name, qtype) )
cname = None
if not result:
diff --git a/test/missingboundary b/test/missingboundary
new file mode 100644
index 0000000..b59c3e1
--- /dev/null
+++ b/test/missingboundary
@@ -0,0 +1,128 @@
+From leec@windowsshop.com Fri Sep 10 11:48:25 2004
+Message-ID: <4141CDD4.7040305@windowsshop.com>
+Date: Fri, 10 Sep 2004 11:52:52 -0400
+From: Lee Connor