Compare commits

..

73 Commits

Author SHA1 Message Date
cvs2svn 65c73f61c2 This commit was manufactured by cvs2svn to create tag 'milter-0_8_3'.
Sprout from master 2005-10-12 17:21:13 UTC Stuart Gathman <stuart@gathman.org> 'Release 0.8.3'
Cherrypick from bmsi 2005-05-31 18:23:49 UTC Stuart Gathman <stuart@gathman.org> 'Development changes since 0.7.2':
    README
    cid2spf.py
    milter.rc
    milter.rc7
    rejects.py
    rhsbl.m4
    sample.py
    test.py
    test/amazon
    test/big5
    test/bounce
    test/bounce1
    test/bound
    test/honey
    test/missingboundary
    test/samp1
    test/spam44
    test/spam7
    test/spam8
    test/test1
    test/test8
    test/virus1
    test/virus13
    test/virus2
    test/virus3
    test/virus4
    test/virus5
    test/virus6
    test/virus7
    testsample.py
2005-10-12 17:21:14 +00:00
Stuart Gathman a50194d07f Release 0.8.3 2005-10-12 17:21:13 +00:00
Stuart Gathman 1cf272ceb0 Release 0.8.3 2005-10-12 16:45:58 +00:00
Stuart Gathman d2dc09f979 Release 0.8.3 2005-10-12 16:43:14 +00:00
Stuart Gathman ea82d6d608 Release 0.8.3 2005-10-12 16:36:30 +00:00
Stuart Gathman ace3e13685 Always check HELO except for SPF pass, temperror. 2005-10-11 22:50:07 +00:00
Stuart Gathman 78ea2e2263 Use logging module to make logging threadsafe (avoid splitting log lines) 2005-10-10 23:50:20 +00:00
Stuart Gathman d34efa39bb Configure SPF policy via sendmail access file. 2005-10-10 20:15:33 +00:00
Stuart Gathman 36b5b4e6d4 Milter.py moved to Milter subpackage. 2005-10-07 03:25:24 +00:00
Stuart Gathman 04874d6e35 Banned users option. Experimental feature to supply Sender when
missing and MFROM domain doesn't match From.  Log cipher bits for
SMTP AUTH.  Sketch access file feature.
2005-10-07 03:23:40 +00:00
Stuart Gathman 073f87dcc7 Handle perverse MFROM quoting. 2005-09-08 03:55:09 +00:00
Stuart Gathman 7ab5ddf053 Getting ready for 0.8.3 2005-08-18 04:19:26 +00:00
Stuart Gathman d6ef1a4007 Don't innoculate with SCREENED mail. 2005-08-18 03:36:54 +00:00
Stuart Gathman 2a4ab4e87c Send DSN before adding message to quarantine. 2005-08-17 19:35:28 +00:00
Stuart Gathman 241717b0e2 quarantine template 2005-08-16 22:46:33 +00:00
Stuart Gathman bd8fabae0f Example of wiretap with multiple destinations. 2005-08-16 22:46:10 +00:00
Stuart Gathman d119af1a3e Trean non-existant include as no match in "lax" mode. 2005-08-12 17:36:51 +00:00
Stuart Gathman f1f082fe8a Consider SMTP AUTH connections internal. 2005-08-11 22:17:59 +00:00
Stuart Gathman b0286bff22 Treat fail like softfail for selected (braindead) domains.
Treat mail according to extended processing results, but
report any PermError that would officially result via DSN.
2005-08-04 21:21:33 +00:00
Stuart Gathman a9663a23d7 Keep screened honeypot mail, but optionally discard honeypot only mail. 2005-08-02 18:04:36 +00:00
Stuart Gathman 8df5cd026e Limit CNAME chains independently of DNS lookup limit 2005-07-22 16:00:23 +00:00
Stuart Gathman 0cbfc0d249 Limit CNAME lookups. 2005-07-21 17:59:46 +00:00
Stuart Gathman 46ad2794f1 Handle corrupt ZIP attachments 2005-07-20 14:56:38 +00:00
Stuart Gathman 8fef702522 Handle corrupt and empty ZIP files. 2005-07-20 14:49:45 +00:00
Stuart Gathman 62b33bd964 Check pydspam version for honeypot, include latest pyspf changes. 2005-07-20 03:30:04 +00:00
Stuart Gathman ffcadf6c01 Remove debug print 2005-07-18 14:36:23 +00:00
Stuart Gathman 9f7d52118a Release 0.8.2 2005-07-17 01:33:13 +00:00
Stuart Gathman 95b24f7663 Log as well as use extended result for best guess. 2005-07-17 01:25:44 +00:00
Stuart Gathman db0f1095e5 Release 0.8.2 2005-07-15 22:24:10 +00:00
Stuart Gathman f749b6f2cd Support callback exception policy 2005-07-15 22:18:17 +00:00
Stuart Gathman 23485978fc Latest pyspf updates 2005-07-15 22:17:41 +00:00
Stuart Gathman e1f4744a22 Use extended results processing for best_guess. 2005-07-15 20:25:36 +00:00
Stuart Gathman ef413913d0 Allow extended processing for MX count. 2005-07-15 20:00:35 +00:00
Stuart Gathman 8ad4b16156 Import bug fixes from pyspf module. CID xml support removed. 2005-07-14 03:47:40 +00:00
Stuart Gathman b28a56ea37 Make SES package optional. Initial honeypot support. 2005-07-14 03:23:33 +00:00
Stuart Gathman e3b18d61c9 Initial SES integration. 2005-07-06 04:05:40 +00:00
Stuart Gathman 5335e18925 Questions from email answered. 2005-07-04 21:06:31 +00:00
Stuart Gathman e2f1587832 Don't match hostnames for internal connects. 2005-07-02 23:27:31 +00:00
Stuart Gathman febf56b031 Always log trusted Received and Received-SPF headers. 2005-07-01 16:30:24 +00:00
Stuart Gathman e9f6773096 Report context allocation error. 2005-06-24 04:20:07 +00:00
Stuart Gathman 2276762c52 Remove unused name argument to generic wrappers. 2005-06-24 04:12:43 +00:00
Stuart Gathman a142fefb19 Handle close called before connect. 2005-06-24 03:57:35 +00:00
Stuart Gathman 900b7ef3fb Setreply for rejectvirus. 2005-06-20 22:35:35 +00:00
Stuart Gathman d07e536f44 Release 0.8.1 2005-06-17 02:23:34 +00:00
Stuart Gathman 2d291d35f6 Release 0.8.1 2005-06-17 02:07:20 +00:00
Stuart Gathman a94f82d8f3 Handle zip within zip. 2005-06-17 01:49:39 +00:00
Stuart Gathman 124747c309 Update faq. 2005-06-17 00:46:29 +00:00
Stuart Gathman 4c659c7f87 Acknowlege that current env callback protocol is entrenched. 2005-06-17 00:45:10 +00:00
Stuart Gathman 3e47952438 Change more info page back to spf.pobox.com which is now maintained. 2005-06-17 00:38:39 +00:00
Stuart Gathman a01c5d31f1 Ignore HeaderParseError decoding header 2005-06-16 18:35:51 +00:00
Stuart Gathman 493741c81e Return consistent tuple on error. 2005-06-15 19:45:47 +00:00
Stuart Gathman 9a969e8f60 Release 0.8.0 2005-06-14 22:02:46 +00:00
Stuart Gathman 3d7003a638 Web site updates. 2005-06-14 22:02:09 +00:00
Stuart Gathman f643cafc04 Check internal_domains for outgoing mail. 2005-06-14 21:55:30 +00:00
Stuart Gathman 09582a2e86 fix pychecker nits 2005-06-14 20:31:26 +00:00
Stuart Gathman 7eb2fb09ef Properly log exceptions from pydspam 2005-06-06 18:24:59 +00:00
Stuart Gathman 07c56ce667 include DSN templates 2005-06-05 02:44:41 +00:00
Stuart Gathman ecb870acaa Fix bugs from testing RPM 2005-06-04 19:41:17 +00:00
Stuart Gathman e99117e8f6 Organize config reader by section. Create defang section. 2005-06-03 04:57:05 +00:00
Stuart Gathman 0283c20eef Configure banned extensions. Scan zipfile option with test case. 2005-06-02 15:00:17 +00:00
Stuart Gathman bdc6b71845 Update copyright notices after reading article on /. 2005-06-02 04:18:55 +00:00
Stuart Gathman 053734d435 Record timestamp in send_dsn.log 2005-06-02 02:09:00 +00:00
Stuart Gathman 56f1f58be8 Reject on PermErr 2005-06-02 02:08:12 +00:00
Stuart Gathman 5d6ceaefe4 Support configurable templates for DSNs. 2005-06-02 01:00:37 +00:00
Stuart Gathman 1d10bb172f Create Milter package. 2005-05-31 20:39:16 +00:00
Stuart Gathman 8e93d4be38 Move development to sourceforge. 2005-05-31 20:34:40 +00:00
Stuart Gathman ea81a31044 Clear unknown mechanism list at proper time. 2005-05-31 18:57:59 +00:00
Stuart Gathman 2ad3e1cd6e This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:23:49 +00:00
Stuart Gathman b056551e16 This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:10:47 +00:00
Stuart Gathman 6277f05e6a This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:09:06 +00:00
Stuart Gathman 19ad88b6b2 This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:08:20 +00:00
Stuart Gathman e688112eed This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:07:19 +00:00
Stuart Gathman c510c4576f This commit was generated by cvs2svn to track changes on a CVS vendor
branch.
2005-05-31 18:04:05 +00:00
28 changed files with 1720 additions and 683 deletions
+6
View File
@@ -9,6 +9,9 @@ Other contributors:
Terence Way
for providing a Python port of SPF
Scott Kitterman
for doing lots of testing and debugging of SPF against draft standard,
and for putting up a web page that validates SPF records using spf.py
Alexander Kourakos
for plugging several memory leaks
George Graf at Vienna University of Economics and Business Administration
@@ -22,6 +25,9 @@ John Draper
then pointing out that it would be easier to just write the MTA in Python.
Eric S. Johansson
for helpful design discussions while working on camram
Alex Savguira
for finding bugs with international headers and
suggesting the scan_zip option.
Business Management Systems - http://www.bmsi.com
for hosting the website, and providing paying clients who need milter service
so I can work on it as part of my day job.
+136
View File
@@ -0,0 +1,136 @@
Step one. Which DSPAM is right for you?
The DSPAM project makes dspam part of the LDA (Local Delivery Agent).
Pydspam puts dspam into the MTA (Mail Transfer Agent - sendmail with pymilter).
The advantage of doing dspam in the LDA is that any aliasing has already been
resolved. You need only configure mailboxes.
The advantage of doing dspam in the MTA is it can screen an entire
company as a gateway with multiple domains. Unfortunately, this
means you have to tell it about all the aliases that comprise each
account. (Also, pydspam is still uses dspam-2.6.5.2 - the Dspam API
has changed for newer versions.)
If the LDA is right for you, you'll want to use the official Dspam
package. http://www.nuclearelephant.com/projects/dspam/
If the MTA approach is what you want, then pydspam is what you want.
In either case, you will still want pymilter to block forgeries, Windows
executables, etc.
So, lets assume you want to install pymilter, and may or may not
wish to install pydspam.
Step two. Obtaining RPMS.
For basic pymilter you'll need:
python-2.4
milter-0.8.2 (the RH9 rpm should work on Fedora Core - let me know)
sendmail-8.13.x (with milter support enabled)
and for SPF you'll need:
pydns-2.3.0-2.4
and for SRS you'll need:
pysrs-0.30.9-1.py24
I'm pretty sure you will want to have SPF and SRS available.
Step three. Activate basic milter.
Activate the basic milter by editing /etc/mail/sendmail.mc and adding:
INPUT_MAIL_FILTER(`pythonfilter', `S=local:/var/run/milter/pythonsock, F=T, T=C:5m;S:20s;R:5m;E:5m')
You can then "make sendmail.cf" and restart sendmail.
Tail /var/log/milter/milter.log while SMTP clients connect to your
sendmail instance. This should show you what the milter is doing.
By default, milter-0.8.2 rejects on SPF fail, except for listed domains
(that are known to be broken). Some admins don't like that, and 0.8.3 will use
the /etc/mail/access database to configure SPF responses. For now,
if you don't like SPF, you can disable spf by replacing "import spf"
with "spf = None" around line 285 in /var/log/milter/bms.py.
Step four. Tweaking the basic config.
Most pymilter configuration is in /etc/mail/pymilter.cfg.
By default, milter scans attachments for executable extensions. You can
turn this off by setting banned_exts to the empty list. There are options
to scan ZIP attachments and rfc822 attachments. When it finds a banned
file type, milter saves the original message in /var/log/milter/save,
and replaces the attachment with a plain text warning message.
Configure hello_blacklist with your own helo name and domains - which
you know cannot legitimately be used by external MTAs.
Configure trusted_relay with your secondary MX servers, if any. These
should also run pymilter with similar policies. (But this isn't
needed for initial testing.)
Configure internal_connect with subnets of your internal SMTP clients.
Internal connections skip SPF testing and other policies.
Configure internal_domains with domains used by your internal SMTP clients.
If they attempt to use any other domain, the attempt is blocked and the
client is logged as a "zombie". Conversely, any attempt by an external
MTA to use one of your internal domains is treated as a forgery and
blocked (a simplified form of local SPF).
Adjust porn_words and spam_words - these block emails with a Subject
containing the listed strings. They can be empty to disable Subject
string blocking.
Advanced SPF configuration.
The sendmail access file, or another readonly database with that
format, can be used for detail spf policy. SPF access policy
record are tagged with "SPF-{Result}:". Results are
Pass, Neutral, Softfail, Fail, PermError. Currently supported
policy keywords are OK, CBV, REJECT. Currently, TempError always
results in TEMPFAIL.
The default policies are set in pymilter.cfg. The defaults
if none of the config options are set are as follows:
SPF-Fail: REJECT
SPF-Softfail: CBV
SPF-Neutral: OK
SPF-PermError: REJECT
SPF-Pass: OK
The tag may be followed by a specific domain. For instance, to
require a Pass from aol.com:
SPF-Neutral:aol.com REJECT
SPF-Softfail:aol.com REJECT
The CBV policy requires a valid HELO name. If the EHLO name is
RFC2822 compliant, then a DSN is sent to the alleged sender. The
template for the DSN is selected according to the SPF result:
Fail: softfail.txt
SoftFail: softfail.txt
Neutral: neutral.txt
PermError: permerror.txt
None: strike3.txt
An SPF-Pass is always accepted by the milter. Domains can be blacklisted
via sendmail in the access file or via a RHS DNS blacklist.
To be continued.
Forthcoming topics:
SRS config
pydspam config
wiretap config
+4 -2
View File
@@ -1,6 +1,7 @@
include COPYING
include TODO
include NEWS
include HOWTO
include CREDITS
include README
include MANIFEST.in
@@ -11,6 +12,7 @@ include testdspam.py
include rejects.py
include bms.py
include spf.py
include cid2spf.py
include spfquery.py
include test.py
include sample.py
@@ -22,5 +24,5 @@ include milter.rc
include milter.rc7
include milter.cfg
include rhsbl.m4
include softfail.txt
include strike3.txt
include *.txt
include *.html
-203
View File
@@ -1,203 +0,0 @@
# Author: Stuart D. Gathman <stuart@bmsi.com>
# Copyright 2001 Business Management Systems, Inc.
# This code is under GPL. See COPYING for details.
import os
import milter
import thread
from milter import ACCEPT,CONTINUE,REJECT,DISCARD,TEMPFAIL, \
set_flags, setdbg, setbacklog, settimeout, \
ADDHDRS, CHGBODY, ADDRCPT, DELRCPT, CHGHDRS, \
V1_ACTS, V2_ACTS, CURR_ACTS
try: from milter import QUARANTINE
except: pass
_seq_lock = thread.allocate_lock()
_seq = 0
def uniqueID():
"""Return a sequence number unique to this process.
"""
global _seq
_seq_lock.acquire()
seqno = _seq = _seq + 1
_seq_lock.release()
return seqno
class Milter:
"""A simple class interface to the milter module.
"""
def _setctx(self,ctx):
self.__ctx = ctx
if ctx:
ctx.setpriv(self)
# user replaceable callbacks
def log(self,*msg):
print 'Milter:',
for i in msg: print i,
print
def connect(self,hostname,unused,hostaddr):
"Called for each connection to sendmail."
self.log("connect from %s at %s" % (hostname,hostaddr))
return CONTINUE
def hello(self,hostname):
"Called after the HELO command."
self.log("hello from %s" % hostname)
return CONTINUE
def envfrom(self,f,*str):
"""Called to begin each message.
f -> string message sender
str -> tuple additional ESMTP parameters
"""
self.log("mail from",f,str)
return CONTINUE
def envrcpt(self,to,*str):
"Called for each message recipient."
self.log("rcpt to",to,str)
return CONTINUE
def header(self,field,value):
"Called for each message header."
self.log("%s: %s" % (field,value))
return CONTINUE
def eoh(self):
"Called after all headers are processed."
self.log("eoh")
return CONTINUE
def body(self,unused):
"Called to transfer the message body."
return CONTINUE
def eom(self):
"Called at the end of message."
self.log("eom")
return CONTINUE
def abort(self):
"Called if the connection is terminated abnormally."
self.log("abort")
return CONTINUE
def close(self):
"Called at the end of connection, even if aborted."
self.log("close")
return CONTINUE
# Milter methods which can be invoked from callbacks
def getsymval(self,sym):
return self.__ctx.getsymval(sym)
# If sendmail does not support setmlreply, then only the
# first msg line is used.
def setreply(self,rcode,xcode=None,msg=None,*ml):
return self.__ctx.setreply(rcode,xcode,msg,*ml)
# Milter methods which can only be called from eom callback.
def addheader(self,field,value):
return self.__ctx.addheader(field,value)
def chgheader(self,field,idx,value):
return self.__ctx.chgheader(field,idx,value)
def addrcpt(self,rcpt):
return self.__ctx.addrcpt(rcpt)
def delrcpt(self,rcpt):
return self.__ctx.delrcpt(rcpt)
def replacebody(self,body):
return self.__ctx.replacebody(body)
# When quarantined, a message goes into the mailq as if to be delivered,
# but delivery is deferred until the message is unquarantined.
def quarantine(self,reason):
return self.__ctx.quarantine(reason)
def progress(self):
return self.__ctx.progress()
factory = Milter
def connectcallback(ctx,hostname,family,hostaddr):
m = factory()
m._setctx(ctx)
return m.connect(hostname,family,hostaddr)
def closecallback(ctx):
m = ctx.getpriv()
if not m: return CONTINUE
rc = m.close()
m._setctx(None) # release milterContext
return rc
def envcallback(c,args):
"""Convert ESMTP parms to keyword parameters.
Can be used in the envfrom and/or envrcpt callbacks to process
ESMTP parameters as python keyword parameters."""
kw = {}
for s in args[1:]:
pos = s.find('=')
if pos > 0:
kw[s[:pos]] = s[pos+1:]
return apply(c,args,kw)
def runmilter(name,socketname,timeout = 0):
# This bit is here on the assumption that you will be starting this filter
# before sendmail. If sendmail is not running and the socket already exists,
# libmilter will throw a warning. If sendmail is running, this is still
# safe if there are no messages currently being processed. It's safer to
# shutdown sendmail, kill the filter process, restart the filter, and then
# restart sendmail.
pos = socketname.find(':')
if pos > 1:
s = socketname[:pos]
fname = socketname[pos+1:]
else:
s = "unix"
fname = socketname
if s == "unix" or s == "local":
print "Removing %s" % fname
try:
os.unlink(fname)
except:
pass
# The default flags set include everything
# milter.set_flags(milter.ADDHDRS)
milter.set_connect_callback(connectcallback)
milter.set_helo_callback(lambda ctx, host: ctx.getpriv().hello(host))
milter.set_envfrom_callback(lambda ctx,*str:
ctx.getpriv().envfrom(*str))
# envcallback(ctx.getpriv().envfrom,str))
milter.set_envrcpt_callback(lambda ctx,*str:
ctx.getpriv().envrcpt(*str))
# envcallback(ctx.getpriv().envrcpt,str))
milter.set_header_callback(lambda ctx,fld,val:
ctx.getpriv().header(fld,val))
milter.set_eoh_callback(lambda ctx: ctx.getpriv().eoh())
milter.set_body_callback(lambda ctx,chunk: ctx.getpriv().body(chunk))
milter.set_eom_callback(lambda ctx: ctx.getpriv().eom())
milter.set_abort_callback(lambda ctx: ctx.getpriv().abort())
milter.set_close_callback(closecallback)
milter.setconn(socketname)
if timeout > 0: milter.settimeout(timeout)
# The name *must* match the X line in sendmail.cf (supposedly)
milter.register(name)
start_seq = _seq
try:
milter.main()
except milter.error:
if start_seq == _seq: raise # couldn't start
# milter has been running for a while, but now it can't start new threads
raise milter.error("out of thread resources")
+3 -1
View File
@@ -16,6 +16,8 @@ from milter import ACCEPT,CONTINUE,REJECT,DISCARD,TEMPFAIL, \
try: from milter import QUARANTINE
except: pass
__version__ = '0.8.3'
_seq_lock = thread.allocate_lock()
_seq = 0
@@ -215,6 +217,6 @@ def runmilter(name,socketname,timeout = 0):
raise milter.error("out of thread resources")
__all__ = globals().copy()
for priv in ('os','milter','thread','factory','_seq','_seq_lock'):
for priv in ('os','milter','thread','factory','_seq','_seq_lock','__version__'):
del __all__[priv]
__all__ = __all__.keys()
+14 -7
View File
@@ -9,6 +9,7 @@ import smtplib
import spf
import socket
from email.Message import Message
import Milter
nospf_msg = """Subject: Critical mail server configuration error
@@ -103,7 +104,7 @@ def send_dsn(mailfrom,receiver,msg=None):
q = spf.query(None,None,None)
mxlist = q.dns(domain,'MX')
if not mxlist:
mxlist = (0,domain),
mxlist = (0,domain), # fallback to A record when no MX
else:
mxlist.sort()
smtp = smtplib.SMTP()
@@ -112,8 +113,11 @@ def send_dsn(mailfrom,receiver,msg=None):
smtp.connect(host)
code,resp = smtp.helo(receiver)
# some wiley spammers have MX records that resolve to 127.0.0.1
if resp.split()[0] == receiver:
return (553,'Fraudulent MX for %s' % domain)
a = resp.split()
if not a:
return (553,'MX for %s has no hostname in banner: %s' % (domain,host))
if a[0] == receiver:
return (553,'Fraudulent MX for %s: %s' % (domain,host))
if not (200 <= code <= 299):
raise smtplib.SMTPHeloError(code, resp)
if msg:
@@ -151,13 +155,13 @@ def create_msg(q,rcptlist,origmsg=None,template=None):
connectip = q.i
receiver = q.r
sender_domain = q.o
result = q.result
perm_error = q.perm_error
rcpt = '\n\t'.join(rcptlist)
try: subject = origmsg['Subject']
except: subject = '(none)'
try:
spf_result = origmsg['Received-SPF']
if not spf_result.startswith('softfail'):
spf_result = None
except: spf_result = None
msg = Message()
@@ -165,11 +169,14 @@ def create_msg(q,rcptlist,origmsg=None,template=None):
msg.add_header('To',sender)
msg.add_header('From','postmaster@%s'%receiver)
msg.add_header('Auto-Submitted','auto-generated (configuration error)')
msg.add_header('X-Mailer','PyMilter-'+Milter.__version__)
msg.set_type('text/plain')
if not template:
if spf_result: template = softfail_msg
else: template = nospf_msg
if spf_result and spf_result.startswith('softfail'):
template = softfail_msg
else:
template = nospf_msg
hdrs,body = template.split('\n',1)
for ln in hdrs.splitlines():
name,val = ln.split(':',1)
+17
View File
@@ -1,5 +1,22 @@
Here is a history of user visible changes to Python milter.
0.8.3 Keep screened honeypot mail, but optionally discard honeypot only mail.
spf_accept_fail option for braindead SPF senders
(treats fail like softfail)
Option to set SPF policy via sendmail access map.
Option to supply Sender header from MAIL FROM when missing.
Consider SMTP AUTH connections internal.
Send DSN for SPF errors corrected by extended processing.
Send DSN before SCREENED mail is quarantined
Use logging package to keep log lines atomic.
0.8.2 Strict processing limits per SPF RFC
Fixed several parsing bugs under RFC
Support official IANA SPF record (type99)
Honeypot support (requires pydspam-1.1.9)
Extended SPF processing results beyond strict RFC limits
Support original SES for bounce protection (requires pysrs-0.30.10)
Callback exception processing option in milter module
Handle corrupt ZIP attachments
0.8.1 Fix zip in zip loop in mime.py
Fix HeaderParseError in bms.py header callback
Check internal_domains for outgoing mail
+12 -29
View File
@@ -1,41 +1,32 @@
Find rfc2822 policy for MFROM quoting.
Use /etc/mail/access for domain specific SPF policies.
SPF-Fail: REJECT
SPF-Softfail: OK
SPF-Neutral: OK
SPF-Neutral:aol.com ERROR:"550 AOL mail must get SPF PASS"
Defer TEMPERROR in SPF evaluation - give precedence to security
(only defer for PASS mechanisms).
Option to add Received-SPF header, but never reject on SPF.
I think the above will handle this.
Create null config that does nothing - except maybe add Received-SPF
headers. Many admins would like to turn features on one at a time.
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.
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
Support SMTP AUTH and disable SPF checks when connection is authorized.
Web admin interface
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.
message log for automated stats and blacklisting
Skip dspam when SPF pass?
Skip dspam when SPF pass? NO
Report 551 with rcpt on SPF fail?
check spam keywords with character classes, e.g.
{a}=[a@ãä], {i}=[i1í], {e}=[eë], {o}=[o0ö]
@@ -46,9 +37,6 @@ user to give to the forwarder. Alias only works for mail from that
forwarder. Milter gets forwarder domain from alias and uses it to
SPF check forwarder.
Another special dspam user, 'honeypot', can be listed in innoculations.
All email to those addresses is treated as known spam.
Framework for modular Python milter components within a single VM.
Python milters can be already be composed through sendmail by running each in
a separate process. However, a significant amount of memory is wasted
@@ -57,8 +45,7 @@ is cumbersome (e.g., adding mail headers, writing external files).
Backup copies for outgoing/incoming mail.
Allow multiple wiretap groups, each with its own destination. Perhaps
also copy incoming wiretap mail, even though sendmail alias works perfectly
Copy incoming wiretap mail, even though sendmail alias works perfectly
for the purpose, to avoid having to change two configs for a wiretap.
Provide a way to reload milter.cfg without stopping/restarting milter.
@@ -72,10 +59,6 @@ Keep separate ismodified flag for headers and body. This is important
when rejecting outgoing mail with viruses removed (so as not to
embarrass yourself), and also removing Received headers with hidepath.
Wrap smfi_setbacklog(int) - but it is only available in sendmail >= 8.12.3,
so how can we detect whether to wrap it?
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.
+454 -144
View File
@@ -1,6 +1,67 @@
#!/usr/bin/env python
# A simple milter that has grown quite a bit.
# $Log$
# Revision 1.29 2005/10/11 22:50:07 customdesigned
# Always check HELO except for SPF pass, temperror.
#
# Revision 1.28 2005/10/10 23:50:20 customdesigned
# Use logging module to make logging threadsafe (avoid splitting log lines)
#
# Revision 1.27 2005/10/10 20:15:33 customdesigned
# Configure SPF policy via sendmail access file.
#
# Revision 1.26 2005/10/07 03:23:40 customdesigned
# Banned users option. Experimental feature to supply Sender when
# missing and MFROM domain doesn't match From. Log cipher bits for
# SMTP AUTH. Sketch access file feature.
#
# Revision 1.25 2005/09/08 03:55:08 customdesigned
# Handle perverse MFROM quoting.
#
# Revision 1.24 2005/08/18 03:36:54 customdesigned
# Don't innoculate with SCREENED mail.
#
# Revision 1.23 2005/08/17 19:35:27 customdesigned
# Send DSN before adding message to quarantine.
#
# Revision 1.22 2005/08/11 22:17:58 customdesigned
# Consider SMTP AUTH connections internal.
#
# Revision 1.21 2005/08/04 21:21:31 customdesigned
# Treat fail like softfail for selected (braindead) domains.
# Treat mail according to extended processing results, but
# report any PermError that would officially result via DSN.
#
# Revision 1.20 2005/08/02 18:04:35 customdesigned
# Keep screened honeypot mail, but optionally discard honeypot only mail.
#
# Revision 1.19 2005/07/20 03:30:04 customdesigned
# Check pydspam version for honeypot, include latest pyspf changes.
#
# Revision 1.18 2005/07/17 01:25:44 customdesigned
# Log as well as use extended result for best guess.
#
# Revision 1.17 2005/07/15 20:25:36 customdesigned
# Use extended results processing for best_guess.
#
# Revision 1.16 2005/07/14 03:23:33 customdesigned
# Make SES package optional. Initial honeypot support.
#
# Revision 1.15 2005/07/06 04:05:40 customdesigned
# Initial SES integration.
#
# Revision 1.14 2005/07/02 23:27:31 customdesigned
# Don't match hostnames for internal connects.
#
# Revision 1.13 2005/07/01 16:30:24 customdesigned
# Always log trusted Received and Received-SPF headers.
#
# Revision 1.12 2005/06/20 22:35:35 customdesigned
# Setreply for rejectvirus.
#
# Revision 1.11 2005/06/17 02:07:20 customdesigned
# Release 0.8.1
#
# Revision 1.10 2005/06/16 18:35:51 customdesigned
# Ignore HeaderParseError decoding header
#
@@ -222,6 +283,7 @@ import traceback
import ConfigParser
import time
import re
import anydbm
import Milter.dsn as dsn
from Milter.dynip import is_dynip as dynip
@@ -233,14 +295,16 @@ try:
import SRS
srsre = re.compile(r'^SRS[01][+-=]',re.IGNORECASE)
except: SRS = None
try:
import SES
except: SES = None
# Import spf if available
try: import spf
except: spf = None
ip4re = re.compile(r'^[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*\.[1-9][0-9]*$')
#import syslog
#syslog.openlog('milter')
import logging
# Thanks to Chris Liechti for config parsing suggestions
@@ -265,6 +329,7 @@ scan_rfc822 = True
internal_connect = ()
trusted_relay = ()
internal_domains = ()
banned_users = ()
hello_blacklist = ()
smart_alias = {}
dspam_dict = None
@@ -277,18 +342,29 @@ dspam_internal = True # True if internal mail should be dspammed
dspam_reject = ()
dspam_sizelimit = 180000
srs = None
ses = None
srs_reject_spoofed = False
srs_fwdomain = None
srs_domain = None
spf_reject_neutral = ()
spf_accept_softfail = ()
spf_accept_fail = ()
spf_best_guess = False
spf_reject_noptr = False
multiple_bounce_recipients = True
supply_sender = False
access_file = None
time_format = '%Y%b%d %H:%M:%S %Z'
timeout = 600
cbv_cache = {}
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO,
format='%(asctime)s %(message)s',
datefmt='%Y%b%d %H:%M:%S'
)
milter_log = logging.getLogger('milter')
try:
too_old = time.time() - 30*24*60*60 # 30 days
too_old = time.time() - 7*24*60*60 # 7 days
for ln in open('send_dsn.log'):
try:
rcpt,ts = ln.strip().split(None,1)
@@ -360,6 +436,7 @@ def read_config(list):
'hashlength': '8',
'reject_spoofed': 'no',
'reject_noptr': 'no',
'supply_sender': 'no',
'best_guess': 'no',
'dspam_internal': 'yes'
})
@@ -413,7 +490,7 @@ def read_config(list):
for sa in cp.getlist('wiretap','smart_alias'):
sm = cp.getlist('wiretap',sa)
if len(sm) < 2:
print 'malformed smart alias:',sa
milter_log.warning('malformed smart alias: %s',sa)
continue
if len(sm) == 2: sm.append(sa)
key = (sm[0],sm[1])
@@ -435,18 +512,21 @@ def read_config(list):
# spf section
global spf_reject_neutral,spf_best_guess,SRS,spf_reject_noptr
global spf_accept_softfail
global spf_accept_softfail,spf_accept_fail,supply_sender,access_file
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_accept_fail = cp.getlist('spf','accept_fail')
spf_best_guess = cp.getboolean('spf','best_guess')
spf_reject_noptr = cp.getboolean('spf','reject_noptr')
supply_sender = cp.getboolean('spf','supply_sender')
access_file = cp.getdefault('spf','access_file')
srs_config = cp.getdefault('srs','config')
if srs_config: cp.read([srs_config])
srs_secret = cp.getdefault('srs','secret')
if SRS and srs_secret:
global srs,srs_reject_spoofed,srs_fwdomain
global ses,srs,srs_reject_spoofed,srs_domain,banned_users
database = cp.getdefault('srs','database')
srs_reject_spoofed = cp.getboolean('srs','reject_spoofed')
maxage = cp.getint('srs','maxage')
@@ -459,13 +539,37 @@ def read_config(list):
else:
srs = SRS.Guarded.Guarded(secret=srs_secret,
maxage=maxage,hashlength=hashlength,separator=separator)
srs_fwdomain = cp.getdefault('srs','fwdomain')
if SES:
ses = SES.new(secret=srs_secret,expiration=maxage)
srs_domain = cp.getlist('srs','ses')
else:
srs_domain = []
srs_domain.append(cp.getdefault('srs','fwdomain'))
banned_users = cp.getlist('srs','banned_users')
#print srs_domain
def parse_addr(t):
"""Split email into user,domain.
>>> parse_addr('user@example.com')
['user', 'example.com']
>>> parse_addr('"user@example.com"')
['user@example.com']
>>> parse_addr('"user@bar"@example.com')
['user@bar', 'example.com']
>>> parse_addr('foo')
['foo']
"""
if t.startswith('<') and t.endswith('>'): t = t[1:-1]
if t.startswith('"'):
if t.endswith('"'): return [t[1:-1]]
pos = t.find('"@')
if pos > 0: return [t[1:pos],t[pos+2:]]
return t.split('@')
def parse_header(val):
"""Decode headers gratuitously encoded to hide the content.
"""
try:
h = decode_header(val)
if not len(h) or (not h[0][1] and len(h) == 1): return val
@@ -485,18 +589,86 @@ def parse_header(val):
except UnicodeError: continue
except UnicodeDecodeError: pass
except LookupError: pass
except email.errors.HeaderParseError: pass
except email.Errors.HeaderParseError: pass
return val
class SPFPolicy(object):
"Get SPF policy by result, defaulting to classic policy from pymilter.cfg"
def __init__(self,domain):
self.domain = domain.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.domain]
except KeyError:
try:
return acf[pfx]
except KeyError:
return None
def getFailPolicy(self):
policy = self.getPolicy('SPF-Fail:')
if not policy:
if self.domain in spf_accept_fail:
policy = 'CBV'
else:
policy = 'REJECT'
return policy
def getNonePolicy(self):
policy = self.getPolicy('SPF-None:')
if not policy:
if spf_reject_noptr:
policy = 'REJECT'
else:
policy = 'CBV'
return policy
def getSoftfailPolicy(self):
policy = self.getPolicy('SPF-Softfail:')
if not policy:
if self.domain in spf_accept_softfail:
policy = 'OK'
elif self.domain in spf_reject_neutral:
policy = 'REJECT'
else:
policy = 'CBV'
return policy
def getNeutralPolicy(self):
policy = self.getPolicy('SPF-Neutral:')
if not policy:
if self.domain in spf_reject_neutral:
policy = 'REJECT'
policy = 'OK'
return policy
def getPermErrorPolicy(self):
policy = self.getPolicy('SPF-PermError:')
if not policy:
policy = 'REJECT'
return policy
def getPassPolicy(self):
policy = self.getPolicy('SPF-Pass:')
if not policy:
policy = 'OK'
return policy
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
and smart alias redirection."""
def log(self,*msg):
print "%s [%d]" % (time.strftime('%Y%b%d %H:%M:%S'),self.id),
for i in msg: print i,
print
milter_log.info('[%d] %s',self.id,' '.join([str(m) for m in msg]))
def __init__(self):
self.tempname = None
@@ -543,10 +715,6 @@ class bmsMilter(Milter.Milter):
else: ipaddr = ''
self.connectip = ipaddr
self.missing_ptr = dynip(hostname,self.connectip)
for pat in internal_connect:
if fnmatchcase(hostname,pat):
self.internal_connection = True
break
if self.internal_connection:
connecttype = 'INTERNAL'
else:
@@ -619,8 +787,41 @@ class bmsMilter(Milter.Milter):
self.new_headers = []
self.recipients = []
self.cbv_needed = None
t = parse_addr(f.lower())
t = parse_addr(f)
if len(t) == 2: t[1] = t[1].lower()
self.canon_from = '@'.join(t)
# Some braindead MTAs can't be relied upon to properly flag DSNs.
# This heuristic tries to recognize such.
self.is_bounce = (f == '<>' or t[0].lower() in banned_users
#and t[1] == self.hello_name
)
# Check SMTP AUTH, also available:
# auth_authen authenticated user
# auth_author (ESMTP AUTH= param)
# auth_ssf (connection security, 0 = unencrypted)
# auth_type (authentication method, CRAM-MD5, DIGEST-MD5, PLAIN, etc)
# cipher_bits SSL encryption strength
# cert_subject SSL cert subject
# verify SSL cert verified
self.user = self.getsymval('{auth_authen}')
if self.user:
# Very simple SMTP AUTH policy by defaul:
# any successful authentication is considered INTERNAL
# FIXME: configure allowed MAIL FROM by user
self.internal_connection = True
self.log(
"SMTP AUTH:",self.user, self.getsymval('{auth_type}'),
"sslbits =",self.getsymval('{cipher_bits}'),
"ssf =",self.getsymval('{auth_ssf}'), "INTERNAL"
)
if self.getsymval('{verify}'):
self.log("SSL AUTH:",
self.getsymval('{cert_subject}'),
"verify =",self.getsymval('{verify}')
)
self.fp.write('From %s %s\n' % (self.canon_from,time.ctime()))
if len(t) == 2:
user,domain = t
@@ -636,7 +837,7 @@ class bmsMilter(Milter.Milter):
else:
self.log("REJECT: zombie PC at ",self.connectip," sending MAIL FROM ",
self.canon_from)
self.setreply('550','5.7.1','Get rid of your virus!',
self.setreply('550','5.7.1',
'Your PC is using an unauthorized MAIL FROM.',
'It is either badly misconfigured or controlled by organized crime.'
)
@@ -659,18 +860,25 @@ class bmsMilter(Milter.Milter):
if not (self.internal_connection or self.trusted_relay) \
and self.connectip and spf:
return self.check_spf()
self.spf = None
return Milter.CONTINUE
def check_spf(self):
t = parse_addr(self.mailfrom)
if len(t) == 2: t[1] = t[1].lower()
receiver = self.receiver
q = spf.query(self.connectip,'@'.join(t),self.hello_name,receiver=receiver)
q.set_default_explanation('SPF fail: see http://spf.pobox.com/why.html')
q = spf.query(self.connectip,self.canon_from,self.hello_name,
receiver=receiver,strict=False)
q.set_default_explanation(
'SPF fail: see http://openspf.com/why.html?sender=%s&ip=%s' % (q.s,q.i))
res,code,txt = q.check()
if res in ('none', 'softfail'):
q.result = res
if res in ('unknown','permerror') and q.perm_error and q.perm_error.ext:
self.cbv_needed = q # report SPF syntax error to sender
res,code,txt = q.perm_error.ext # extended (lax processing) result
txt = 'EXT: ' + txt
p = SPFPolicy(q.o)
if res not in ('pass','error','temperror'):
if self.mailfrom != '<>':
# check hello name via spf
# check hello name via spf unless spf pass
h = spf.query(self.connectip,'',self.hello_name,receiver=receiver)
hres,hcode,htxt = h.check()
if hres in ('deny','fail','neutral','softfail'):
@@ -686,6 +894,7 @@ class bmsMilter(Milter.Milter):
and not dynip(self.hello_name,self.connectip):
hres,hcode,htxt = h.best_guess()
else: hres = res
ores = res
if spf_best_guess and res == 'none':
#self.log('SPF: no record published, guessing')
q.set_default_explanation(
@@ -697,62 +906,87 @@ class bmsMilter(Milter.Milter):
else:
res,code,txt = q.best_guess()
receiver += ': guessing'
if self.missing_ptr and res in ('neutral', 'none') and hres != 'pass':
if spf_reject_noptr:
if q.perm_error: # FIXME: should never happen?
res,code,txt = q.perm_error.ext # extended result
txt = 'EXT: ' + txt
if self.missing_ptr and ores == 'none' and res != 'pass' \
and hres != 'pass':
policy = p.getNonePolicy()
if policy == 'CBV':
if self.mailfrom != '<>':
q.result = ores
self.cbv_needed = q # accept, but inform sender via DSN
elif policy != 'OK':
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',
'Contact your mail administrator IMMEDIATELY! Your mail server is',
'severely misconfigured. It has no PTR record (dynamic PTR records',
"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 self.mailfrom != '<>':
q.result = res
self.cbv_needed = q
if res in ('deny', '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 == 'softfail' and not q.o in spf_accept_softfail:
if self.missing_ptr and hres != 'pass':
if spf_reject_noptr or q.o in spf_reject_neutral:
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
self.setreply('550','5.7.1',
'SPF softfail: 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.REJECT
if self.mailfrom != '<>':
q.result = res
self.cbv_needed = q
if res == 'neutral' and q.o in spf_reject_neutral:
self.log('REJECT: SPF neutral for',q.s)
self.setreply('550','5.7.1',
'mail from %s must pass SPF: http://spf.pobox.com/why.html' % q.o,
'The %s domain is one that spammers love to forge. Due to' % q.o,
'the volume of forged mail, we can only accept mail that',
'the SPF record for %s explicitly designates as legitimate.' % q.o,
'Sending your email through the recommended outgoing SMTP',
'servers for %s should accomplish this.' % q.o
)
return Milter.REJECT
if res == 'error':
if code >= 500:
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)
policy = p.getFailPolicy()
if hres == 'pass' and policy == 'CBV':
if self.mailfrom != '<>':
self.cbv_needed = q
elif policy != 'OK':
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.getSoftfailPolicy()
if policy == 'CBV' and hres == 'pass':
if self.mailfrom != '<>':
self.cbv_needed = q
elif policy != 'OK':
self.log('REJECT: SPF %s %i %s' % (res,code,txt))
self.setreply('550','5.7.1',
'SPF softfail: 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.REJECT
if res == 'neutral' and q.o in spf_reject_neutral:
policy = p.getNeutralPolicy()
if policy == 'CBV' and hres == 'pass':
if self.mailfrom != '<>':
self.cbv_needed = q
elif policy != 'OK':
self.log('REJECT: SPF neutral for',q.s)
self.setreply('550','5.7.1',
'mail from %s must pass SPF: http://spf.pobox.com/why.html' % q.o,
'The %s domain is one that spammers love to forge. Due to' % q.o,
'the volume of forged mail, we can only accept mail that',
'the SPF record for %s explicitly designates as legitimate.' % q.o,
'Sending your email through the recommended outgoing SMTP',
'servers for %s should accomplish this.' % q.o
)
return Milter.REJECT
if res in ('unknown','permerror'):
policy = p.getPermErrorPolicy()
if policy == 'CBV' and hres == 'pass':
if self.mailfrom != '<>':
self.cbv_needed = q
elif policy != 'OK':
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 in ('error','temperror'):
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))
self.spf = q
return Milter.CONTINUE
# hide_path causes a copy of the message to be saved - until we
@@ -767,23 +1001,31 @@ class bmsMilter(Milter.Milter):
t = parse_addr(to.lower())
if len(t) == 2:
user,domain = t
if self.mailfrom == '<>' or self.canon_from.startswith('postmaster@') \
or self.canon_from.startswith('mailer-daemon@'):
if self.recipients and not multiple_bounce_recipients:
self.data_allowed = False
if srs and domain == srs_fwdomain:
oldaddr = '@'.join(parse_addr(to))
try:
if self.is_bounce and srs and domain in srs_domain:
oldaddr = '@'.join(parse_addr(to))
try:
if ses:
newaddr = ses.verify(oldaddr)
else:
newaddr = oldaddr,
if len(newaddr) > 1:
self.log("ses rcpt:",newaddr[0])
else:
newaddr = srs.reverse(oldaddr)
# Currently, a sendmail map reverses SRS. We just log it here.
self.log("srs rcpt:",newaddr)
except:
if not (self.internal_connection or self.trusted_relay):
if srsre.match(oldaddr):
self.log("REJECT: srs spoofed:",oldaddr)
self.setreply('550','5.7.1','Invalid SRS signature')
return Milter.REJECT
self.data_allowed = not srs_reject_spoofed
except:
if not (self.internal_connection or self.trusted_relay):
if srsre.match(oldaddr):
self.log("REJECT: srs spoofed:",oldaddr)
self.setreply('550','5.7.1','Invalid SRS signature')
return Milter.REJECT
if oldaddr.startswith('SES='):
self.log("REJECT: ses spoofed:",oldaddr)
self.setreply('550','5.7.1','Invalid SES signature')
return Milter.REJECT
self.data_allowed = not srs_reject_spoofed
# non DSN mail to SRS address will bounce due to invalid local part
self.recipients.append('@'.join(t))
users = check_user.get(domain)
@@ -858,24 +1100,23 @@ class bmsMilter(Milter.Milter):
or mailer.find('optin') >= 0:
self.log('REJECT: %s: %s' % (name,val))
return Milter.REJECT
elif self.trust_received and lname == 'received':
self.trust_received = False
self.log('%s: %s' % (name,val.splitlines()[0]))
elif self.trust_spf and lname == 'received-spf':
self.trust_spf = False
self.log('%s: %s' % (name,val.splitlines()[0]))
return Milter.CONTINUE
def forged_bounce(self):
if len(self.recipients) > 1:
self.log('REJECT: Multiple bounce recipients')
self.setreply('550','5.7.1','Multiple bounce recipients')
if self.mailfrom != '<>':
self.log("REJECT: bogus DSN")
self.setreply('550','5.7.1',
"I do not accept mail from postmaster, mailer-daemon, or clamav.",
"All such mail has turned out to be Delivery Status Notifications",
"which failed to be marked as such. Please send a real DSN if",
"you need to. Use another MAIL FROM if you need to send me mail."
)
else:
self.log('REJECT: bounce with no SRS encoding')
self.setreply('550','5.7.1',
"I did not send you that message. Please consider implementing SPF",
"(http://spf.pobox.com) to avoid bouncing mail to spoofed senders.",
"Thank you."
"I did not send you that message. Please consider implementing SPF",
"(http://openspf.com) to avoid bouncing mail to spoofed senders.",
"Thank you."
)
return Milter.REJECT
@@ -898,6 +1139,12 @@ class bmsMilter(Milter.Milter):
# log selected headers
if log_headers or lname in ('subject','x-mailer'):
self.log('%s: %s' % (name,val))
elif self.trust_received and lname == 'received':
self.trust_received = False
self.log('%s: %s' % (name,val.splitlines()[0]))
elif self.trust_spf and lname == 'received-spf':
self.trust_spf = False
self.log('%s: %s' % (name,val.splitlines()[0]))
if self.fp:
try:
val = val.encode('us-ascii')
@@ -913,8 +1160,28 @@ class bmsMilter(Milter.Milter):
for name,val in self.new_headers:
self.fp.write("%s: %s\n" % (name,val)) # add new headers to buffer
self.fp.write("\n") # terminate headers
self.fp.seek(0)
# log when neither sender nor from domains matches mail from domain
if supply_sender and self.mailfrom != '<>':
mf_domain = self.canon_from.split('@')[-1]
self.fp.seek(0)
msg = rfc822.Message(self.fp)
for rn,hf in msg.getaddrlist('from')+msg.getaddrlist('sender'):
t = parse_addr(hf)
if len(t) == 2 and t[1].lower() == mf_domain:
break
else:
for f in msg.getallmatchingheaders('from'):
self.log(f)
sender = msg.getallmatchingheaders('sender')
if sender:
for f in sender:
self.log(f)
else:
self.log("NOTE: Supplying MFROM as Sender");
self.add_header('Sender',self.mailfrom)
del msg
# copy headers to a temp file for scanning the body
self.fp.seek(0)
headers = self.fp.getvalue()
self.fp.close()
fd,fname = tempfile.mkstemp(".defang")
@@ -994,6 +1261,7 @@ class bmsMilter(Milter.Milter):
# this will give a fast start to stats
def check_spam(self):
"return True/False if self.fp, else return Milter.REJECT/TEMPFAIL/etc"
if not dspam_userdir: return False
ds = Dspam.DSpamDirectory(dspam_userdir)
ds.log = self.log
@@ -1013,7 +1281,7 @@ class bmsMilter(Milter.Milter):
ds.add_spam(sender,txt)
txt = None
self.fp = None
return False
return Milter.DISCARD
elif user == 'falsepositive' and self.internal_connection:
sender = dspam_users.get(self.canon_from)
if sender:
@@ -1028,6 +1296,25 @@ class bmsMilter(Milter.Milter):
if len(txt) > dspam_sizelimit:
self.log("Large message:",len(txt))
return False
if user == 'honeypot' and Dspam.VERSION >= '1.1.9':
keep = False # keep honeypot mail
self.fp = None
if len(self.recipients) > 1:
self.log("HONEYPOT:",rcpt,'SCREENED')
if self.spf:
# check that sender accepts quarantine DSN
msg = mime.message_from_file(StringIO.StringIO(txt))
rc = self.send_dsn(self.spf,msg,'quarantine.txt')
del msg
if rc != Milter.CONTINUE:
return rc
ds.check_spam(user,txt,self.recipients,quarantine=True,
force_result=dspam.DSR_ISSPAM)
else:
ds.check_spam(user,txt,self.recipients,quarantine=keep,
force_result=dspam.DSR_ISSPAM)
self.log("HONEYPOT:",rcpt)
return Milter.DISCARD
txt = ds.check_spam(user,txt,self.recipients)
if not txt:
# DISCARD if quarrantined for any recipient. It
@@ -1035,12 +1322,12 @@ class bmsMilter(Milter.Milter):
# as a false positive.
self.log("DSPAM:",user,rcpt)
self.fp = None
return False
return Milter.DISCARD
self.fp = StringIO.StringIO(txt)
modified = True
except Exception,x:
self.log("check_spam:",x)
traceback.print_exc()
milter_log.error("check_spam: %s",x,exc_info=True)
# screen if no recipients are dspam_users
if not modified and dspam_screener and not self.internal_connection \
and self.dspam:
@@ -1051,14 +1338,27 @@ class bmsMilter(Milter.Milter):
return False
screener = dspam_screener[self.id % len(dspam_screener)]
if not ds.check_spam(screener,txt,self.recipients,
classify=True,quarantine=not self.reject_spam):
self.fp = None
classify=True,quarantine=False):
if self.reject_spam:
self.log("DSPAM:",screener,
'REJECT: X-DSpam-Score: %f' % ds.probability)
self.setreply('550','5.7.1','Your Message looks spammy')
return True
self.fp = None
return Milter.REJECT
self.log("DSPAM:",screener,"SCREENED")
if self.spf:
# check that sender accepts quarantine DSN
self.fp.seek(0)
msg = mime.message_from_file(self.fp)
rc = self.send_dsn(self.spf,msg,'quarantine.txt')
if rc != Milter.CONTINUE:
self.fp = None
return rc
del msg
if not ds.check_spam(screener,txt,self.recipients,classify=True):
self.fp = None
return Milter.DISCARD
# Message no longer looks spammy, deliver normally. We lied in the DSN.
return modified
def eom(self):
@@ -1069,8 +1369,7 @@ class bmsMilter(Milter.Milter):
# analyze external mail for spam
spam_checked = self.check_spam() # tag or quarantine for spam
if not self.fp:
if spam_checked: return Milter.REJECT
return Milter.DISCARD # message quarantined for all recipients
return spam_checked
# analyze all mail for dangerous attachments and scripts
self.fp.seek(0)
@@ -1088,7 +1387,7 @@ class bmsMilter(Milter.Milter):
if not exc_value.strerror:
exc_value.strerror = exc_value.args[0]
if exc_value.strerror == 'Lock failed':
self.log("LOCK: BUSY") # log filename
milter_log.warn("LOCK: BUSY") # log filename
self.setreply('450','4.2.0',
'Too busy discarding spam. Please try again later.')
return Milter.TEMPFAIL
@@ -1096,7 +1395,7 @@ class bmsMilter(Milter.Milter):
os.rename(self.tempname,fname)
self.tempname = None
if exc_type == email.Errors.BoundaryError:
self.log("MALFORMED: %s" % fname) # log filename
milter_log.warn("MALFORMED: %s",fname) # log filename
if self.internal_connection:
# accept anyway for now
return Milter.ACCEPT
@@ -1104,12 +1403,12 @@ class bmsMilter(Milter.Milter):
'Boundary error in your message, are you a spammer?')
return Milter.REJECT
if exc_type == email.Errors.HeaderParseError:
self.log("MALFORMED: %s" % fname) # log filename
milter_log.warn("MALFORMED: %s",fname) # log filename
self.setreply('554','5.7.7',
'Header parse error in your message, are you a spammer?')
return Milter.REJECT
milter_log.error("FAIL: %s",fname) # log filename
# let default exception handler print traceback and return 451 code
self.log("FAIL: %s" % fname) # log filename
raise
if rc == Milter.REJECT: return rc;
if rc == Milter.DISCARD: return rc;
@@ -1137,39 +1436,17 @@ class bmsMilter(Milter.Milter):
if self.cbv_needed:
q = self.cbv_needed
sender = q.s
cached = cbv_cache.has_key(sender)
if cached:
self.log('CBV:',sender,'(cached)')
res = cbv_cache[sender]
if q.result in ('softfail','fail','deny'):
template_name = 'softfail.txt'
elif q.result in ('unknown','permerror'):
template_name = 'permerror.txt'
elif q.result == 'neutral':
template_name = 'neutral.txt'
else:
self.log('CBV:',sender)
try:
if q.result == 'softfail':
template = file('softfail.txt').read()
else:
template = file('strike3.txt').read()
except IOError: template = None
m = dsn.create_msg(q,self.recipients,msg,template)
m = m.as_string()
print >>open('last_dsn','w'),m
res = dsn.send_dsn(sender,self.receiver,m)
if res:
desc = "CBV: %d %s" % res[:2]
if 400 <= res[0] < 500:
self.log('TEMPFAIL:',desc)
self.setreply('450','4.2.0',*desc.splitlines())
return Milter.TEMPFAIL
if len(res) < 3: res += time.time(),
cbv_cache[sender] = res
self.log('REJECT:',desc)
self.setreply('550','5.7.1',*desc.splitlines())
return Milter.REJECT
cbv_cache[sender] = res
if not cached:
s = time.strftime(time_format,time.localtime())
print >>open('send_dsn.log','a'),sender,s # log who we sent DSNs to
template_name = 'strike3.txt'
rc = self.send_dsn(q,msg,template_name)
self.cbv_needed = None
if rc != Milter.CONTINUE: return rc
if not defanged and not spam_checked:
os.remove(self.tempname)
@@ -1181,6 +1458,8 @@ class bmsMilter(Milter.Milter):
if defanged:
if self.rejectvirus and not self.hidepath:
self.log("REJECT virus from",self.mailfrom)
self.setreply('550','5.7.1','Attachment type not allowed.',
'You attempted to send an attachment with a banned extension.')
self.tempname = None
return Milter.REJECT
self.log("Temp file:",self.tempname)
@@ -1201,13 +1480,44 @@ class bmsMilter(Milter.Milter):
out.close()
return Milter.TEMPFAIL
def send_dsn(self,q,msg,template_name):
sender = q.s
cached = cbv_cache.has_key(sender)
if cached:
self.log('CBV:',sender,'(cached)')
res = cbv_cache[sender]
else:
self.log('CBV:',sender)
try:
template = file(template_name).read()
except IOError: template = None
m = dsn.create_msg(q,self.recipients,msg,template)
m = m.as_string()
print >>open('last_dsn','w'),m
res = dsn.send_dsn(sender,self.receiver,m)
if res:
desc = "CBV: %d %s" % res[:2]
if 400 <= res[0] < 500:
self.log('TEMPFAIL:',desc)
self.setreply('450','4.2.0',*desc.splitlines())
return Milter.TEMPFAIL
if len(res) < 3: res += time.time(),
cbv_cache[sender] = res
self.log('REJECT:',desc)
self.setreply('550','5.7.1',*desc.splitlines())
return Milter.REJECT
cbv_cache[sender] = res
if not cached:
s = time.strftime(time_format,time.localtime())
print >>open('send_dsn.log','a'),sender,s # log who we sent DSNs to
return Milter.CONTINUE
def close(self):
sys.stdout.flush() # make log messages visible
if self.tempname:
os.remove(self.tempname) # remove in case session aborted
if self.fp:
self.fp.close()
sys.stdout.flush()
return Milter.CONTINUE
def abort(self):
@@ -1222,10 +1532,10 @@ def main():
if srs or len(discard_users) > 0 or smart_alias or dspam_userdir:
flags = flags + Milter.DELRCPT
Milter.set_flags(flags)
print "%s bms milter startup" % time.strftime('%Y%b%d %H:%M:%S')
milter_log.info("bms milter startup")
sys.stdout.flush()
Milter.runmilter("pythonfilter",socketname,timeout)
print "%s bms milter shutdown" % time.strftime('%Y%b%d %H:%M:%S')
milter_log.info("bms milter shutdown")
if __name__ == "__main__":
read_config(["/etc/mail/pymilter.cfg","milter.cfg"])
+31
View File
@@ -141,6 +141,36 @@ is a milter declaration for sendmail.cf with all timeouts specified:
<pre>
Xpythonfilter, S=local:/var/log/milter/pythonsock, F=T, T=C:5m;S:20s;R:60s;E:5m
</pre>
<li> Q. There is a Python traceback in the log file! What happened to
my email?
<p> A. When the milter fails with an untrapped exception, a TEMPFAIL
result (451) is returned to the sender. The sender will then retry every
hour or so for several days. Hopefully, someone will notice the
traceback, and workaround or fix the problem.
<li> Q. I read some notes such as "Check valid domains allowed by internal
senders to detect PCs infected with spam trojans." but could not
understand the idea. Could you clarify the content ?
<p> A. The <code>internal_domains</code> configuration specifies which
MAIL FROM domains are used by internal connections. If an internal
PC tries to use some other domain, it is assumed to be a "Zombie".
<p>
Here is a sample log line:
<pre>
2005Jun22 12:01:04 [12430] REJECT: zombie PC at 192.168.100.171 sending MAIL FROM debby@fedex.com
</pre>
No, fedex.com does not use pymilter, and there is no one named debby at my
client. But the idiot using the PC at 192.168.100.171 has downloaded and
installed some stupid weatherbar/hotbar/aquariumscreensaver that is actually a
spam bot.
<p>
The <code>internal_domains</code> option is simplistic, it assumes all
valid senders of the domains are internal. SPF provides a much more general
check of IP and MAIL FROM for external email. Pymilter should soon
have a local policy feature for more general checking of internal mail.
<h3> Using SPF </h3>
<a name="spf">
<li> Q. So how do I use the SPF support? The sample.py milter doesn't seem
@@ -164,4 +194,5 @@ everything up for you. For other systems:
</ol>
</ol>
</body>
</html>
+22 -2
View File
@@ -50,7 +50,7 @@ porn_words = penis, breast, pussy, horse cock, porn, xenical, diet pill, d1ck,
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,
valium, rolex, sexual, fuck
valium, rolex, sexual, fuck, adv1t
# reject mail with these case sensitive strings in the subject
spam_words = $$$, !!!, XXX, FREE, HGH
# attachments with these extensions will be replaced with a warning
@@ -70,6 +70,10 @@ config=/etc/mail/pysrs.cfg
;fwdomain = mydomain.com
# turn this on after a grace period to reject spoofed DSNs
reject_spoofed = 0
# Many braindead MTAs send DSNs with a non-DSN MFROM (e.g. to report that
# some virus claiming to be sent by you). This heuristic
# 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.
[spf]
@@ -85,6 +89,16 @@ reject_spoofed = 0
;reject_noptr = 0
# always accept softfail from these domains, or send DSN otherwise
;accept_softfail = bounces.amazon.com
# Treat fail from these domains like softfail: because their SPF record
# or an important sender is screwed up. Must have valid HELO, however.
;accept_fail = custhelp.com
# Use sendmail access map or similar format for detailed spf policy.
# SPF entries in the access map will override any defaults set above.
;access_file = /etc/mail/access.db
# Add MAIL FROM as Sender when Sender is missing and From domain
# doesn't match MAIL FROM. Outlook and other email clients will then display
# something like: "Sent by sender@domain.com on behalf of from@example.com"
;supply_sender = 0
# features intended to clean up outgoing mail
[scrub]
@@ -101,6 +115,7 @@ blind = 1
# (sendmail aliases let you monitor incoming mail)
#
;users = disloyal@bigcorp.com, bigmouth@bigcorp.com
# multiple destinations can use smart_alias
;dest = spy@bigcorp.com
# discard outgoing mail without alerting sender
# can be used in conjunction with wiretap to censor outgoing mail
@@ -108,7 +123,10 @@ blind = 1
#
# smart aliases trigger on both sender and recipient
#
;smart_alias = copycust,walter
;smart_alias = copycust,walter,spy1,spy2
# multiple wiretap monitors
;spy1 = disloyal@bigcorp.com,spy@bigcorp.com
;spy2 = bigmouth@bigcorp.com,spy@bigcorp.com
# mail from client@clientcorp.com to sue@bigcorp.com is redirected to
# local alias copycust
;copycust = client@clientcorp.com,sue@bigcorp.com
@@ -152,6 +170,8 @@ blind = 1
;spam=spam@foocorp.com
# address to forward false positives to. milter will process and not deliver
;falsepositive=ham@foocorp.com
# account which receives only spam: all received messages are marked as spam.
;honeypot=spam-me@example.com
# the dspam_screener is a list of dspam users who screen mail for all
# recipients who are not dspam_users. Spam goes to the screeners quarantine,
# and the original recipients are saved so that false positives can be properly
+13 -5
View File
@@ -24,7 +24,7 @@ ALT="Viewable With Any Browser" BORDER="0"></A>
Stuart D. Gathman</a><br>
This web page is written by Stuart D. Gathman<br>and<br>sponsored by
<a href="http://www.bmsi.com">Business Management Systems, Inc.</a> <br>
Last updated Jun 09, 2005</h4>
Last updated Aug 28, 2005</h4>
See the <a href="faq.html">FAQ</a> | <a href="http://sourceforge.net/project/showfiles.php?group_id=139894">Download now</a> |
<a href="/mailman/listinfo/pymilter">Subscribe to mailing list</a> |
@@ -49,7 +49,15 @@ efficient and secure. I recommend upgrading.
Python milter is being moved to
<a href="http://sourceforge.net/projects/pymilter/">pymilter Sourceforge
project</a> for development.
project</a> for development and release downloads.
<p>
Release 0.8.2 has changes to <a href="http://openspf.net">SPF</a> to bring it
in line with the newly official RFC. It adds
<a href="http://ses.codeshare.ca/">SES</a>
support (the original SES without body hash) for pysrs-0.30.10, and honeypot
support for pydspam-1.1.9. There is a new method in the base milter module.
milter.set_exception_policy(i) lets you choose a policy of CONTINUE, REJECT, or
TEMPFAIL (default) for untrapped exceptions encountered in a milter callback.
<p>
Release 0.8.0 is the first <a href="http://sourceforge.net/">Sourceforge</a>
release. It supports Python-2.4, and provides an option to accept mail
@@ -124,9 +132,9 @@ recommend ignoring it and continuing to implement and improve SPF until a
working and unencumbered proposal for RFC2822 headers surfaces.
<p>
<a href="http://spf.pobox.com">
<a href="http://openspf.com">
<img src="SPF.gif" align=left alt="SPF logo"></a>
Release 0.6.6 adds support for <a href="http://spf.pobox.com/">SPF</a>,
Release 0.6.6 adds support for <a href="http://openspf.com/">SPF</a>,
a protocol to prevent forging of the envelope from address.
SPF support requires <a href="http://pydns.sourceforge.net/">pydns</a>.
The included spf.py module is an updated version of the original 1.6
@@ -222,7 +230,7 @@ methods that
do nothing, and also provides wrappers for the libmilter methods to mutate
the message.
<p>
The 'spf' module provides an implementation of <a href="http://spf.pobox.com">
The 'spf' module provides an implementation of <a href="http://openspf.com">
SPF</a> useful for detecting email forgery.
<p>
The 'mime' module provides a wrapper for the Python email package that
+31 -5
View File
@@ -1,5 +1,5 @@
%define name milter
%define version 0.8.1
%define version 0.8.3
%define release 1.RH7
# what version of RH are we building for?
%define redhat9 0
@@ -63,7 +63,7 @@ rm -rf $RPM_BUILD_ROOT
mkdir -p $RPM_BUILD_ROOT/var/log/milter
mkdir -p $RPM_BUILD_ROOT/etc/mail
mkdir $RPM_BUILD_ROOT/var/log/milter/save
cp bms.py strike3.txt softfail.txt $RPM_BUILD_ROOT/var/log/milter
cp bms.py *.txt $RPM_BUILD_ROOT/var/log/milter
cp milter.cfg $RPM_BUILD_ROOT/etc/mail/pymilter.cfg
# logfile rotation
@@ -146,7 +146,7 @@ rm -rf $RPM_BUILD_ROOT
%files -f INSTALLED_FILES
%defattr(-,root,root)
%doc README NEWS TODO CREDITS sample.py
%doc README HOWTO NEWS TODO CREDITS sample.py
/etc/logrotate.d/milter
/etc/cron.daily/milter
%ifos aix4.1
@@ -160,12 +160,38 @@ rm -rf $RPM_BUILD_ROOT
%dir /var/log/milter/save
%config /var/log/milter/start.sh
%config /var/log/milter/bms.py
%config /var/log/milter/strike3.txt
%config /var/log/milter/softfail.txt
%config(noreplace) /var/log/milter/strike3.txt
%config(noreplace) /var/log/milter/softfail.txt
%config(noreplace) /var/log/milter/neutral.txt
%config(noreplace) /var/log/milter/quarantine.txt
%config(noreplace) /var/log/milter/permerror.txt
%config(noreplace) /etc/mail/pymilter.cfg
/usr/share/sendmail-cf/hack/rhsbl.m4
%changelog
* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.3-1
- Keep screened honeypot mail, but optionally discard honeypot only mail.
- spf_accept_fail option for braindead SPF senders (treats fail like softfail)
- Consider SMTP AUTH connections internal.
- Send DSN for SPF errors corrected by extended processing.
- Send DSN before SCREENED mail is quarantined
- Option to set SPF policy via sendmail access map.
- Option to supply Sender header from MAIL FROM when missing.
- Use logging package to keep log lines atomic.
* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.2-4
- Limit each CNAME chain independently like PTR and MX
* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.2-3
- Limit CNAME lookups (regression)
* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.2-2
- Handle corrupt ZIP attachments
* Fri Jul 15 2005 Stuart Gathman <stuart@bmsi.com> 0.8.2-1
- Strict processing limits per SPF RFC
- Fixed several parsing bugs under RFC
- Support official IANA SPF record (type99)
- Honeypot support (requires pydspam-1.1.9)
- Extended SPF processing results beyond strict RFC limits
- Support original SES for local bounce protection (requires pysrs-0.30.10)
- Callback exception processing option in milter module
* Thu Jun 16 2005 Stuart Gathman <stuart@bmsi.com> 0.8.1-1
- Fix zip in zip loop in mime.py
- Fix HeaderParseError in bms.py header callback
+74 -14
View File
@@ -34,6 +34,18 @@ $ python setup.py help
libraries=["milter","smutil","resolv"]
* $Log$
* Revision 1.5 2005/06/24 04:20:07 customdesigned
* Report context allocation error.
*
* Revision 1.4 2005/06/24 04:12:43 customdesigned
* Remove unused name argument to generic wrappers.
*
* Revision 1.3 2005/06/24 03:57:35 customdesigned
* Handle close called before connect.
*
* Revision 1.2 2005/06/02 04:18:55 customdesigned
* Update copyright notices after reading article on /.
*
* Revision 1.1.1.2 2005/05/31 18:09:06 customdesigned
* Release 0.7.1
*
@@ -194,7 +206,7 @@ $ python setup.py help
/* Yes, these are static. If you need multiple different callbacks, */
/* it's cleaner to use multiple filters. */
/* it's cleaner to use multiple filters, or convert to OO method calls. */
static PyObject *connect_callback = NULL;
static PyObject *helo_callback = NULL;
static PyObject *envfrom_callback = NULL;
@@ -239,8 +251,11 @@ _get_context(SMFICTX *ctx) {
PyEval_AcquireThread(t); /* lock interp */
self = PyObject_New(milter_ContextObject,&milter_ContextType);
if (!self) {
/* Can't pass on exception since we are called from libmilter */
PyErr_Clear();
/* Report and clear exception since we are called from libmilter */
if (PyErr_Occurred()) {
PyErr_Print();
PyErr_Clear();
}
PyThreadState_Clear(t);
PyEval_ReleaseThread(t);
PyThreadState_Delete(t);
@@ -331,7 +346,8 @@ CHGHDRS - filter may change/delete headers";
static PyObject *
milter_set_flags(PyObject *self, PyObject *args) {
if (!PyArg_ParseTuple(args, "i", &description.xxfi_flags)) return NULL;
if (!PyArg_ParseTuple(args, "i:set_flags", &description.xxfi_flags))
return NULL;
Py_INCREF(Py_None);
return Py_None;
}
@@ -497,6 +513,28 @@ milter_set_close_callback(PyObject *self, PyObject *args) {
return generic_set_callback(args, "O:set_close_callback", &close_callback);
}
static int exception_policy = SMFIS_TEMPFAIL;
static char milter_set_exception_policy__doc__[] =
"set_exception_policy(i) -> None\n\
Sets the policy for untrapped Python exceptions during a callback.\n\
Must be one of TEMPFAIL,REJECT,CONTINUE";
static PyObject *
milter_set_exception_policy(PyObject *self, PyObject *args) {
int i;
if (!PyArg_ParseTuple(args, "i:set_exception_policy", &i))
return NULL;
switch (i) {
case SMFIS_REJECT: case SMFIS_TEMPFAIL: case SMFIS_CONTINUE:
exception_policy = i;
Py_INCREF(Py_None);
return Py_None;
}
PyErr_SetString(MilterError,"invalid exception policy");
return NULL;
}
/** Report and clear any python exception before returning to libmilter.
The interpreter is locked when we are called, and we unlock it. */
static int _report_exception(milter_ContextObject *self) {
@@ -504,8 +542,15 @@ static int _report_exception(milter_ContextObject *self) {
PyErr_Print();
PyErr_Clear(); /* must clear since not returning to python */
PyEval_ReleaseThread(self->t);
smfi_setreply(self->ctx, "451", "4.3.0", "Filter failure");
return SMFIS_TEMPFAIL;
switch (exception_policy) {
case SMFIS_REJECT:
smfi_setreply(self->ctx, "554", "5.3.0", "Filter failure");
return SMFIS_REJECT;
case SMFIS_TEMPFAIL:
smfi_setreply(self->ctx, "451", "4.3.0", "Filter failure");
return SMFIS_TEMPFAIL;
}
return SMFIS_CONTINUE;
}
PyEval_ReleaseThread(self->t);
return SMFIS_CONTINUE;
@@ -616,7 +661,7 @@ milter_wrap_helo(SMFICTX *ctx, char *helohost) {
}
static int
generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv, const char *name) {
generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv) {
PyObject *arglist;
milter_ContextObject *self;
int count = 0;
@@ -653,12 +698,12 @@ generic_env_wrapper(SMFICTX *ctx, PyObject*cb, char **argv, const char *name) {
static int
milter_wrap_envfrom(SMFICTX *ctx, char **argv) {
return generic_env_wrapper(ctx,envfrom_callback,argv,"milter_wrap_envfrom");
return generic_env_wrapper(ctx,envfrom_callback,argv);
}
static int
milter_wrap_envrcpt(SMFICTX *ctx, char **argv) {
return generic_env_wrapper(ctx,envrcpt_callback,argv,"milter_wrap_envrcpt");
return generic_env_wrapper(ctx,envrcpt_callback,argv);
}
static int
@@ -674,7 +719,7 @@ milter_wrap_header(SMFICTX *ctx, char *headerf, char *headerv) {
}
static int
generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb,const char *name) {
generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb) {
PyObject *arglist;
milter_ContextObject *c;
if (cb == NULL) return SMFIS_CONTINUE;
@@ -686,7 +731,7 @@ generic_noarg_wrapper(SMFICTX *ctx,PyObject *cb,const char *name) {
static int
milter_wrap_eoh(SMFICTX *ctx) {
return generic_noarg_wrapper(ctx,eoh_callback,"milter_wrap_eoh");
return generic_noarg_wrapper(ctx,eoh_callback);
}
static int
@@ -704,18 +749,31 @@ milter_wrap_body(SMFICTX *ctx, u_char *bodyp, size_t bodylen) {
static int
milter_wrap_eom(SMFICTX *ctx) {
return generic_noarg_wrapper(ctx,eom_callback,"milter_wrap_eom");
return generic_noarg_wrapper(ctx,eom_callback);
}
static int
milter_wrap_abort(SMFICTX *ctx) {
/* libmilter still calls close after abort */
return generic_noarg_wrapper(ctx,abort_callback,"milter_wrap_abort");
return generic_noarg_wrapper(ctx,abort_callback);
}
static int
milter_wrap_close(SMFICTX *ctx) {
int r = generic_noarg_wrapper(ctx,close_callback,"milter_wrap_close");
/* xxfi_close can be called out of order - even before connect.
* There may not yet be a private context pointer. To avoid
* creating a ThreadContext and allocating a milter context only
* to destroy them, and to avoid invoking the python close_callback when
* connect has never been called, we don't use generic_noarg_wrapper here. */
PyObject *cb = close_callback;
milter_ContextObject *self = smfi_getpriv(ctx);
int r = SMFIS_CONTINUE;
if (self != NULL && cb != NULL && self->ctx == ctx) {
PyObject *arglist;
PyEval_AcquireThread(self->t);
arglist = Py_BuildValue("(O)", self);
r = _generic_wrapper(self, cb, arglist);
}
/* FIXME: It is inefficient to have released the interp lock only to
acquire it again in _clear_context. We can tell _generic_return and
friends not to release the lock by, for instance, setting self->t to NULL.
@@ -1155,6 +1213,8 @@ static PyMethodDef milter_methods[] = {
{ "set_eom_callback", milter_set_eom_callback, METH_VARARGS, milter_set_eom_callback__doc__},
{ "set_abort_callback", milter_set_abort_callback, METH_VARARGS, milter_set_abort_callback__doc__},
{ "set_close_callback", milter_set_close_callback, METH_VARARGS, milter_set_close_callback__doc__},
{ "set_exception_policy", milter_set_exception_policy,METH_VARARGS, milter_set_exception_policy__doc__},
{ "register", milter_register, METH_VARARGS, milter_register__doc__},
{ "register", milter_register, METH_VARARGS, milter_register__doc__},
{ "main", milter_main, METH_VARARGS, milter_main__doc__},
{ "setdbg", milter_setdbg, METH_VARARGS, milter_setdbg__doc__},
+24 -14
View File
@@ -1,4 +1,7 @@
# $Log$
# Revision 1.4 2005/06/17 01:49:39 customdesigned
# Handle zip within zip.
#
# Revision 1.3 2005/06/02 15:00:17 customdesigned
# Configure banned extensions. Scan zipfile option with test case.
#
@@ -193,7 +196,8 @@ class MimeMessage(Message):
for key,name in tuple(names): # copy by converting to tuple
if name and name.lower().endswith('.zip'):
txt = self.get_payload(decode=True)
names += zipnames(txt)
if txt.strip():
names += zipnames(txt)
return names
def ismodified(self):
@@ -304,19 +308,25 @@ See your administrator.
def check_name(msg,savname=None,ckname=check_ext,scan_zip=False):
"Replace attachment with a warning if its name is suspicious."
for key,name in msg.getnames(scan_zip):
badname = ckname(name)
if badname:
hostname = socket.gethostname()
if key == 'zipname':
badname = msg.get_filename()
msg.set_payload(virus_msg % (badname,hostname,savname))
del msg["content-type"]
del msg["content-disposition"]
del msg["content-transfer-encoding"]
name = "WARNING.TXT"
msg["Content-Type"] = "text/plain; name="+name
break
try:
for key,name in msg.getnames(scan_zip):
badname = ckname(name)
if badname:
if key == 'zipname':
badname = msg.get_filename()
break
else:
return Milter.CONTINUE
except zipfile.BadZipfile:
# a ZIP that is not a zip is very suspicious
badname = msg.get_filename()
hostname = socket.gethostname()
msg.set_payload(virus_msg % (badname,hostname,savname))
del msg["content-type"]
del msg["content-disposition"]
del msg["content-transfer-encoding"]
name = "WARNING.TXT"
msg["Content-Type"] = "text/plain; name="+name
return Milter.CONTINUE
import email.Iterators
+34
View File
@@ -0,0 +1,34 @@
Subject: SPF %(result)s (POSSIBLE FORGERY)
This is an automatically generated Delivery Status Notification.
THIS IS A WARNING MESSAGE ONLY.
YOU DO *NOT* NEED TO RESEND YOUR MESSAGE.
Delivery to the following recipients has been delayed.
%(rcpt)s
Subject: %(subject)s
Received-SPF: %(spf_result)s
Your sender policy (or lack thereof) indicated that the above email was not
sent via an authorized SMTP server, but may still be legitimate. Since there
is no positive confirmation that the message is really from you, we have
to give it extra scrutiny - including verifying that the sender really
exists by sending you this DSN. We will remember this sender and not
bother you again for while. You can avoid this message entirely for
legitimate mail by using an authorized SMTP server. Contact your mail
administrator and ask how to configure your email client to use an
authorized server.
If you never sent the above message, then your domain has been forged.
Your mail admin needs to publish a strict SPF record so that I can reject
those forgeries instead of bugging you about them.
If you need further assistance, please do not hesitate to contact me.
Kind regards,
postmaster@%(receiver)s
+31
View File
@@ -0,0 +1,31 @@
Subject: Critical SPF configuration error
This is an automatically generated Delivery Status Notification.
THIS IS A WARNING MESSAGE ONLY.
YOU DO *NOT* NEED TO RESEND YOUR MESSAGE.
Delivery to the following recipients has been delayed.
%(rcpt)s
Subject: %(subject)s
Your spf record has a permanent error. The error was:
%(perm_error)s
We will reinterpret your record using "lax" processing heuristics
which may result in your mail being accepted anyway. But you or your
mail administrator need to fix your SPF record as soon as possible.
We are sending you this message to alert you to the fact that
you have problems with your email configuration.
If you need further assistance, please do not hesitate to
contact me again.
Kind regards,
postmaster@%(receiver)s
+237
View File
@@ -0,0 +1,237 @@
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<html>
<head>
<title>Python Milter Mail Policy </title>
</head><body>
<h1> Python Milter Mail Policy </h1>
<h3> Classify connection </h3>
When the SMTP client connects, the connection IP address is
saved for later verification, and the connection
is classified as INTERNAL or EXTERNAL by matching the ip
address against the <code>internal_connect</code> configuration.
IP addresses with no PTR, and PTR names that look like
the kind assigned to dynamic IPs (as determined by a heuristic
algorithm) are flagged as DYNAMIC. IPs that match the
<code>trusted_relay</code> configuration are flagged as TRUSTED.
<p>
Examples from the log file (<i>not</i> the SMTP error message returned):
<pre>
2005Jul29 13:56:53 [71207] connect from p50863492.dip0.t-ipconnect.de at ('80.134.52.146', 1858) EXTERNAL DYN
2005Jul29 18:10:15 [74511] connect from foopub at ('1.2.3.4', 46513) EXTERNAL TRUSTED
2005Jul29 14:41:00 [71805] connect from foobar at ('192.168.0.1', 41205) INTERNAL
2005Jul29 14:41:15 [71806] connect from cncln.online.ln.cn at ('218.25.240.137', 35992) EXTERNAL
</pre>
<p>
Certain obviously evil PTR names are blocked at this point:
"localhost" (when IP is not 127.*) and ".".
<pre>
2005Jul29 14:49:50 [71918] connect from localhost at ('221.132.0.6', 50507) EXTERNAL
2005Jul29 14:49:50 [71918] REJECT: PTR is localhost
</pre>
<h3> HELO Check </h3>
The HELO name provided by the client is saved for later verification
(for example by SPF). We could validate the HELO at this point
by verifying that an A record for the HELO name matches the connect ip.
However, currently we only block certain obvious problems.
HELO names that look like an IP4 address
and ones that match the <code>hello_blacklist</code> configuration
are immediately rejected. The hello_blacklist typically contains
the current MTAs own HELO name or email domains.
Clients that attempt to skip HELO are immediately rejected.
<pre>
2005Jul29 18:10:15 [74512] hello from example.com
2005Jul29 18:10:15 [74512] REJECT: spam from self: example.com
2005Jul29 18:17:09 [74581] hello from 80.191.244.69
2005Jul29 18:17:09 [74581] REJECT: numeric hello name: 80.191.244.69
</pre>
<h3> MAIL FROM Check </h3>
Before calling our milter, sendmail checks a DNS blacklist to
block banned sender domains. We never see a blocked domain.
<p>
The MAIL FROM address is saved for possible use by the smart-alias
feature. First, the <code>internal_domains</code> is used for
a simple screening if defined. If the MAIL FROM for an INTERNAL connection
is NOT in <code>internal_domains</code>, then it is rejected (the
PC is most likely infected and attempting to send out spam).
If the MAIL FROM for an EXTERNAL connection IS in
<code>internal_domains</code>, then the message is immediately rejected.
This is quick and effective for most small company MTAs. For more
complex mail networks, it is too simplistic, and should not be defined.
SPF will handle the complex cases.
<h4> wiretap </h4>
The wiretap feature can screen and/or monitor mail to/from certain
users. If the MAIL FROM is being wiretapped, the recipients are
altered accordingly.
<h4> SPF check </h4>
Finally, the MAIL FROM, connect IP, and HELO name are checked against
any SPF records published via DNS for the alleged sender (MAIL FROM).
If there is no SPF record, we check for a local substitute under the
domain defined in the <code>[spf]delegate</code> configuration.
Further checks depend on the result.
<table border=1>
<tr><th>NONE</th><td>
If there is no SPF record (official or delegated), then we
initiate a "three strikes and your out" regime, which looks for
<b>some</b> form of validated identification.
<ol>
<li>We try a "best guess" SPF record of "v=spf1 a/24 mx/24 ptr". If this
passes, good.
<li> We try to validate the HELO name. First check for an SPF record.
Otherwise, check whether the connect IP matches any A record for
the HELO name, or any A record for any MX name for the HELO name,
or is at least in the same /24 subnet as any of the above.
(In other words, a HELO SPF "best guess" of "v=spf1 a/24 mx/24".)
If so, good. We consider the HELO validated. If the HELO SPF
check fails, we reject the email.
</ol>
<pre>
2005Jul30 19:45:16 [93991] connect from [221.200.41.54] at ('221.200.41.54', 3581) EXTERNAL DYN
2005Jul30 19:45:18 [93991] hello from adelphia.net
2005Jul30 19:45:19 [93991] mail from <wendy.stubbsua@link-it.com> ()
2005Jul30 19:45:19 [93991] REJECT: hello SPF: fail 550 access denied
</pre>
<ol>
<li> If there is a validated PTR name, and it doesn't look
like a dynamic name, good. We consider the connection validated.
</ol>
If any of the above can be validated, we continue on.
If none of the above can be validated, and the <code>[SPF]reject_noptr</code>
option is true, we reject the message immediately with the explanation
that we need some form of valid identification before we accept an email.
If <code>[SPF]reject_noptr</code> is false, we flag the message as
needing Call Back Validation.
The Call Back Valildation sends a DSN to the purported sender informing
them of the lack of identification. If the message is legitimate, the
sender needs to know that their email setup is broken and should be corrected.
If the message is forged, the sender is informed of the forgery,
and their need to publish an SPF record or at least use a valid HELO name.
If the purported sender does not accept the DSN,
then the message is rejected. The CBV status is cached to avoid
annoying the purported sender with too many DSNs. Currently, the DSN
is repeated to the same sender once per month.
<p>
In this example, although 3com.com has no SPF record, we assume that
any legitimate mail from them will at least have a valid HELO or PTR.
<pre>
2005Jul30 23:52:03 [96777] connect from [222.252.233.200] at ('222.252.233.200', 29934) EXTERNAL DYN
2005Jul30 23:52:03 [96777] hello from 3mail.3com.com
2005Jul30 23:52:04 [96777] mail from <etec_nic_family@3mail.3com.com> ()
2005Jul30 23:52:04 [96777] REJECT: no PTR, HELO or SPF
</pre>
</td></tr>
<tr><th>PASS</th><td>
A pass result normally lets the email continue on, but the domain is
tracked for reputation (and may be blocked), and may skip content scanning if
it matches a whitelist.
<pre>
2005Jul24 17:44:26 [2104] mail from <gnucash-devel-bounces@gnucash.org> ('SIZE=4410',)
2005Jul24 17:44:26 [2104] Received-SPF: pass (mail.bmsi.com: domain of gnucash.org
designates 204.107.200.65 as permitted sender)
client-ip=204.107.200.65; envelope-from=gnucash-devel-bounces@gnucash.org; helo=cvs.gnucash.org;
</pre>
</td></tr>
<tr><th>NEUTRAL</th><td>
A neutral result normally lets the email continue on, but the domain is not
tracked for reputation or matched against any whitelists.
Highly forged domains listed in <code>[SPF]reject_neutral</code> are
rejected.
<pre>
2005Jul24 17:41:37 [2070] connect from cp500627-a.dbsch1.nb.home.nl at ('84.27.225.3', 3465) EXTERNAL
2005Jul24 17:41:37 [2070] hello from cp500627-a.dbsch1.nb.home.nl
2005Jul24 17:41:38 [2070] mail from <nwarjejkw@yahoo.com> ()
2005Jul24 17:41:38 [2070] REJECT: SPF neutral for nwarjejkw@yahoo.com
</pre>
</td></tr>
<tr><th>SOFTFAIL</th><td>
A softfail result normally lets the email continue on, but the domain is not
tracked for reputation or matched against any whitelists. Furthermore,
the message is flagged as needing Call Back Validation,
and the highly forged domains listed in <code>[SPF]reject_neutral</code> are
rejected as well.
<p>
At present, we also require a valid HELO or PTR to avoid rejecting
a softfail. But this should probably change to only require a
successful CBV.
<p>
The Call Back Valildation sends a DSN to the purported sender informing
them of the softfail. If the message is legitimate, the sender needs
to know about the softfail so that their email setup can be corrected.
If the message is forged, the sender is informed of the forgery, confirming
that SPF is protecting their reputation and encouraging a rapid transition
to a strict policy. If the purported sender does not accept the DSN,
then the message is rejected. The CBV status is cached to avoid
annoying the purported sender with too many DSNs. Currently, the DSN
is repeated to the same sender once per month.
<pre>
2005Jul24 15:41:33 [801] mail from <Aitp@horafeliz.com> ()
2005Jul24 15:41:33 [801] Received-SPF: softfail (mail.bmsi.com: transitioning domain of horafeliz.com
does not designate 221.184.83.185 as permitted sender)
client-ip=221.184.83.185; envelope-from=Aitp@horafeliz.com;
helo=p8185-ipad30funabasi.chiba.ocn.ne.jp;
2005Jul24 15:41:33 [801] rcpt to <david@example.com> ()
2005Jul24 15:41:35 [801] Subject: Microsoft, Adobe, Macromedia, Corel software. Up to 80% discount.
2005Jul24 15:41:35 [801] X-Mailer: Microsoft Outlook, Build 10.0.2605
2005Jul24 15:41:35 [801] CBV: Aitp@horafeliz.com
2005Jul24 15:41:38 [801] REJECT: CBV: 550 <Aitp@horafeliz.com>: User unknown
</pre>
</td></tr>
<tr><th>FAIL</th><td>
The message is rejected with a reference the SPF why page.
<pre>
2005Jul30 19:53:27 [94070] connect from [212.70.52.16] at ('212.70.52.16', 3192) EXTERNAL DYN
2005Jul30 19:53:27 [94070] hello from winzip.com
2005Jul30 19:53:27 [94070] mail from <dan@winzip.com> ()
2005Jul30 19:53:27 [94070] REJECT: SPF fail 550 SPF fail:
see http://openspf.com/why.html?sender=dan@winzip.com&ip=212.70.52.16
</pre>
</td></tr>
<tr><th>PERMERROR</th><td>
Permanent errors were called "unknown", and are still show that way
in the log. The message is rejected. Previously, we enabled "lax" parsing
of the SPF record, but rejecting is better because it informs the
sender about their problem. The next milter version will
look for a local substitute SPF record (as for a missing SPF record)
before rejecting. This will inform the sender of their problem, but
also let the receiver install a temporary workaround.
<pre>
2005Jul24 18:05:37 [2312] mail from <b-mihdbcgaacaa-becibijh-000-@msg.euxiphipops.com> ()
2005Jul24 18:05:37 [2312] REJECT: SPF unknown 550 SPF Permanent Error:
include mechanism missing domain: include
</pre>
The SPF record for msg.euxiphipops.com looked like this at the time of the
above error:
<pre>
msg.euxiphipops.com TXT "v=spf1 mx ptr a include"
</pre>
</td></tr>
<tr><th>TEMPERROR</th><td>
Temporary errors result in a 451 "Try again later" response. The sender
should retry the message at a later time.
<pre>
2005Jul24 07:33:13 [29846] mail from <quickenloans@rate.quicken.com> ('SIZE=73775', 'BODY=8BITMIME')
2005Jul24 07:33:43 [29846] TEMPFAIL: SPF error 450 SPF Temporary Error: DNS Timeout
</pre>
</td></tr>
</table>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
Subject: DELIVERY STATUS (POSSIBLE SPAM)
This is an automatically generated Delivery Status Notification.
THIS IS A WARNING MESSAGE ONLY.
YOU DO *NOT* NEED TO RESEND YOUR MESSAGE.
Delivery to the following recipients has been delayed.
%(rcpt)s
Subject: %(subject)s
Received-SPF: %(spf_result)s
A statistical analysis of your message has classified it as junk mail,
and it has been quarantined. Eventually, the recipients will review
their quarantined mail and may notice your message. If your message is
important, please contact them via other means. You may also try sending
them a simple plain text message.
If you need further assistance, please do not hesitate to contact me.
Kind regards,
postmaster@%(receiver)s
+2 -2
View File
@@ -1,5 +1,5 @@
[bdist_rpm]
python=python2
python=python2.4
doc_files=README NEWS TODO
packager=Stuart D. Gathman <stuart@bmsi.com>
release=2.4
release=1
+2 -1
View File
@@ -1,6 +1,7 @@
import os
import sys
from distutils.core import setup, Extension
import Milter
# FIXME: on some versions of sendmail, smutil is renamed to sm
libs = ["milter", "smutil"]
@@ -12,7 +13,7 @@ if sys.version < '2.2.3':
DistributionMetadata.classifiers = None
DistributionMetadata.download_url = None
setup(name = "milter", version = "0.8.1",
setup(name = "milter", version = Milter.__version__,
description="Python interface to sendmail milter API",
long_description="""\
This is a python extension module to enable python scripts to
+4 -2
View File
@@ -1,4 +1,4 @@
Subject: SPF softfail (POSSIBLE FORGERY)
Subject: SPF %(result)s (POSSIBLE FORGERY)
This is an automatically generated Delivery Status Notification.
@@ -14,7 +14,9 @@ Subject: %(subject)s
Received-SPF: %(spf_result)s
Your sender policy indicated that the above email was likely forged and that
feedback was desired.
feedback was desired. If you are sending from a foreign ISP,
then you may need to follow your home ISPs instructions for configuring
your outgoing mail server.
If you need further assistance, please do not hesitate to contact me.
+424 -248
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python
"""SPF (Sender-Permitted From) implementation.
"""SPF (Sender Policy Framework) implementation.
Copyright (c) 2003, Terence Way
Portions Copyright (c) 2004,2005 Stuart Gathman <stuart@bmsi.com>
@@ -19,10 +19,11 @@ AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
For more information about SPF, a tool against email forgery, see
http://spf.pobox.com
http://spf.pobox.com/
For news, bugfixes, etc. visit the home page for this implementation at
http://www.wayforward.net/spf/
http://sourceforge.net/projects/pymilter/
"""
# Changes:
@@ -46,6 +47,130 @@ For news, bugfixes, etc. visit the home page for this implementation at
# Terrence is not responding to email.
#
# $Log$
# Revision 1.13 2005/07/22 16:00:23 customdesigned
# Limit CNAME chains independently of DNS lookup limit
#
# Revision 1.31 2005/07/22 02:11:50 customdesigned
# Use dictionary to check for CNAME loops. Check limit independently for
# each top level name, just like for PTR.
#
# Revision 1.30 2005/07/21 20:07:31 customdesigned
# Translate DNS error in DNSLookup. This completely isolates DNS
# dependencies to the DNSLookup method.
#
# Revision 1.29 2005/07/21 17:49:39 customdesigned
# My best guess at what RFC intended for limiting CNAME loops.
#
# Revision 1.28 2005/07/21 17:37:08 customdesigned
# Break out external DNSLookup method so that test suite can
# duplicate CNAME loop bug. Test zone data dictionary now
# mirrors structure of real DNS.
#
# Revision 1.27 2005/07/21 15:26:06 customdesigned
# First cut at updating docs. Test suite is obsolete.
#
# Revision 1.26 2005/07/20 03:12:40 customdesigned
# When not in strict mode, don't give PermErr for bad mechanism until
# encountered during evaluation.
#
# Revision 1.25 2005/07/19 23:24:42 customdesigned
# Validate all mechanisms before evaluating.
#
# Revision 1.24 2005/07/19 18:11:52 kitterma
# Fix to change that compares type TXT and type SPF records. Bug in the change
# prevented records from being returned if it was published as TXT, but not SPF.
#
# Revision 1.23 2005/07/19 15:22:50 customdesigned
# MX and PTR limits are MUST NOT check limits, and do not result in PermErr.
# Also, check belongs in mx and ptr specific methods, not in dns() method.
#
# Revision 1.22 2005/07/19 05:02:29 customdesigned
# FQDN test was broken. Added test case. Move FQDN test to after
# macro expansion.
#
# Revision 1.21 2005/07/18 20:46:27 kitterma
# Fixed reference problem in 1.20
#
# Revision 1.20 2005/07/18 20:21:47 kitterma
# Change to dns_spf to go ahead and check for a type 99 (SPF) record even if a
# TXT record is found and make sure if type SPF is present that they are
# identical when using strict processing.
#
# Revision 1.19 2005/07/18 19:36:00 kitterma
# Change to require at least one dot in a domain name. Added PermError
# description to indicate FQDN should be used. This is a common error.
#
# Revision 1.18 2005/07/18 17:13:37 kitterma
# Change macro processing to raise PermError on an unknown macro.
# schlitt-spf-classic-02 para 8.1. Change exp modifier processing to ignore
# exp strings with syntax errors. schlitt-spf-classic-02 para 6.2.
#
# Revision 1.17 2005/07/18 14:35:34 customdesigned
# Remove debugging printf
#
# Revision 1.16 2005/07/18 14:34:14 customdesigned
# Forgot to remove debugging print
#
# Revision 1.15 2005/07/15 21:17:36 customdesigned
# Recursion limit raises AssertionError in strict mode, PermError otherwise.
#
# Revision 1.14 2005/07/15 20:34:11 customdesigned
# Check whether DNS package already supports SPF before patching
#
# Revision 1.13 2005/07/15 20:01:22 customdesigned
# Allow extended results for MX limit
#
# Revision 1.12 2005/07/15 19:12:09 customdesigned
# Official IANA SPF record (type 99) support.
#
# Revision 1.11 2005/07/15 18:03:02 customdesigned
# Fix unknown Received-SPF header broken by result changes
#
# Revision 1.10 2005/07/15 16:17:05 customdesigned
# Start type99 support.
# Make Scott's "/" support in parse_mechanism more elegant as requested.
# Add test case for "/" support.
#
# Revision 1.9 2005/07/15 03:33:14 kitterma
# Fix for bug 1238403 - Crash if non-CIDR / present. Also added
# validation check for valid IPv4 CIDR range.
#
# Revision 1.8 2005/07/14 04:18:01 customdesigned
# Bring explanations and Received-SPF header into line with
# the unknown=PermErr and error=TempErr convention.
# Hope my case-sensitive mech fix doesn't clash with Scotts.
#
# Revision 1.7 2005/07/12 21:43:56 kitterma
# Added processing to clarify some cases of unknown
# qualifier errors (to distinguish between unknown qualifier and
# unknown mechanism).
# Also cleaned up comments from previous updates.
#
# Revision 1.6 2005/06/29 14:46:26 customdesigned
# Distinguish trivial recursion from missing arg for diagnostic purposes.
#
# Revision 1.5 2005/06/28 17:48:56 customdesigned
# Support extended processing results when a PermError should strictly occur.
#
# Revision 1.4 2005/06/22 15:54:54 customdesigned
# Correct spelling.
#
# Revision 1.3 2005/06/22 00:08:24 kitterma
# Changes from draft-mengwong overall DNS lookup and recursion
# depth limits to draft-schlitt-spf-classic-02 DNS lookup, MX lookup, and
# PTR lookup limits. Recursion code is still present and functioning, but
# it should be impossible to trip it.
#
# Revision 1.2 2005/06/21 16:46:09 kitterma
# Updated definition of SPF, added reference to the sourceforge project site,
# and deleted obsolete Microsoft Caller ID for Email XML translation routine.
#
# Revision 1.1.1.1 2005/06/20 19:57:32 customdesigned
# Move Python SPF to its own module.
#
# Revision 1.5 2005/06/14 20:31:26 customdesigned
# fix pychecker nits
#
# Revision 1.4 2005/06/02 04:18:55 customdesigned
# Update copyright notices after reading article on /.
#
@@ -144,135 +269,21 @@ import struct # for pack() and unpack()
import time # for time()
import DNS # http://pydns.sourceforge.net
import xml.sax
if not hasattr(DNS.Type,'SPF'):
# patch in type99 support
DNS.Type.SPF = 99
DNS.Type.typemap[99] = 'SPF'
DNS.Lib.RRunpacker.getSPFdata = DNS.Lib.RRunpacker.getTXTdata
# -------------------------------------------------------------------------
# Convert a MS Caller-ID entry (XML) to a SPF entry
#
# (c) 2004 by Ernesto Baschny
# (c) 2004 Python version by Stuart Gathman
#
# Date: 2004-02-25
#
# A complete reverse translation (SPF -> CID) might be impossible, since
# there are no ways to handle:
# - PTR and EXISTS mechanism
# - MX mechanism with an different domain as argument
# - macros
#
# References:
# http://www.microsoft.com/mscorp/twc/privacy/spam_callerid.mspx
# http://spf.pobox.com/
#
# Known bugs:
# - Currently it won't handle the exclusions provided in the A and R
# tags (prefix '!'). They will show up "as-is" in the SPF record
# - I really haven't read the MS-CID specs in-depth, so there are probably
# other bugs too :)
#
# Ernesto Baschny <ernst@baschny.de>
#
class CIDParser(xml.sax.ContentHandler):
"Convert a MS Caller-ID entry (XML) to a SPF entry."
def __init__(self,q=None):
self.spf = []
self.action = '-all'
self.has_servers = None
self.spf_entry = None
if q:
self.spf_query = q
else:
self.spf_query = query(i='127.0.0.1', s='localhost', h='unknown')
def startElement(self,tag,attr):
if tag == 'm':
if self.has_servers != None and not self.has_servers:
raise ValueError(
"Declared <noMailServers\> and later <m>, this CID entry is not valid."
)
self.has_servers = True
elif tag == 'noMailServers':
if self.has_servers:
raise ValueError(
"Declared <m> and later <noMailServers\>, this CID entry is not valid."
)
self.has_servers = False
elif tag == 'ep':
if attr.has_key('testing') and attr.getValue('testing') == 'true':
# A CID with 'testing' found:
# From the MS-specs:
# "Documents in which such attribute is present with a true
# value SHOULD be entirely ignored (one should act as if the
# document were absent)"
# From the SPF-specs:
# "Neutral (?): The SPF client MUST proceed as if a domain did
# not publish SPF data."
# So we set SPF action to "neutral":
self.action = '?all'
elif tag == 'mx':
# The empty MX-tag, same as SPF's MX-mechanism
self.spf.append('mx')
self.tag = tag
def characters(self,text):
tag = self.tag
# Remove starting and trailing spaces from text:
text = text.strip()
if tag == 'a' or tag == 'r':
# The A and R tags from MS-CID are both handled by the
# ipv4/6-mechanisms from SPF:
if text.find(':') < 0:
mechanism = 'ip4'
else:
mechanism = 'ip6'
self.spf.append(mechanism + ':' + text)
elif tag == 'indirect':
# MS-CID's indirect is "sort of" the include from SPF:
# Not really true, because the <indirect> tag from MS-CID also
# provides a fallback in case the included domain doesn't provide
# _ep-records: The inbound MX-servers of the included domains
# are added to the list of allowed outgoing mailservers for the
# domain that declared the _ep-record with the <indirect> tag.
# In SPF you would use the 'mx:domain' to handle this, but this
# wouldn't depend on referred domain having or not SPF-records.
cid_xml = self.cid_txt(text)
if cid_xml:
p = CIDParser()
xml.sax.parseString(cid_xml,p)
if p.has_servers != False:
self.spf += p.spf
else:
self.spf.append('mx:' + text)
def cid_txt(self,domain):
q = self.spf_query
domain='_ep.' + domain
a = q.dns_txt(domain)
if not a: return None
if a[0].lower().startswith('<ep ') and a[-1].lower().endswith('</ep>'):
return ''.join(a)
return None
def endElement(self,tag):
if tag == 'ep':
# This is the end... assemble what we've got
spf_entry = ['v=spf1']
if self.has_servers != False:
spf_entry += self.spf
spf_entry.append(self.action)
self.spf_entry = ' '.join(spf_entry)
def spf_txt(self,cid_xml):
if not cid_xml.startswith('<'):
cid_xml = self.cid_txt(cid_xml)
if not cid_xml: return None
# Parse the beast. Any XML-problem will be reported by xlm.sax
self.spf_entry = None
xml.sax.parseString(cid_xml,self)
return self.spf_entry
def DNSLookup(name,qtype):
try:
req = DNS.DnsRequest(name, qtype=qtype)
resp = req.req()
#resp.show()
# key k: ('wayforward.net', 'A'), value v
return [((a['name'], a['typename']), a['data']) for a in resp.answers]
except DNS.DNSError,x:
raise TempError,'DNS ' + str(x)
# 32-bit IPv4 address mask
MASK = 0xFFFFFFFFL
@@ -286,6 +297,8 @@ RE_CHAR = re.compile(r'%(%|_|-|(\{[a-zA-Z][0-9]*r?[^\}]*\}))')
# Regular expression to break up a macro expansion
RE_ARGS = re.compile(r'([0-9]*)(r?)([^0-9a-zA-Z]*)')
RE_CIDR = re.compile(r'/([1-9]|1[0-9]*|2[0-9]*|3[0-2]*)$')
# Local parts and senders have their delimiters replaced with '.' during
# macro expansion
#
@@ -293,11 +306,12 @@ JOINERS = {'l': '.', 's': '.'}
RESULTS = {'+': 'pass', '-': 'fail', '?': 'neutral', '~': 'softfail',
'pass': 'pass', 'fail': 'fail', 'unknown': 'unknown',
'neutral': 'neutral', 'softfail': 'softfail',
'error': 'error', 'neutral': 'neutral', 'softfail': 'softfail',
'none': 'none', 'deny': 'fail' }
EXPLANATIONS = {'pass': 'sender SPF verified', 'fail': 'access denied',
'unknown': 'SPF unknown',
'unknown': 'permanent error in processing',
'error': 'temporary error in processing',
'softfail': 'domain in transition',
'neutral': 'access neither permitted nor denied',
'none': ''
@@ -316,22 +330,28 @@ except NameError:
def bool(x): return not not x
# ...pre 2.2.1
# standard default SPF record
# standard default SPF record for best_guess
DEFAULT_SPF = 'v=spf1 a/24 mx/24 ptr'
# maximum DNS lookups allowed
MAX_LOOKUP = 100
MAX_LOOKUP = 10 #draft-schlitt-spf-classic-02 Para 10.1
MAX_MX = 10 #draft-schlitt-spf-classic-02 Para 10.1
MAX_PTR = 10 #draft-schlitt-spf-classic-02 Para 10.1
MAX_CNAME = 10 # analogous interpretation to MAX_PTR
MAX_RECURSION = 20
ALL_MECHANISMS = ('a', 'mx', 'ptr', 'exists', 'include', 'ip4', 'ip6', 'all')
COMMON_MISTAKES = { 'prt': 'ptr', 'ip': 'ip4', 'ipv4': 'ip4', 'ipv6': 'ip6' }
class TempError(Exception):
"Temporary SPF error"
class PermError(Exception):
"Permanent SPF error"
def __init__(self,msg,mech=None):
def __init__(self,msg,mech=None,ext=None):
Exception.__init__(self,msg,mech)
self.msg = msg
self.mech = mech
self.ext = ext
def __str__(self):
if self.mech:
return '%s: %s'%(self.msg,self.mech)
@@ -372,7 +392,7 @@ class query(object):
Also keeps cache: DNS cache.
"""
def __init__(self, i, s, h,local=None,receiver=None):
def __init__(self, i, s, h,local=None,receiver=None,strict=True):
self.i, self.s, self.h = i, s, h
if not s and h:
self.s = 'postmaster@' + h
@@ -387,6 +407,8 @@ class query(object):
self.exps = dict(EXPLANATIONS)
self.local = local # local policy
self.lookups = 0
# strict can be False, True, or 2 for harsh
self.strict = strict
def set_default_explanation(self,exp):
exps = self.exps
@@ -408,10 +430,47 @@ class query(object):
def check(self, spf=None):
"""
Returns (result, mta-status-code, explanation) where
result in ['fail', 'softfail', 'neutral' 'unknown', 'pass', 'error']
Returns (result, mta-status-code, explanation) where result
in ['fail', 'softfail', 'neutral' 'unknown', 'pass', 'error', 'none']
Examples:
>>> q = query(s='strong-bad@email.example.com',
... h='mx.example.org', i='192.0.2.3')
>>> q.check(spf='v=spf1 ?all')
('neutral', 250, 'access neither permitted nor denied')
>>> q.check(spf='v=spf1 ip4:192.0.0.0/8 ?all moo')
('unknown', 550, 'SPF Permanent Error: Unknown mechanism found: moo')
>>> q.check(spf='v=spf1 =a ?all moo')
('unknown', 550, 'SPF Permanent Error: Unknown qualifier, IETF draft para 4.6.1, found in: =a')
>>> q.check(spf='v=spf1 ip4:192.0.0.0/8 ~all')
('pass', 250, 'sender SPF verified')
>>> q.strict = False
>>> q.check(spf='v=spf1 ip4:192.0.0.0/8 -all moo')
('pass', 250, 'sender SPF verified')
>>> q.check(spf='v=spf1 ip4:192.1.0.0/16 moo -all')
('unknown', 550, 'SPF Permanent Error: Unknown mechanism found: moo')
>>> q.check(spf='v=spf1 ip4:192.1.0.0/16 ~all')
('softfail', 250, 'domain in transition')
>>> q.check(spf='v=spf1 -ip4:192.1.0.0/6 ~all')
('fail', 550, 'access denied')
# Assumes DNS available
>>> q.check()
('none', 250, '')
"""
self.mech = [] # unknown mechanisms
# If not strict, certain PermErrors (mispelled
# mechanisms, strict processing limits exceeded)
# will continue processing. However, the exception
# that strict processing would raise is saved here
self.perm_error = None
if self.i.startswith('127.'):
return ('pass', 250, 'local connections always pass')
@@ -421,31 +480,104 @@ class query(object):
spf = self.dns_spf(self.d)
if self.local and spf:
spf += ' ' + self.local
return self.check1(spf, self.d, 0)
except DNS.DNSError,x:
return ('error', 450, 'SPF DNS Error: ' + str(x))
rc = self.check1(spf, self.d, 0)
if self.perm_error:
# extended processing succeeded, but strict failed
self.perm_error.ext = rc
raise self.perm_error
return rc
except TempError,x:
return ('error', 450, 'SPF Temporary Error: ' + str(x))
except PermError,x:
self.prob = x.msg
self.mech.append(x.mech)
if x.mech:
self.mech.append(x.mech)
# Pre-Lentczner draft treats this as an unknown result
# and equivalent to no SPF record.
# return ('unknown', 550, 'SPF Permanent Error: ' + str(x))
return ('error', 550, 'SPF Permanent Error: ' + str(x))
return ('unknown', 550, 'SPF Permanent Error: ' + str(x))
def check1(self, spf, domain, recursion):
# spf rfc: 3.7 Processing Limits
#
if recursion > MAX_RECURSION:
self.prob = 'Too many levels of recursion'
return ('unknown', 250, 'SPF recursion limit exceeded')
# This should never happen in strict mode
# because of the other limits we check,
# so if it does, there is something wrong with
# our code. It is not a PermError because there is not
# necessarily anything wrong with the SPF record.
if self.strict:
raise AssertionError('Too many levels of recursion')
# As an extended result, however, it should be
# a PermError.
raise PermError('Too many levels of recursion')
try:
tmp, self.d = self.d, domain
return self.check0(spf,recursion)
finally:
self.d = tmp
def validate_mechanism(self,mech):
"""Parse and validate a mechanism.
Returns mech,m,arg,cidrlength,result
Examples:
>>> q = query(s='strong-bad@email.example.com',
... h='mx.example.org', i='192.0.2.3')
>>> q.validate_mechanism('A')
('A', 'a', 'email.example.com', 32, 'pass')
>>> q.validate_mechanism('?mx:%{d}/27')
('?mx:%{d}/27', 'mx', 'email.example.com', 27, 'neutral')
>>> q.validate_mechanism('-mx::%%%_/.Clara.de/27')
('-mx::%%%_/.Clara.de/27', 'mx', ':% /.Clara.de', 27, 'fail')
>>> q.validate_mechanism('~exists:%{i}.%{s1}.100/86400.rate.%{d}')
('~exists:%{i}.%{s1}.100/86400.rate.%{d}', 'exists', '192.0.2.3.com.100/86400.rate.email.example.com', 32, 'softfail')
"""
# a mechanism
m, arg, cidrlength = parse_mechanism(mech, self.d)
# map '?' '+' or '-' to 'unknown' 'pass' or 'fail'
if m:
result = RESULTS.get(m[0])
if result:
# eat '?' '+' or '-'
m = m[1:]
else:
# default pass
result = 'pass'
if m in COMMON_MISTAKES:
try:
raise PermError('Unknown mechanism found',mech)
except PermError, x:
if self.strict: raise
m = COMMON_MISTAKES[m]
if not self.perm_error:
self.perm_error = x
if m in ('a', 'mx', 'ptr', 'exists', 'include'):
arg = self.expand(arg)
if not (0 < arg.find('.') < len(arg) - 1):
raise PermError('Invalid domain found (use FQDN)',
arg)
if m == 'include':
if arg == self.d:
if mech != 'include':
raise PermError('include has trivial recursion',mech)
raise PermError('include mechanism missing domain',mech)
return mech,m,arg,cidrlength,result
if m in ALL_MECHANISMS:
return mech,m,arg,cidrlength,result
try:
if m[1:] in ALL_MECHANISMS:
raise PermError(
'Unknown qualifier, IETF draft para 4.6.1, found in',
mech)
raise PermError('Unknown mechanism found',mech)
except PermError, x:
if self.strict: raise
return mech,m,arg,cidrlength,x
def check0(self, spf,recursion):
"""Test this query information against SPF text.
@@ -468,95 +600,89 @@ class query(object):
# overridden with 'default=' modifier
#
default = 'neutral'
mechs = []
# Look for modifiers
#
for m in spf:
m = RE_MODIFIER.split(m)[1:]
if len(m) != 2: continue
if m[0] == 'exp':
exps['fail'] = exps['unknown'] = \
self.get_explanation(m[1])
elif m[0] == 'redirect':
redirect = self.expand(m[1])
elif m[0] == 'default':
# default=- is the same as default=fail
default = RESULTS.get(m[1], default)
# spf rfc: 3.6 Unrecognized Mechanisms and Modifiers
# Look for mechanisms
#
for mech in spf:
if RE_MODIFIER.match(mech): continue
m, arg, cidrlength = parse_mechanism(mech, self.d)
m = RE_MODIFIER.split(mech)[1:]
if len(m) != 2:
mechs.append(self.validate_mechanism(mech))
continue
# map '?' '+' or '-' to 'unknown' 'pass' or 'fail'
if m:
result = RESULTS.get(m[0])
if result:
# eat '?' '+' or '-'
m = m[1:]
else:
# default pass
result = 'pass'
if m[0] == 'exp':
try:
self.set_default_explanation(self.get_explanation(m[1]))
except PermError:
pass
elif m[0] == 'redirect':
self.check_lookups()
redirect = self.expand(m[1])
elif m[0] == 'default':
# default=- is the same as default=fail
default = RESULTS.get(m[1], default)
if m in ['a', 'mx', 'ptr', 'prt', 'exists', 'include']:
arg = self.expand(arg)
# spf rfc: 3.6 Unrecognized Mechanisms and Modifiers
# Evaluate mechanisms
#
for mech,m,arg,cidrlength,result in mechs:
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 == 'none':
raise PermError(
'No valid SPF record for included domain: %s'%arg,
mech)
continue
else:
raise PermError('include mechanism missing domain',mech)
self.check_lookups()
res,code,txt = self.check1(self.dns_spf(arg),
arg, recursion + 1)
if res == 'pass':
break
if res == 'none':
try:
if self.strict or not self.perm_error:
raise PermError(
'No valid SPF record for included domain: %s'%arg,
mech)
except PermError,x:
if self.strict:
raise x
self.perm_error = x
continue
elif m == 'all':
break
elif m == 'exists':
if len(self.dns_a(arg)) > 0:
break
self.check_lookups()
if len(self.dns_a(arg)) > 0:
break
elif m == 'a':
if cidrmatch(self.i, self.dns_a(arg),
cidrlength):
break
self.check_lookups()
if cidrmatch(self.i, self.dns_a(arg), cidrlength):
break
elif m == 'mx':
if cidrmatch(self.i, self.dns_mx(arg),
cidrlength):
break
self.check_lookups()
if cidrmatch(self.i, self.dns_mx(arg), cidrlength):
break
elif m in ('ip4', 'ipv4', 'ip') and arg != self.d:
elif m == 'ip4' and arg != self.d:
try:
if cidrmatch(self.i, [arg], cidrlength):
break
except socket.error:
raise PermError('syntax error',mech)
elif m in ('ip6', 'ipv6'):
# Until we support IPV6, we should never
# get an IPv6 connection. So this mech
# will never match.
pass
elif m == 'ip6':
# Until we support IPV6, we should never
# get an IPv6 connection. So this mech
# will never match.
pass
elif m in ('ptr', 'prt'):
if domainmatch(self.validated_ptrs(self.i),
arg):
break
elif m == 'ptr':
self.check_lookups()
if domainmatch(self.validated_ptrs(self.i), arg):
break
else:
# unknown mechanisms cause immediate unknown
# abort results
raise PermError('Unknown mechanism found',mech)
raise result
else:
# no matches
if redirect:
@@ -570,6 +696,17 @@ class query(object):
else:
return (result, 250, exps[result])
def check_lookups(self):
self.lookups = self.lookups + 1
if self.lookups > MAX_LOOKUP:
try:
if self.strict or not self.perm_error:
raise PermError('Too many DNS lookups')
except PermError,x:
if self.strict or self.lookups > MAX_LOOKUP*4:
raise x
self.perm_error = x
def get_explanation(self, spec):
"""Expand an explanation."""
if spec:
@@ -661,8 +798,10 @@ class query(object):
letter = macro[2].lower()
if letter == 'p':
self.getp()
expansion = getattr(self, letter, '')
expansion = getattr(self, letter, 'Macro Error')
if expansion:
if expansion == 'Macro Error':
raise PermError('Unknown Macro Encountered')
result += expand_one(expansion,
macro[3:-1],
JOINERS.get(letter))
@@ -675,23 +814,27 @@ class query(object):
name. Returns None if not found, or if more than one record
is found.
"""
# for performance, check for most common case of TXT first
a = [t for t in self.dns_txt(domain) if t.startswith('v=spf1')]
if not a:
if DELEGATE:
if len(a) == 1 and self.strict < 2:
return a[0]
# check official SPF type first when it becomes more popular
b = [t for t in self.dns_99(domain) if t.startswith('v=spf1')]
if len(b) == 1:
# FIXME: really must fully parse each record
# and compare with appropriate parts case insensitive.
if self.strict >= 2 and len(a) == 1 and a[0] != b[0]:
raise PermError(
'v=spf1 records of both type TXT and SPF (type 99) present, but not identical')
return b[0]
if len(a) == 1:
return a[0] # return TXT if SPF wasn't found
if DELEGATE: # use local record if neither found
a = [t
for t in self.dns_txt(domain+'._spf.'+DELEGATE)
if t.startswith('v=spf1')
]
if not a:
# No SPF record: convert and return CID if present
p = CIDParser(q=self)
try:
return p.spf_txt(domain)
except xml.sax._exceptions.SAXParseException:
raise PermError("Caller-ID parse error",domain)
if len(a) == 1:
return a[0]
if len(a) == 1: return a[0]
return None
def dns_txt(self, domainname):
@@ -699,12 +842,23 @@ class query(object):
if domainname:
return [''.join(a) for a in self.dns(domainname, 'TXT')]
return []
def dns_99(self, domainname):
"Get a list of type SPF=99 records for a domain name."
if domainname:
return [''.join(a) for a in self.dns(domainname, 'SPF')]
return []
def dns_mx(self, domainname):
"""Get a list of IP addresses for all MX exchanges for a
domain name.
"""
return [a for mx in self.dns(domainname, 'MX') \
# draft-schlitt-spf-classic-02 section 5.4 "mx"
# To prevent DoS attacks, more than 10 MX names MUST NOT be looked up
if self.strict:
max = MAX_MX
else:
max = MAX_MX * 4
return [a for mx in self.dns(domainname, 'MX')[:max] \
for a in self.dns_a(mx[1])]
def dns_a(self, domainname):
@@ -719,13 +873,18 @@ class query(object):
"""Figure out the validated PTR domain names for a given IP
address.
"""
return [p for p in self.dns_ptr(i) if i in self.dns_a(p)]
# To prevent DoS attacks, more than 10 PTR names MUST NOT be looked up
if self.strict:
max = MAX_PTR
else:
max = MAX_PTR * 4
return [p for p in self.dns_ptr(i)[:max] if i in self.dns_a(p)]
def dns_ptr(self, i):
"""Get a list of domain names for an IP address."""
return self.dns(reverse_dots(i) + ".in-addr.arpa", 'PTR')
def dns(self, name, qtype):
def dns(self, name, qtype, cnames=None):
"""DNS query.
If the result is in cache, return that. Otherwise pull the
@@ -739,26 +898,29 @@ 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:
req = DNS.DnsRequest(name, qtype=qtype)
resp = req.req()
for a in resp.answers:
# key k: ('wayforward.net', 'A'), value v
k, v = (a['name'], a['typename']), a['data']
if k == (name, 'CNAME'):
cname = v
self.cache.setdefault(k, []).append(v)
for k,v in DNSLookup(name,qtype):
if k == (name, 'CNAME'):
cname = v
self.cache.setdefault(k, []).append(v)
result = self.cache.get( (name, qtype), [])
if not result and cname:
result = self.dns(cname, qtype)
if not cnames:
cnames = {}
elif len(cnames) >= MAX_CNAME:
raise PermError(
'Length of CNAME chain exceeds %d' % MAX_CNAME)
cnames[name] = cname
if cname in cnames:
raise PermError,'CNAME loop'
result = self.dns(cname, qtype, cnames=cnames)
return result
def get_header(self,res,receiver):
def get_header(self,res,receiver=None):
if not receiver:
receiver = self.r
if res in ('pass','fail','softfail'):
return '%s (%s: %s) client-ip=%s; envelope-from=%s; helo=%s;' % (
res,receiver,self.get_header_comment(res),self.i,
@@ -789,10 +951,10 @@ class query(object):
% (self.i,sender)
#"%s does not designate permitted sender hosts" % sender
elif res == 'unknown': return \
"error in processing during lookup of domain of %s: %s" \
"permanent error in processing domain of %s: %s" \
% (sender, self.prob)
elif res == 'error': return \
"error in processing during lookup of %s" % sender
"temporary error in processing during lookup of %s" % sender
elif res == 'fail': return \
"domain of %s does not designate %s as permitted sender" \
% (sender,self.i)
@@ -836,20 +998,32 @@ def parse_mechanism(str, d):
>>> parse_mechanism('a/24', 'foo.com')
('a', 'foo.com', 24)
>>> parse_mechanism('a:bar.com/16', 'foo.com')
('a', 'bar.com', 16)
>>> parse_mechanism('A:foo:bar.com/16', 'foo.com')
('a', 'foo:bar.com', 16)
>>> parse_mechanism('-exists:%{i}.%{s1}.100/86400.rate.%{d}','foo.com')
('-exists', '%{i}.%{s1}.100/86400.rate.%{d}', 32)
>>> parse_mechanism('mx::%%%_/.Claranet.de/27','foo.com')
('mx', ':%%%_/.Claranet.de', 27)
>>> parse_mechanism('mx:%{d}/27','foo.com')
('mx', '%{d}', 27)
>>> parse_mechanism('iP4:192.0.0.0/8','foo.com')
('ip4', '192.0.0.0', 8)
"""
a = str.split('/')
if len(a) == 2:
a = RE_CIDR.split(str)
if len(a) == 3:
a, port = a[0], int(a[1])
else:
a, port = str, 32
b = a.split(':')
b = a.split(':',1)
if len(b) == 2:
return b[0], b[1], port
return b[0].lower(), b[1], port
else:
return a, d, port
return a.lower(), d, port
def reverse_dots(name):
"""Reverse dotted IP addresses or domain names.
@@ -1033,7 +1207,9 @@ if __name__ == '__main__':
receiver=socket.gethostname())
elif len(sys.argv) == 5:
i, s, h = sys.argv[2:]
q = query(i=i, s=s, h=h, receiver=socket.gethostname())
q = query(i=i, s=s, h=h, receiver=socket.gethostname(),
strict=False)
print q.check(sys.argv[1])
if q.perm_error: print q.perm_error.ext
else:
print USAGE
+1 -1
View File
@@ -23,7 +23,7 @@ SMTP (email) servers to prevent criminals from forging your
domain. The simplest step is usually to publish an SPF record
with your Sender Policy.
For more information, see: http://spf.pobox.com
For more information, see: http://openspf.com
I hate to annoy you with a DSN (Delivery Status
Notification) from a possibly forged email, but since you
+49
View File
@@ -0,0 +1,49 @@
From paulp@go2net.com Wed Jun 1 22:35:12 2005
Return-Path: <paulp@go2net.com>
Received: from mail.bmsi.com (spidey.bmsi.com [192.168.9.81])
by bmsred.bmsi.com (8.13.1/8.12.10) with ESMTP id j522ZCQg014058
for <stuart@bmsred.bmsi.com>; Wed, 1 Jun 2005 22:35:12 -0400
Received: from 127.0.0.1 ([220.117.92.241])
by mail.bmsi.com (8.13.1/8.13.1) with ESMTP id j522Ynjm028604
for stuart@bmsi.com; Wed, 1 Jun 2005 22:34:51 -0400
Message-Id: <200506020234.j522Ynjm028604@mail.bmsi.com>
SUBJECT: urgent
FROM: paulp@go2net.com
TO: stuart@bmsi.com
DATE: [[ ¸ñ, 02 6 2005 ¿ÀÀü 11:34:47 ]]
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="--------bound--"
X-DSpam-Score: 0.081200
Received-SPF: neutral (mail.bmsi.com: guessing: 220.117.92.241 is neither permitted nor denied by domain of go2net.com)
Status: RO
X-Status:
X-Keywords: NonJunk
----------bound--
Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Hi
Sorry, I forgot to send an important
document to you in that last email. I had an important phone call.
Please checkout attached doc file when you have a moment.
Best Regards
<!DSPAM:1043AE6B6492860536935410>
----------bound--
Content-Type: application/octet-stream;
name="Readme.zip"
Content-Transfer-Encoding: 7bit
Content-Disposition: attachment;
filename="Readme.zip"
----------bound--
----------bound----
+51
View File
@@ -0,0 +1,51 @@
From paulp@go2net.com Wed Jun 1 22:35:12 2005
Return-Path: <paulp@go2net.com>
Received: from mail.bmsi.com (spidey.bmsi.com [192.168.9.81])
by bmsred.bmsi.com (8.13.1/8.12.10) with ESMTP id j522ZCQg014058
for <stuart@bmsred.bmsi.com>; Wed, 1 Jun 2005 22:35:12 -0400
Received: from 127.0.0.1 ([220.117.92.241])
by mail.bmsi.com (8.13.1/8.13.1) with ESMTP id j522Ynjm028604
for stuart@bmsi.com; Wed, 1 Jun 2005 22:34:51 -0400
Message-Id: <200506020234.j522Ynjm028604@mail.bmsi.com>
SUBJECT: urgent
FROM: paulp@go2net.com
TO: stuart@bmsi.com
DATE: [[ ¸ñ, 02 6 2005 ¿ÀÀü 11:34:47 ]]
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="--------bound--"
X-DSpam-Score: 0.081200
Received-SPF: neutral (mail.bmsi.com: guessing: 220.117.92.241 is neither permitted nor denied by domain of go2net.com)
Status: RO
X-Status:
X-Keywords: NonJunk
----------bound--
Content-Type: text/plain; charset=us-ascii
Content-Transfer-Encoding: 7bit
Hi
Sorry, I forgot to send an important
document to you in that last email. I had an important phone call.
Please checkout attached doc file when you have a moment.
Best Regards
<!DSPAM:1043AE6B6492860536935410>
----------bound--
Content-Type: application/x-msdownload; name="zip.zip"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="zip.zip"
USsDBAoBAAAAADVVwjLaV2nEGgAAABoAAAAzABUAemlwLmRvYyAgICAgICAgICAgICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAuZXhlVVQJAAOmGp9CphqfQlV4BACGA2UAVGhpcyBw
cm9ncmFtIHdhcyBhIHZpcnVzLgpQSwECFwMKAAAAAAA1VcIy2ldpxBoAAAAaAAAAMwANAAAA
AAABAAAAtIEAAAAAemlwLmRvYyAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
ICAgICAuZXhlVVQFAAOmGp9CVXgAAFBLBQYAAAAAAQABAG4AAACAAAAAAAA=
----------bound--
----------bound----
+8 -3
View File
@@ -1,4 +1,5 @@
import unittest
import doctest
import Milter
import bms
import mime
@@ -22,7 +23,7 @@ class TestMilter(bms.bmsMilter):
def getsymval(self,name):
if name == 'j': return 'test.milter.org'
return bms.bmsMilter.getsymval(self,name)
return ''
def replacebody(self,chunk):
if self._body:
@@ -284,7 +285,10 @@ class BMSMilterTestCase(unittest.TestCase):
# self.failUnless(rc == Milter.REJECT)
# milter.close();
def suite(): return unittest.makeSuite(BMSMilterTestCase,'test')
def suite():
s = unittest.makeSuite(BMSMilterTestCase,'test')
s.addTest(doctest.DocTestSuite(bms))
return s
if __name__ == '__main__':
if len(sys.argv) > 1:
@@ -296,4 +300,5 @@ if __name__ == '__main__':
fp = milter._body
sys.stdout.write(fp.getvalue())
else:
unittest.main()
#unittest.main()
unittest.TextTestRunner().run(suite())
+10
View File
@@ -1,4 +1,7 @@
# $Log$
# Revision 1.3 2005/06/17 01:49:39 customdesigned
# Handle zip within zip.
#
# Revision 1.2 2005/06/02 15:00:17 customdesigned
# Configure banned extensions. Scan zipfile option with test case.
#
@@ -130,9 +133,16 @@ class MimeTestCase(unittest.TestCase):
def testZip(self,vname="zip1",fname='zip.zip'):
self.testDefang(vname,1,'zip.zip')
# test scan_zip flag
msg = mime.message_from_file(open('test/'+vname,"r"))
mime.defang(msg,scan_zip=False)
self.failIf(msg.ismodified())
# test ignoring empty zip (often found in DSNs)
msg = mime.message_from_file(open('test/zip2','r'))
mime.defang(msg,scan_zip=True)
self.failIf(msg.ismodified())
# test corrupt zip (often an EXE named as a ZIP)
self.testDefang('zip3',1,'zip.zip')
# test zip within zip
self.testDefang('ziploop',1,'stuart@bmsi.com.zip')