diff --git a/HOWTO b/HOWTO new file mode 100644 index 0000000..797c0c1 --- /dev/null +++ b/HOWTO @@ -0,0 +1,122 @@ + 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, TempError, PermError. Currently supported +policy keywords are OK, CBV, REJECT, TEMPFAIL, ERROR:"550 description". + +The default policies are as follows: + +SPF-Fail: REJECT +SPF-Softfail: CBV +SPF-Neutral: OK +SPF-PermError: REJECT +SPF-TempError: TEMPFAIL +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 ERROR:"550 AOL mail must get SPF PASS" +SPF-Softfail:aol.com ERROR:"550 AOL mail must get SPF PASS" + + To be continued. + +Forthcoming topics: + +SRS config + + +pydspam config +wiretap config diff --git a/TODO b/TODO index 27e7b32..a63cf7d 100644 --- a/TODO +++ b/TODO @@ -1,7 +1,17 @@ +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. diff --git a/bms.py b/bms.py index 551f63b..d3f1fa5 100644 --- a/bms.py +++ b/bms.py @@ -1,6 +1,9 @@ #!/usr/bin/env python # A simple milter that has grown quite a bit. # $Log$ +# Revision 1.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. # @@ -312,6 +315,7 @@ scan_rfc822 = True internal_connect = () trusted_relay = () internal_domains = () +banned_users = () hello_blacklist = () smart_alias = {} dspam_dict = None @@ -332,7 +336,6 @@ spf_accept_softfail = () spf_accept_fail = () spf_best_guess = False spf_reject_noptr = False -multiple_bounce_recipients = True time_format = '%Y%b%d %H:%M:%S %Z' timeout = 600 cbv_cache = {} @@ -496,7 +499,7 @@ def read_config(list): if srs_config: cp.read([srs_config]) srs_secret = cp.getdefault('srs','secret') if SRS and srs_secret: - global ses,srs,srs_reject_spoofed,srs_domain + 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') @@ -515,6 +518,7 @@ def read_config(list): else: srs_domain = [] srs_domain.append(cp.getdefault('srs','fwdomain')) + banned_users = cp.getlist('srs','banned_users') #print srs_domain def parse_addr(t): @@ -537,6 +541,8 @@ def parse_addr(t): 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 @@ -689,18 +695,37 @@ class bmsMilter(Milter.Milter): 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: - # any successful authentication is considered INTERNAL + # 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}'),'ssf =',self.getsymval('{auth_ssf}'), - "INTERNAL") + 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: @@ -859,34 +884,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 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 - 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 + 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 + 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) @@ -964,15 +986,20 @@ class bmsMilter(Milter.Milter): 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 @@ -1018,18 +1045,24 @@ class bmsMilter(Milter.Milter): self.fp.write("\n") # terminate headers self.fp.seek(0) # log when neither sender nor from domains matches mail from domain - mf_domain = self.canon_from.split('@')[-1] - 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: - self.log("NOTE: MFROM domain doesn't match From or Sender"); - for f in msg.getallmatchingheaders('from') \ - + msg.getallmatchingheaders('sender'): - self.log(f) - del msg + if self.mailfrom != '<>': + mf_domain = self.canon_from.split('@')[-1] + 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() diff --git a/milter.cfg b/milter.cfg index 11afe08..656ff27 100644 --- a/milter.cfg +++ b/milter.cfg @@ -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] @@ -88,6 +92,8 @@ reject_spoofed = 0 # 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 file or similar format for detailed spf policy +;access_file = /etc/mail/access.db # features intended to clean up outgoing mail [scrub] diff --git a/milter.html b/milter.html index f553061..5e9f732 100644 --- a/milter.html +++ b/milter.html @@ -24,7 +24,7 @@ ALT="Viewable With Any Browser" BORDER="0"> Stuart D. Gathman
This web page is written by Stuart D. Gathman
and
sponsored by Business Management Systems, Inc.
-Last updated Jul 20, 2005 +Last updated Aug 28, 2005 See the FAQ | Download now | Subscribe to mailing list | @@ -51,12 +51,13 @@ Python milter is being moved to pymilter Sourceforge project for development and release downloads.

-Release 0.8.2 has changes to SPF to bring it in line with the newly -official RFC. It adds SES 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. +Release 0.8.2 has changes to SPF to bring it +in line with the newly official RFC. It adds +SES +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.

Release 0.8.0 is the first Sourceforge release. It supports Python-2.4, and provides an option to accept mail