Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b3a3e94

Browse files
authored
Merge pull request #19 from ActiveState/cve-2015-20107
Address CVE-2015-20107 for mailcap
2 parents 6a89ff9 + 5c81519 commit b3a3e94

4 files changed

Lines changed: 303 additions & 3 deletions

File tree

Doc/library/mailcap.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,19 @@ standard. However, mailcap files are supported on most Unix systems.
5353
external condition (such as the machine architecture, or the window system in
5454
use) to determine whether or not the mailcap line applies. :func:`findmatch`
5555
will automatically check such conditions and skip the entry if the check fails.
56-
56+
57+
.. versionchanged:: 2.7.18.6
58+
59+
To prevent security issues with shell metacharacters (symbols that have
60+
special effects in a shell command line), ``findmatch`` will refuse
61+
to inject ASCII characters other than alphanumerics and ``@+=:,./-_``
62+
into the returned command line.
63+
64+
If a disallowed character appears in *filename*, ``findmatch`` will always
65+
return ``(None, None)`` as if no entry was found.
66+
If such a character appears elsewhere (a value in *plist* or in *MIMEtype*),
67+
``findmatch`` will ignore all mailcap entries which use that value.
68+
A :mod:`warning <warnings>` will be raised in either case.
5769

5870
.. function:: getcaps()
5971

Lib/mailcap.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
"""Mailcap file handling. See RFC 1524."""
22

33
import os
4+
import re
5+
import warnings
46

57
__all__ = ["getcaps","findmatch"]
68

9+
_find_unsafe = re.compile(ur'[^\xa1-\U0010FFFF\w@+=:,./-]').search
10+
11+
class UnsafeMailcapInput(Warning):
12+
"""Warning raised when refusing unsafe input"""
13+
714
# Part 1: top-level interface.
815

916
def getcaps():
@@ -144,15 +151,22 @@ def findmatch(caps, MIMEtype, key='view', filename="/dev/null", plist=[]):
144151
entry to use.
145152
146153
"""
154+
if _find_unsafe(filename):
155+
msg = "Refusing to use mailcap with filename %r. Use a safe temporary filename." % (filename,)
156+
warnings.warn(msg, UnsafeMailcapInput)
157+
return None, None
147158
entries = lookup(caps, MIMEtype, key)
148159
# XXX This code should somehow check for the needsterminal flag.
149160
for e in entries:
150161
if 'test' in e:
151162
test = subst(e['test'], filename, plist)
163+
if test is None:
164+
continue
152165
if test and os.system(test) != 0:
153166
continue
154167
command = subst(e[key], MIMEtype, filename, plist)
155-
return command, e
168+
if command is not None:
169+
return command, e
156170
return None, None
157171

158172
def lookup(caps, MIMEtype, key=None):
@@ -184,14 +198,23 @@ def subst(field, MIMEtype, filename, plist=[]):
184198
elif c == 's':
185199
res = res + filename
186200
elif c == 't':
201+
if _find_unsafe(MIMEtype):
202+
msg = "Refusing to substitute MIME type %r into a shell command." % (MIMEtype,)
203+
warnings.warn(msg, UnsafeMailcapInput)
204+
return None
187205
res = res + MIMEtype
188206
elif c == '{':
189207
start = i
190208
while i < n and field[i] != '}':
191209
i = i+1
192210
name = field[start:i]
193211
i = i+1
194-
res = res + findparam(name, plist)
212+
param = findparam(name, plist)
213+
if _find_unsafe(param):
214+
msg = "Refusing to substitute parameter %r (%s) into a shell command" % (param, name)
215+
warnings.warn(msg, UnsafeMailcapInput)
216+
return None
217+
res = res + param
195218
# XXX To do:
196219
# %n == number of parts if type is multipart/*
197220
# %F == list of alternating type and filename for parts

Lib/test/mailcap.txt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Mailcap file for test_mailcap; based on RFC 1524
2+
# Referred to by test_mailcap.py
3+
4+
#
5+
# This is a comment.
6+
#
7+
8+
application/frame; showframe %s; print="cat %s | lp"
9+
application/postscript; ps-to-terminal %s;\
10+
needsterminal
11+
application/postscript; ps-to-terminal %s; \
12+
compose=idraw %s
13+
application/x-dvi; xdvi %s
14+
application/x-movie; movieplayer %s; compose=moviemaker %s; \
15+
description="Movie"; \
16+
x11-bitmap="/usr/lib/Zmail/bitmaps/movie.xbm"
17+
application/*; echo "This is \"%t\" but \
18+
is 50 \% Greek to me" \; cat %s; copiousoutput
19+
20+
audio/basic; showaudio %s; compose=audiocompose %s; edit=audiocompose %s;\
21+
description="An audio fragment"
22+
audio/* ; /usr/local/bin/showaudio %t
23+
24+
image/rgb; display %s
25+
#image/gif; display %s
26+
image/x-xwindowdump; display %s
27+
28+
# The continuation char shouldn't \
29+
# make a difference in a comment.
30+
31+
message/external-body; showexternal %s %{access-type} %{name} %{site} \
32+
%{directory} %{mode} %{server}; needsterminal; composetyped = extcompose %s; \
33+
description="A reference to data stored in an external location"
34+
35+
text/richtext; shownonascii iso-8859-8 -e richtext -p %s; test=test "`echo \
36+
%{charset} | tr '[A-Z]' '[a-z]'`" = iso-8859-8; copiousoutput
37+
38+
video/mpeg; mpeg_play %s
39+
video/*; animate %s

Lib/test/test_mailcap.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import mailcap
2+
import os
3+
import copy
4+
import test.support
5+
import unittest
6+
import sys
7+
8+
# Location of mailcap file
9+
MAILCAPFILE = test.support.findfile("mailcap.txt")
10+
11+
# Dict to act as mock mailcap entry for this test
12+
# The keys and values should match the contents of MAILCAPFILE
13+
MAILCAPDICT = {
14+
'application/x-movie':
15+
[{'compose': 'moviemaker %s',
16+
'x11-bitmap': '"/usr/lib/Zmail/bitmaps/movie.xbm"',
17+
'description': '"Movie"',
18+
'view': 'movieplayer %s'}],
19+
'application/*':
20+
[{'copiousoutput': '',
21+
'view': 'echo "This is \\"%t\\" but is 50 \\% Greek to me" \\; cat %s'}],
22+
'audio/basic':
23+
[{'edit': 'audiocompose %s',
24+
'compose': 'audiocompose %s',
25+
'description': '"An audio fragment"',
26+
'view': 'showaudio %s'}],
27+
'video/mpeg':
28+
[{'view': 'mpeg_play %s'}],
29+
'application/postscript':
30+
[{'needsterminal': '', 'view': 'ps-to-terminal %s'},
31+
{'compose': 'idraw %s', 'view': 'ps-to-terminal %s'}],
32+
'application/x-dvi':
33+
[{'view': 'xdvi %s'}],
34+
'message/external-body':
35+
[{'composetyped': 'extcompose %s',
36+
'description': '"A reference to data stored in an external location"',
37+
'needsterminal': '',
38+
'view': 'showexternal %s %{access-type} %{name} %{site} %{directory} %{mode} %{server}'}],
39+
'text/richtext':
40+
[{'test': 'test "`echo %{charset} | tr \'[A-Z]\' \'[a-z]\'`" = iso-8859-8',
41+
'copiousoutput': '',
42+
'view': 'shownonascii iso-8859-8 -e richtext -p %s'}],
43+
'image/x-xwindowdump':
44+
[{'view': 'display %s'}],
45+
'audio/*':
46+
[{'view': '/usr/local/bin/showaudio %t'}],
47+
'video/*':
48+
[{'view': 'animate %s'}],
49+
'application/frame':
50+
[{'print': '"cat %s | lp"', 'view': 'showframe %s'}],
51+
'image/rgb':
52+
[{'view': 'display %s'}]
53+
}
54+
55+
56+
class HelperFunctionTest(unittest.TestCase):
57+
58+
def test_listmailcapfiles(self):
59+
# The return value for listmailcapfiles() will vary by system.
60+
# So verify that listmailcapfiles() returns a list of strings that is of
61+
# non-zero length.
62+
mcfiles = mailcap.listmailcapfiles()
63+
self.assertIsInstance(mcfiles, list)
64+
for m in mcfiles:
65+
self.assertIsInstance(m, str)
66+
with test.support.EnvironmentVarGuard() as env:
67+
# According to RFC 1524, if MAILCAPS env variable exists, use that
68+
# and only that.
69+
if "MAILCAPS" in env:
70+
env_mailcaps = env["MAILCAPS"].split(os.pathsep)
71+
else:
72+
env_mailcaps = ["/testdir1/.mailcap", "/testdir2/mailcap"]
73+
env["MAILCAPS"] = os.pathsep.join(env_mailcaps)
74+
mcfiles = mailcap.listmailcapfiles()
75+
self.assertEqual(env_mailcaps, mcfiles)
76+
77+
def test_readmailcapfile(self):
78+
# Test readmailcapfile() using test file. It should match MAILCAPDICT.
79+
with open(MAILCAPFILE, 'r') as mcf:
80+
d = mailcap.readmailcapfile(mcf)
81+
self.assertDictEqual(d, MAILCAPDICT)
82+
83+
def test_lookup(self):
84+
# Test without key
85+
expected = [{'view': 'mpeg_play %s'}, {'view': 'animate %s'}]
86+
actual = mailcap.lookup(MAILCAPDICT, 'video/mpeg')
87+
self.assertListEqual(expected, actual)
88+
89+
# Test with key
90+
key = 'compose'
91+
expected = [{'edit': 'audiocompose %s',
92+
'compose': 'audiocompose %s',
93+
'description': '"An audio fragment"',
94+
'view': 'showaudio %s'}]
95+
actual = mailcap.lookup(MAILCAPDICT, 'audio/basic', key)
96+
self.assertListEqual(expected, actual)
97+
98+
# Test on user-defined dicts without line numbers
99+
expected = [{'view': 'mpeg_play %s'}, {'view': 'animate %s'}]
100+
actual = mailcap.lookup(MAILCAPDICT, 'video/mpeg')
101+
self.assertListEqual(expected, actual)
102+
103+
def test_subst(self):
104+
plist = ['id=1', 'number=2', 'total=3']
105+
# test case: ([field, MIMEtype, filename, plist=[]], <expected string>)
106+
test_cases = [
107+
(["", "audio/*", "foo.txt"], ""),
108+
(["echo foo", "audio/*", "foo.txt"], "echo foo"),
109+
(["echo %s", "audio/*", "foo.txt"], "echo foo.txt"),
110+
(["echo %t", "audio/*", "foo.txt"], None),
111+
(["echo %t", "audio/wav", "foo.txt"], "echo audio/wav"),
112+
(["echo \\%t", "audio/*", "foo.txt"], "echo %t"),
113+
(["echo foo", "audio/*", "foo.txt", plist], "echo foo"),
114+
(["echo %{total}", "audio/*", "foo.txt", plist], "echo 3")
115+
]
116+
for tc in test_cases:
117+
self.assertEqual(mailcap.subst(*tc[0]), tc[1])
118+
119+
120+
class GetcapsTest(unittest.TestCase):
121+
122+
def test_mock_getcaps(self):
123+
# Test mailcap.getcaps() using mock mailcap file in this dir.
124+
# Temporarily override any existing system mailcap file by pointing the
125+
# MAILCAPS environment variable to our mock file.
126+
with test.support.EnvironmentVarGuard() as env:
127+
env["MAILCAPS"] = MAILCAPFILE
128+
caps = mailcap.getcaps()
129+
self.maxDiff = None
130+
self.assertDictEqual(caps, MAILCAPDICT)
131+
132+
def test_system_mailcap(self):
133+
# Test mailcap.getcaps() with mailcap file(s) on system, if any.
134+
caps = mailcap.getcaps()
135+
self.assertIsInstance(caps, dict)
136+
mailcapfiles = mailcap.listmailcapfiles()
137+
existingmcfiles = [mcf for mcf in mailcapfiles if os.path.exists(mcf)]
138+
if existingmcfiles:
139+
# At least 1 mailcap file exists, so test that.
140+
for (k, v) in caps.items():
141+
self.assertIsInstance(k, str)
142+
self.assertIsInstance(v, list)
143+
for e in v:
144+
self.assertIsInstance(e, dict)
145+
else:
146+
# No mailcap files on system. getcaps() should return empty dict.
147+
self.assertEqual({}, caps)
148+
149+
150+
class FindmatchTest(unittest.TestCase):
151+
152+
def test_findmatch(self):
153+
154+
# default findmatch arguments
155+
c = MAILCAPDICT
156+
fname = "foo.txt"
157+
plist = ["access-type=default", "name=john", "site=python.org",
158+
"directory=/tmp", "mode=foo", "server=bar"]
159+
audio_basic_entry = {
160+
'edit': 'audiocompose %s',
161+
'compose': 'audiocompose %s',
162+
'description': '"An audio fragment"',
163+
'view': 'showaudio %s'
164+
}
165+
audio_entry = {"view": "/usr/local/bin/showaudio %t"}
166+
video_entry = {'view': 'animate %s'}
167+
mpeg_entry = {'view': 'mpeg_play %s'}
168+
message_entry = {
169+
'composetyped': 'extcompose %s',
170+
'description': '"A reference to data stored in an external location"', 'needsterminal': '',
171+
'view': 'showexternal %s %{access-type} %{name} %{site} %{directory} %{mode} %{server}'
172+
}
173+
174+
# test case: (findmatch args, findmatch keyword args, expected output)
175+
# positional args: caps, MIMEtype
176+
# keyword args: key="view", filename="/dev/null", plist=[]
177+
# output: (command line, mailcap entry)
178+
cases = [
179+
([{}, "video/mpeg"], {}, (None, None)),
180+
([c, "foo/bar"], {}, (None, None)),
181+
([c, "video/mpeg"], {}, ('mpeg_play /dev/null', mpeg_entry)),
182+
([c, "audio/basic", "edit"], {}, ("audiocompose /dev/null", audio_basic_entry)),
183+
([c, "audio/basic", "compose"], {}, ("audiocompose /dev/null", audio_basic_entry)),
184+
([c, "audio/basic", "description"], {}, ('"An audio fragment"', audio_basic_entry)),
185+
([c, "audio/basic", "foobar"], {}, (None, None)),
186+
([c, "video/*"], {"filename": fname}, ("animate %s" % fname, video_entry)),
187+
([c, "audio/basic", "compose"],
188+
{"filename": fname},
189+
("audiocompose %s" % fname, audio_basic_entry)),
190+
([c, "audio/basic"],
191+
{"key": "description", "filename": fname},
192+
('"An audio fragment"', audio_basic_entry)),
193+
([c, "audio/*"], {"filename": fname}, (None, None)),
194+
([c, "audio/wav"], {"filename": fname}, ("/usr/local/bin/showaudio audio/wav", audio_entry)),
195+
([c, "message/external-body"],
196+
{"plist": plist},
197+
("showexternal /dev/null default john python.org /tmp foo bar", message_entry))
198+
]
199+
self._run_cases(cases)
200+
201+
@unittest.skipUnless(os.name == "posix", "Requires 'test' command on system")
202+
@unittest.skipIf(sys.platform == "vxworks", "'test' command is not supported on VxWorks")
203+
def test_test(self):
204+
# findmatch() will automatically check any "test" conditions and skip
205+
# the entry if the check fails.
206+
caps = {"test/pass": [{"test": "test 1 -eq 1"}],
207+
"test/fail": [{"test": "test 1 -eq 0"}]}
208+
# test case: (findmatch args, findmatch keyword args, expected output)
209+
# positional args: caps, MIMEtype, key ("test")
210+
# keyword args: N/A
211+
# output: (command line, mailcap entry)
212+
cases = [
213+
# findmatch will return the mailcap entry for test/pass because it evaluates to true
214+
([caps, "test/pass", "test"], {}, ("test 1 -eq 1", {"test": "test 1 -eq 1"})),
215+
# findmatch will return None because test/fail evaluates to false
216+
([caps, "test/fail", "test"], {}, (None, None))
217+
]
218+
self._run_cases(cases)
219+
220+
def _run_cases(self, cases):
221+
for c in cases:
222+
self.assertEqual(mailcap.findmatch(*c[0], **c[1]), c[2])
223+
224+
225+
if __name__ == '__main__':
226+
unittest.main()

0 commit comments

Comments
 (0)