# Copyright (C) 2002-2018 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
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301,
# USA.
"""MIME-stripping filter for Mailman.
This module scans a message for MIME content, removing those sections whose
MIME types match one of a list of matches. multipart/alternative sections are
replaced by the first non-empty component, and multipart/mixed sections
wrapping only single sections after other processing are replaced by their
contents.
"""
import os
import errno
import tempfile
from os.path import splitext
from email.Iterators import typed_subpart_iterator
from Mailman import mm_cfg
from Mailman import Errors
from Mailman.Message import UserNotification
from Mailman.Queue.sbcache import get_switchboard
from Mailman.Logging.Syslog import syslog
from Mailman.Version import VERSION
from Mailman.i18n import _
from Mailman.Utils import oneline
def process(mlist, msg, msgdata):
# Short-circuits
if not mlist.filter_content:
return
if msgdata.get('isdigest'):
return
# We also don't care about our own digests or plaintext
ctype = msg.get_content_type()
mtype = msg.get_content_maintype()
# Check to see if the outer type matches one of the filter types
filtertypes = mlist.filter_mime_types
passtypes = mlist.pass_mime_types
if ctype in filtertypes or mtype in filtertypes:
dispose(mlist, msg, msgdata,
_("The message's content type was explicitly disallowed"))
# Check to see if there is a pass types and the outer type doesn't match
# one of these types
if passtypes and not (ctype in passtypes or mtype in passtypes):
dispose(mlist, msg, msgdata,
_("The message's content type was not explicitly allowed"))
# Filter by file extensions
filterexts = mlist.filter_filename_extensions
passexts = mlist.pass_filename_extensions
fext = get_file_ext(msg)
if fext:
if fext in filterexts:
dispose(mlist, msg, msgdata,
_("The message's file extension was explicitly disallowed"))
if passexts and not (fext in passexts):
dispose(mlist, msg, msgdata,
_("The message's file extension was not explicitly allowed"))
numparts = len([subpart for subpart in msg.walk()])
# If the message is a multipart, filter out matching subparts
if msg.is_multipart():
# Recursively filter out any subparts that match the filter list
prelen = len(msg.get_payload())
filter_parts(msg, filtertypes, passtypes, filterexts, passexts)
# If the outer message is now an empty multipart (and it wasn't
# before!) then, again it gets discarded.
postlen = len(msg.get_payload())
if postlen == 0 and prelen > 0:
dispose(mlist, msg, msgdata,
_("After content filtering, the message was empty"))
# Now replace all multipart/alternatives with just the first non-empty
# alternative. BAW: We have to special case when the outer part is a
# multipart/alternative because we need to retain most of the outer part's
# headers. For now we'll move the subpart's payload into the outer part,
# and then copy over its Content-Type: and Content-Transfer-Encoding:
# headers (any others?).
if mlist.collapse_alternatives:
collapse_multipart_alternatives(msg)
if ctype == 'multipart/alternative':
firstalt = msg.get_payload(0)
reset_payload(msg, firstalt)
# Now that we've collapsed the MPA parts, go through the message
# and recast any multipart parts with only one sub-part as just
# the sub-part.
if msg.is_multipart():
recast_multipart(msg)
# If we removed some parts, make note of this
changedp = 0
if numparts <> len([subpart for subpart in msg.walk()]):
changedp = 1
# Now perhaps convert all text/html to text/plain
if mlist.convert_html_to_plaintext and mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND:
changedp += to_plaintext(msg)
# If we're left with only two parts, an empty body and one attachment,
# recast the message to one of just that part
if msg.is_multipart() and len(msg.get_payload()) == 2:
if msg.get_payload(0).get_payload() == '':
useful = msg.get_payload(1)
reset_payload(msg, useful)
changedp = 1
if changedp:
msg['X-Content-Filtered-By'] = 'Mailman/MimeDel %s' % VERSION
def reset_payload(msg, subpart):
# Reset payload of msg to contents of subpart, and fix up content headers
payload = subpart.get_payload()
msg.set_payload(payload)
del msg['content-type']
del msg['content-transfer-encoding']
del msg['content-disposition']
del msg['content-description']
msg['Content-Type'] = subpart.get('content-type', 'text/plain')
cte = subpart.get('content-transfer-encoding')
if cte:
msg['Content-Transfer-Encoding'] = cte
cdisp = subpart.get('content-disposition')
if cdisp:
msg['Content-Disposition'] = cdisp
cdesc = subpart.get('content-description')
if cdesc:
msg['Content-Description'] = cdesc
def filter_parts(msg, filtertypes, passtypes, filterexts, passexts):
# Look at all the message's subparts, and recursively filter
if not msg.is_multipart():
return 1
payload = msg.get_payload()
prelen = len(payload)
newpayload = []
for subpart in payload:
keep = filter_parts(subpart, filtertypes, passtypes,
filterexts, passexts)
if not keep:
continue
ctype = subpart.get_content_type()
mtype = subpart.get_content_maintype()
if ctype in filtertypes or mtype in filtertypes:
# Throw this subpart away
continue
if passtypes and not (ctype in passtypes or mtype in passtypes):
# Throw this subpart away
continue
# check file extension
fext = get_file_ext(subpart)
if fext:
if fext in filterexts:
continue
if passexts and not (fext in passexts):
continue
newpayload.append(subpart)
# Check to see if we discarded all the subparts
postlen = len(newpayload)
msg.set_payload(newpayload)
if postlen == 0 and prelen > 0:
# We threw away everything
return 0
return 1
def collapse_multipart_alternatives(msg):
if not msg.is_multipart():
return
newpayload = []
for subpart in msg.get_payload():
if subpart.get_content_type() == 'multipart/alternative':
try:
firstalt = subpart.get_payload(0)
if msg.get_content_type() == 'message/rfc822':
# This is a multipart/alternative message in a
# message/rfc822 part. We treat it specially so as not to
# lose the headers.
reset_payload(subpart, firstalt)
newpayload.append(subpart)
else:
newpayload.append(firstalt)
except (IndexError, TypeError):
pass
elif subpart.is_multipart():
collapse_multipart_alternatives(subpart)
newpayload.append(subpart)
else:
newpayload.append(subpart)
msg.set_payload(newpayload)
def recast_multipart(msg):
# If we're left with a multipart message with only one sub-part, recast
# the message to just the sub-part, but not if the part is message/rfc822
# because we don't want to lose the headers.
# Also, if this is a multipart/signed part, stop now as the original part
# may have had a multipart sub-part with only one sub-sub-part, the sig
# may still be valid and going further may break it. (LP: #1551075)
if msg.get_content_type() == 'multipart/signed':
return
if msg.is_multipart():
if (len(msg.get_payload()) == 1 and
msg.get_content_type() <> 'message/rfc822'):
reset_payload(msg, msg.get_payload(0))
# now that we've recast this part, check the subordinate parts
recast_multipart(msg)
else:
# This part's OK but check deeper.
for part in msg.get_payload():
recast_multipart(part)
def to_plaintext(msg):
changedp = 0
for subpart in typed_subpart_iterator(msg, 'text', 'html'):
filename = tempfile.mktemp('.html')
fp = open(filename, 'w')
try:
fp.write(subpart.get_payload(decode=1))
fp.close()
cmd = os.popen(mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND %
{'filename': filename})
plaintext = cmd.read()
rtn = cmd.close()
if rtn:
syslog('error', 'HTML->text/plain error: %s', rtn)
finally:
try:
os.unlink(filename)
except OSError, e:
if e.errno <> errno.ENOENT: raise
# Now replace the payload of the subpart and twiddle the Content-Type:
del subpart['content-transfer-encoding']
subpart.set_payload(plaintext)
subpart.set_type('text/plain')
changedp = 1
return changedp
def dispose(mlist, msg, msgdata, why):
# filter_action == 0 just discards, see below
if mlist.filter_action == 1:
# Bounce the message to the original author
raise Errors.RejectMessage, why
if mlist.filter_action == 2:
# Forward it on to the list owner
listname = mlist.internal_name()
mlist.ForwardMessage(
msg,
text=_("""\
The attached message matched the %(listname)s mailing list's content filtering
rules and was prevented from being forwarded on to the list membership. You
are receiving the only remaining copy of the discarded message.
"""),
subject=_('Content filtered message notification'))
if mlist.filter_action == 3 and \
mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES:
badq = get_switchboard(mm_cfg.BADQUEUE_DIR)
badq.enqueue(msg, msgdata)
# Most cases also discard the message
raise Errors.DiscardMessage
def get_file_ext(m):
"""
Get filename extension. Caution: some virus don't put filename
in 'Content-Disposition' header.
"""
fext = ''
filename = m.get_filename('') or m.get_param('name', '')
if filename:
fext = splitext(oneline(filename,'utf-8'))[1]
if len(fext) > 1:
fext = fext[1:]
else:
fext = ''
return fext.lower()