------------------------------------------------------------
revno: 1315
committer: Mark Sapiro <msap...@value.net>
branch nick: 2.2
timestamp: Tue 2014-04-15 13:55:52 -0700
message:
  Added and modified various options regarding DMARC.  See the NEWS file.
modified:
  Mailman/Defaults.py.in
  Mailman/Gui/General.py
  Mailman/Gui/Privacy.py
  Mailman/Handlers/CleanseDKIM.py
  Mailman/Handlers/CookHeaders.py
  Mailman/Handlers/Moderate.py
  Mailman/Handlers/WrapMessage.py
  Mailman/MailList.py
  Mailman/Utils.py
  Mailman/Version.py
  Mailman/versions.py
  NEWS


--
lp:mailman/2.2
https://code.launchpad.net/~mailman-coders/mailman/2.2

Your team Mailman Checkins is subscribed to branch lp:mailman/2.2.
To unsubscribe from this branch go to 
https://code.launchpad.net/~mailman-coders/mailman/2.2/+edit-subscription
=== modified file 'Mailman/Defaults.py.in'
--- Mailman/Defaults.py.in	2014-04-06 16:54:19 +0000
+++ Mailman/Defaults.py.in	2014-04-15 20:55:52 +0000
@@ -108,10 +108,6 @@
 # expire that many seconds following their last use.
 AUTHENTICATION_COOKIE_LIFETIME = 0
 
-# The following must be set to Yes to enable the 'from is list' feature.
-# See DEFAULT_FROM_IS_LIST below.
-ALLOW_FROM_IS_LIST = No
-
 # Form lifetime is set against Cross Site Request Forgery.
 FORM_LIFETIME = hours(1)
 
@@ -1064,6 +1060,20 @@
 # moderators?
 DEFAULT_FORWARD_AUTO_DISCARDS = Yes
 
+# Shall dmarc_moderation_action be applied to messages From: domains with
+# a DMARC policy of quarantine as well as reject?
+DMARC_QUARANTINE_MODERATION_ACTION = Yes
+
+# Default action for posts whose From: address domain has a DMARC policy of
+# reject or quarantine.  See DEFAULT_FROM_IS_LIST below.  Whatever is set as
+# the default here precludes the list owner from setting a lower value.
+# 0 = Accept
+# 1 = Munge From
+# 2 = Wrap Message
+# 3 = Reject
+# 4 = Discard
+DEFAULT_DMARC_MODERATION_ACTION = 0
+
 # What shold happen to non-member posts which are do not match explicit
 # non-member actions?
 # 0 = Accept
@@ -1101,7 +1111,9 @@
 # Send goodbye messages to unsubscribed members?
 DEFAULT_SEND_GOODBYE_MSG = Yes
 
-# The following is a three way setting.
+# The following is a three way setting.  It sets the default for the list's
+# from_is_list policy which is applied to all posts except those for which a
+# dmarc_moderation_action other than accept applies.
 # 0 -> Do not rewrite the From: or wrap the message.
 # 1 -> Rewrite the From: header of posts replacing the posters address with
 #      that of the list.  Also see REMOVE_DKIM_HEADERS above.

=== modified file 'Mailman/Gui/General.py'
--- Mailman/Gui/General.py	2013-09-28 23:07:16 +0000
+++ Mailman/Gui/General.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2001-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 2001-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -153,25 +153,27 @@
              directive. eg.; [listname %%d] -> [listname 123]
                             (listname %%05d) -> (listname 00123)
              """)),
-          ]
-
-        if mm_cfg.ALLOW_FROM_IS_LIST:
-            rtn.append(
-                ('from_is_list', mm_cfg.Radio,
-                 (_('No'), _('Mung From'), _('Wrap Message')), 0,
-                 _("""Replace the sender with the list address to conform with
-                 policies like ADSP and DMARC.  It replaces the poster's
-                 address in the From: header with the list address and adds the
-                 poster to the Reply-To: header, but the anonymous_list and
-                 Reply-To: header munging settings below take priority.  If
-                 setting this to Yes, it is advised to set the MTA to DKIM sign
-                 all emails.""") +
-                 _("""<br>If this is set to Wrap Message, just wrap the message
-                 in an outer message From: the list with Content-Type:
-                 message/rfc822."""))
-              )
-
-        rtn.extend([
+
+            ('from_is_list', mm_cfg.Radio,
+             (_('No'), _('Munge From'), _('Wrap Message')), 0,
+             _("""Replace the sender with the list address to conform with
+             policies like DMARC."""),
+             _("""Replace the sender with the list address to conform with
+             policies like ADSP and DMARC.  It replaces the poster's
+             address in the From: header with the list address and adds the
+             poster to the Reply-To: header, but the anonymous_list and
+             Reply-To: header munging settings below take priority.  If
+             setting this to Yes, it is advised to set the MTA to DKIM sign
+             all emails.""") +
+             _("""<p>If this is set to Wrap Message, just wrap the message
+             in an outer message From: the list with Content-Type:
+             message/rfc822.""") +
+             _("""<p>If <a
+             href="?VARHELP=privacy/sender/dmarc_moderation_action">
+             dmarc_moderation_action</a> applies to this message with an
+             action other than Accept, that action rather than this is
+             applied""")),
+
             ('anonymous_list', mm_cfg.Radio, (_('No'), _('Yes')), 0,
              _("""Hide the sender of a message, replacing it with the list
              address (Removes From, Sender and Reply-To fields)""")),
@@ -392,7 +394,7 @@
              useful for selecting among alternative names of a host that has
              multiple addresses.""")),
 
-          ])
+          ]
 
         if mm_cfg.ALLOW_RFC2369_OVERRIDES:
             rtn.append(

=== modified file 'Mailman/Gui/Privacy.py'
--- Mailman/Gui/Privacy.py	2009-01-11 19:34:35 +0000
+++ Mailman/Gui/Privacy.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2001-2008 by the Free Software Foundation, Inc.
+# Copyright (C) 2001-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -167,6 +167,11 @@
             ]
 
         adminurl = mlist.GetScriptURL('admin', absolute=1)
+    
+        if mm_cfg.DMARC_QUARANTINE_MODERATION_ACTION:
+            quarantine = _('/Quarantine')
+        else:
+            quarantine = ''
         sender_rtn = [
             _("""When a message is posted to the list, a series of
             moderation steps are taken to decide whether a moderator must
@@ -244,6 +249,42 @@
              >rejection notice</a> to
              be sent to moderated members who post to this list.""")),
 
+            ('dmarc_moderation_action', mm_cfg.Radio,
+             (_('Accept'), _('Wrap Message'), _('Munge From'), _('Reject'),
+                 _('Discard')), 0,
+             _("""Action to take when anyone posts to the
+             list from a domain with a DMARC Reject%(quarantine)s Policy."""),
+
+             _("""<ul><li><b>Wrap Message</b> -- applies the <a
+             href="?VARHELP=general/from_is_list">from_is_list Wrap
+             Message</a> transformation to these messages.
+
+             <p><li><b>Munge From</b> -- applies the <a
+             href="?VARHELP=general/from_is_list">from_is_list Munge From</a>
+             transformation to these messages.
+
+             <p><li><b>Reject</b> -- this automatically rejects the message by
+             sending a bounce notice to the post's author.  The text of the
+             bounce notice can be <a
+             href="?VARHELP=privacy/sender/dmarc_moderation_notice"
+             >configured by you</a>.
+
+             <p><li><b>Discard</b> -- this simply discards the message, with
+             no notice sent to the post's author.
+             </ul>
+
+             <p>This setting takes precedence over the <a
+             href="?VARHELP=general/from_is_list"> from_is_list</a> setting
+             if the message is From: an affected domain and the setting is
+             other than Accept.""")),
+
+            ('dmarc_moderation_notice', mm_cfg.Text, (10, WIDTH), 1,
+             _("""Text to include in any
+             <a href="?VARHELP=privacy/sender/dmarc_moderation_action"
+             >rejection notice</a> to
+             be sent to anyone who posts to this list from a domain
+             with DMARC Reject/Quarantine Policy.""")),
+
             _('Non-member filters'),
 
             ('accept_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1,
@@ -451,6 +492,11 @@
         # an option.
         if property == 'subscribe_policy' and not mm_cfg.ALLOW_OPEN_SUBSCRIBE:
             val += 1
+        if (property == 'dmarc_moderation_action' and
+                val < mm_cfg.DEFAULT_DMARC_MODERATION_ACTION):
+            doc.addError(_("""dmarc_moderation_action must be >= the configured
+                           default value."""))
+            val = mm_cfg.DEFAULT_DMARC_MODERATION_ACTION
         setattr(mlist, property, val)
 
     # We need to handle the header_filter_rules widgets specially, but

=== modified file 'Mailman/Handlers/CleanseDKIM.py'
--- Mailman/Handlers/CleanseDKIM.py	2013-09-28 23:07:16 +0000
+++ Mailman/Handlers/CleanseDKIM.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2006-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 2006-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -31,11 +31,12 @@
 def process(mlist, msg, msgdata):
     if not mm_cfg.REMOVE_DKIM_HEADERS:
         return
-    if (mm_cfg.ALLOW_FROM_IS_LIST and
-            mm_cfg.REMOVE_DKIM_HEADERS == 1 and
-            mlist.from_is_list != 1):
-        return
-    del msg['domainkey-signature']
-    del msg['dkim-signature']
-    del msg['authentication-results']
+    if (mm_cfg.REMOVE_DKIM_HEADERS == 1 and
+           (msgdata.get('from_is_list') == 1 or
+            (mlist.from_is_list == 1 and msgdata.get('from_is_list') != 2)
+           )
+       ):
+        del msg['domainkey-signature']
+        del msg['dkim-signature']
+        del msg['authentication-results']
 

=== modified file 'Mailman/Handlers/CookHeaders.py'
--- Mailman/Handlers/CookHeaders.py	2014-04-09 02:16:48 +0000
+++ Mailman/Handlers/CookHeaders.py	2014-04-15 20:55:52 +0000
@@ -65,8 +65,8 @@
     return Header(s, charset, maxlinelen, header_name, continuation_ws)
 
 def change_header(name, value, mlist, msg, msgdata, delete=True, repl=True):
-    if (mm_cfg.ALLOW_FROM_IS_LIST and
-        mlist.from_is_list == 2 and
+    if ((msgdata.get('from_is_list') == 2 or
+        (msgdata.get('from_is_list') == 0 and mlist.from_is_list == 2)) and 
         not msgdata.get('_fasttrack')
        ):
         msgdata.setdefault('add_header', {})[name] = value
@@ -119,7 +119,7 @@
     change_header('Precedence', 'list',
                   mlist, msg, msgdata, repl=False)
     # Do we change the from so the list takes ownership of the email
-    if mm_cfg.ALLOW_FROM_IS_LIST and mlist.from_is_list and not fasttrack:
+    if (msgdata.get('from_is_list') or mlist.from_is_list) and not fasttrack:
         realname, email = parseaddr(msg['from'])
         if not realname:
             if mlist.isMember(email):
@@ -200,8 +200,8 @@
         # is already in From and Reply-To in this case and similarly for
         # a 'from is list' list.
         if mlist.personalize == 2 and mlist.reply_goes_to_list <> 1 \
-           and not mlist.anonymous_list and not (mlist.from_is_list and
-                                                 mm_cfg.ALLOW_FROM_IS_LIST):
+           and not mlist.anonymous_list and not (mlist.from_is_list or
+                                                 msgdata.get('from_is_list')):
             # Watch out for existing Cc headers, merge, and remove dups.  Note
             # that RFC 2822 says only zero or one Cc header is allowed.
             new = []

=== modified file 'Mailman/Handlers/Moderate.py'
--- Mailman/Handlers/Moderate.py	2013-11-19 04:53:43 +0000
+++ Mailman/Handlers/Moderate.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2001-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 2001-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -21,6 +21,7 @@
 import re
 from email.MIMEMessage import MIMEMessage
 from email.MIMEText import MIMEText
+from email.Utils import parseaddr
 
 from Mailman import mm_cfg
 from Mailman import Utils
@@ -49,7 +50,32 @@
 def process(mlist, msg, msgdata):
     if msgdata.get('approved'):
         return
-    # First of all, is the poster a member or not?
+    # Before anything else, check DMARC.
+    msgdata['from_is_list'] = 0
+    dn, addr = parseaddr(msg.get('from'))
+    if addr:
+        if Utils.IsDMARCProhibited(addr):
+            # Note that for dmarc_moderation_action, 0 = Accept, 
+            #    1 = Wrap, 2 = Munge, 3 = Reject, 4 = Discard
+            if mlist.dmarc_moderation_action == 1:
+                msgdata['from_is_list'] = 2
+            elif mlist.dmarc_moderation_action == 2:
+                msgdata['from_is_list'] = 1
+            elif mlist.dmarc_moderation_action == 3:
+                # Reject
+                text = mlist.dmarc_moderation_notice
+                if text:
+                    text = Utils.wrap(text)
+                else:
+                    text = Utils.wrap(_(
+"""You are not allowed to post to this mailing list From: a domain which
+publishes a DMARC policy of reject or quarantine, and your message has been
+automatically rejected.  If you think that your messages are being rejected in
+error, contact the mailing list owner at %(listowner)s."""))
+                raise Errors.RejectMessage, text
+            elif mlist.dmarc_moderation_action == 4:
+                raise Errors.DiscardMessage
+    # Then, is the poster a member or not?
     for sender in msg.get_senders():
         if mlist.isMember(sender):
             break

=== modified file 'Mailman/Handlers/WrapMessage.py'
--- Mailman/Handlers/WrapMessage.py	2013-09-28 23:07:16 +0000
+++ Mailman/Handlers/WrapMessage.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 2013 by the Free Software Foundation, Inc.
+# Copyright (C) 2013-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -35,7 +35,8 @@
 
 
 def process(mlist, msg, msgdata):
-    if not mm_cfg.ALLOW_FROM_IS_LIST or mlist.from_is_list != 2:
+    if not (msgdata.get('from_is_list') == 2 or
+            (mlist.from_is_list == 2 and msgdata.get('from_is_list') == 0)):
         return
 
     # There are various headers in msg that we don't want, so we basically

=== modified file 'Mailman/MailList.py'
--- Mailman/MailList.py	2013-09-28 23:07:16 +0000
+++ Mailman/MailList.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 1998-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 1998-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -391,6 +391,8 @@
         # 2==Discard
         self.member_moderation_action = 0
         self.member_moderation_notice = ''
+        self.dmarc_moderation_action = mm_cfg.DEFAULT_DMARC_MODERATION_ACTION
+        self.dmarc_moderation_notice = ''
         self.accept_these_nonmembers = []
         self.hold_these_nonmembers = []
         self.reject_these_nonmembers = []

=== modified file 'Mailman/Utils.py'
--- Mailman/Utils.py	2013-12-07 01:23:54 +0000
+++ Mailman/Utils.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 1998-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 1998-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -35,6 +35,7 @@
 import base64
 import random
 import urlparse
+import collections
 import htmlentitydefs
 import email.Header
 import email.Iterators
@@ -71,6 +72,13 @@
     True = 1
     False = 0
 
+try:
+    import dns.resolver
+    from dns.exception import DNSException
+    dns_resolver = True
+except ImportError:
+    dns_resolver = False
+
 EMPTYSTRING = ''
 UEMPTYSTRING = u''
 CR = '\r'
@@ -1114,3 +1122,90 @@
     else:
         return False
 
+
+# This takes an email address, and returns True if DMARC policy is p=reject
+# or possibly quarantine.
+def IsDMARCProhibited(email):
+    if not dns_resolver:
+         return False
+
+    email = email.lower()
+    at_sign = email.find('@')
+    if at_sign < 1:
+        return False
+    dmarc_domain = '_dmarc.' + email[at_sign+1:]
+
+    try:
+        resolver = dns.resolver.Resolver()
+        resolver.timeout = 3
+        resolver.lifetime = 5
+        txt_recs = resolver.query(dmarc_domain, dns.rdatatype.TXT)
+    except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
+        return False
+    except DNSException, e:
+        syslog('error',
+               'DNSException: Unable to query DMARC policy for %s (%s). %s',
+              email, dmarc_domain, e.__class__)
+        return False
+    else:
+# people are already being dumb, don't trust them to provide honest DNS
+# where the answer section only contains what was asked for, nor to include
+# CNAMEs before the values they point to.
+        full_record = ""
+        results_by_name = collections.defaultdict(list)
+        cnames = {}
+        want_names = set([dmarc_domain + '.'])
+        for txt_rec in txt_recs.response.answer:
+            if txt_rec.rdtype == dns.rdatatype.CNAME:
+                cnames[txt_rec.name.to_text()] = (
+                    txt_rec.items[0].target.to_text())
+            if txt_rec.rdtype != dns.rdatatype.TXT:
+                continue
+            results_by_name[txt_rec.name.to_text()].append(
+                "".join(txt_rec.items[0].strings))
+        expands = list(want_names)
+        seen = set(expands)
+        while expands:
+            item = expands.pop(0)
+            if item in cnames:
+                if cnames[item] in seen:
+                    continue # cname loop
+                expands.append(cnames[item])
+                seen.add(cnames[item])
+                want_names.add(cnames[item])
+                want_names.discard(item)
+
+        if len(want_names) != 1:
+            syslog('error',
+                   """multiple DMARC entries in results for %s,
+                   processing each to be strict""",
+                   dmarc_domain)
+        for name in want_names:
+            if name not in results_by_name:
+                continue
+            dmarcs = filter(lambda n: n.startswith('v=DMARC1;'),
+                            results_by_name[name])
+            if len(dmarcs) == 0:
+                return False
+            if len(dmarcs) > 1:
+                syslog('error',
+                       """RRset of TXT records for %s has %d v=DMARC1 entries;
+                       testing them all""",
+                        dmarc_domain, len(dmarc))
+            for entry in dmarcs:
+                if re.search(r'\bp=reject\b', entry, re.IGNORECASE):
+#                   syslog('info',
+#                       'DMARC lookup for %s (%s) found p=reject in %s = %s',
+#                       email, dmarc_domain, name, entry)
+                    return True
+
+                if (mm_cfg.DMARC_QUARANTINE_MODERATION_ACTION and
+                    re.search(r'\bp=quarantine\b', entry, re.IGNORECASE)):
+#                   syslog('info',
+#                     'DMARC lookup for %s (%s) found p=quarantine in %s = %s',
+#                           email, dmarc_domain, name, entry)
+                    return True
+
+    return False
+
+

=== modified file 'Mailman/Version.py'
--- Mailman/Version.py	2013-09-28 23:07:16 +0000
+++ Mailman/Version.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 1998-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 1998-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -37,7 +37,7 @@
                (REL_LEVEL << 4)  | (REL_SERIAL << 0))
 
 # config.pck schema version number
-DATA_FILE_VERSION = 103
+DATA_FILE_VERSION = 104
 
 # qfile/*.db schema version number
 QFILE_SCHEMA_VERSION = 3

=== modified file 'Mailman/versions.py'
--- Mailman/versions.py	2013-09-28 23:07:16 +0000
+++ Mailman/versions.py	2014-04-15 20:55:52 +0000
@@ -1,4 +1,4 @@
-# Copyright (C) 1998-2013 by the Free Software Foundation, Inc.
+# Copyright (C) 1998-2014 by the Free Software Foundation, Inc.
 #
 # This program is free software; you can redistribute it and/or
 # modify it under the terms of the GNU General Public License
@@ -399,6 +399,9 @@
     # the current GUI description model.  So, 0==Hold, 1==Reject, 2==Discard
     add_only_if_missing('member_moderation_action', 0)
     add_only_if_missing('member_moderation_notice', '')
+    add_only_if_missing('dmarc_moderation_action', 
+                        mm_cfg.DEFAULT_DMARC_MODERATION_ACTION)
+    add_only_if_missing('dmarc_moderation_notice', '')
     add_only_if_missing('new_member_options',
                         mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS)
     # Emergency moderation flag

=== modified file 'NEWS'
--- NEWS	2014-04-08 16:30:02 +0000
+++ NEWS	2014-04-15 20:55:52 +0000
@@ -51,6 +51,35 @@
 
 2.1.18 (xx-xxx-xxxx)
 
+  Dependencies
+
+    - There is a new dependency associated with the new Privacy options ->
+      Sender filters -> dmarc_moderation_action feature discussed below.
+      This requires that the dnspython <http://www.dnspython.org/> package
+      be available in Python.
+
+  New Features
+
+    - The from_is_list feature introduced in 2.1.16 is now unconditionally
+      available to list owners.  There is also, a new Privacy options ->
+      Sender filters -> dmarc_moderation_action feature which applies to list
+      messages where the From: address is in a domain which publishes a DMARC
+      policy of reject or possibly quarantine.  This is a list setting with
+      values of Accept, Wrap Message, Munge From, Reject or Discard. There is
+      a new DEFAULT_DMARC_MODERATION_ACTION configuration setting to set the
+      default for this, and the list admin UI is not able to set an action
+      which is 'less' than the default.  The prior ALLOW_FROM_IS_LIST setting
+      has been removed and is effectively always Yes. There is a new
+      DMARC_QUARANTINE_MODERATION_ACTION configuration setting which defaults
+      to Yes but can be set to No to exclude domains with DMARC policy of
+      quarantine from dmarc_moderation_action.
+
+      dmarc_moderation_action and from_is_list interact in the following way.
+      If the message is From: a domain to which dmarc_moderation_action applies
+      and if dmarc_moderation_action is other than Accept,
+      dmarc_moderation_action applies to that message.  Otherwise the
+      from_is_list action applies.
+
   i18n
 
     - Added missing <mm-digest-question-start> tag to French listinfo template.

_______________________________________________
Mailman-checkins mailing list
Mailman-checkins@python.org
Unsubscribe: 
https://mail.python.org/mailman/options/mailman-checkins/archive%40jab.org

Reply via email to