Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions Lib/email/_header_value_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -3199,14 +3199,23 @@ def _fold_mime_parameters(part, lines, maxlen, encoding):
# have that, we'd be stuck, so in that case fall back to
# the RFC standard width.
maxlen = 78
splitpoint = maxchars = maxlen - chrome_len - 2
while True:
maxchars = maxlen - chrome_len - 2
Comment on lines 3199 to +3202

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct me if I've misunderstood something, but I believe the simpler fix here is the following:

-               # have that, we'd be stuck, so in that case fall back to
-               # the RFC standard width.
-               maxlen = 78
+               # the RFC standard width, or the width of the chrome plus
+               # an arbitrary 10 if the parameter name is long.
+               maxlen = max(78, chrome_len + 10

With this change applied instead of yours, your test passes, at least. (I haven't reviewed the test yet; thought I'd post this first). My logic is that if we are already violating the user's request for maxwidth (and we already have a case where we're doing that), we might as well just make the new value something semi-reasonable, rather than "just enough" (one character more than the chrome).

If this isn't enough to protect against an infinite loop (and I haven't tried to reason through that yet, either), then we are going to need a better test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I tried maxlen = max(78, chrome_len + 10) in place of my change. It fixes the ASCII cases, but some non-ASCII values still hang, as @devdavidejesus also found:

import email.message
m = email.message.EmailMessage()
m.set_content('hi')
m.set_param('x' * 62, '\U0001F600')
m.as_string()  # never returns

Here the fallback isn't taken (maxlen > chrome_len + 3), but maxchars is only a few characters, and a single percent-encoded astral character needs 12 (%F0%9F%98%80). The inner loop ends at splitpoint == 0, writes an empty section, and value never gets shorter. Because chrome_len grows once the section number reaches 10, longer values hit this with even more names. In a sweep of names of 40–120 characters and 8 ASCII/non-ASCII values, main hangs in 62 of 112 cases, the max(78, chrome_len + 10) change in 8 (non-ASCII values, names of 60–63 characters), and this PR in none. Every value survives a round trip through message_from_string().

So I kept the forced one-character split. The side effect is that when even one encoded character doesn't fit, that section is written on its own line past maxlen (86 columns for the example above). Since the fallback already exceeds the requested width in this situation, that seemed acceptable to me, but I'm happy to combine both approaches if you prefer.

I've also rewritten the test. It now checks the exact folded output for inputs that hang on main (one of them also hangs with the maxlen change alone), and it checks that a range of long names and non-ASCII values survives a round trip.

splitpoint = maxchars = max(1, maxchars)
while splitpoint > 1:
partial = value[:splitpoint]
encoded_value = urllib.parse.quote(
partial, safe='', errors=error_handler)
if len(encoded_value) <= maxchars:
break
splitpoint -= 1
else:
# Even a single (encoded) character does not fit in the
# remaining room. Write it anyway, exceeding maxlen, so that
# the value always gets shorter and the loop terminates.
splitpoint = 1
partial = value[:splitpoint]
encoded_value = urllib.parse.quote(
partial, safe='', errors=error_handler)
lines.append(" {}*{}*={}{}".format(
name, section, extra_chrome, encoded_value))
extra_chrome = ''
Expand Down
39 changes: 39 additions & 0 deletions Lib/test/test_email/test_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,45 @@ def test_string_payload_with_multipart_content_type(self):
attachments = msg.iter_attachments()
self.assertEqual(list(attachments), [])

def test_fold_mime_parameters_long_name(self):
# gh-138223: _fold_mime_parameters() used to loop forever when the
# parameter name left no room for even one (encoded) character of
# the value. How long the name must be depends on the value.
name = 'x' * 62
m = self._make_message()
m.set_content('hi')
m.set_param(name, '\U0001F600')
self.assertEqual(
m['Content-Type'].fold(policy=m.policy),
'Content-Type: text/plain; charset="utf-8";\n'
f" {name}*0*=utf-8''%F0%9F%98%80\n")

name = 'x' * 66
m = self._make_message()
m.set_content('hi')
m.set_param(name, 'abcdefgh')
self.assertEqual(
m['Content-Type'].fold(policy=m.policy),
'Content-Type: text/plain; charset="utf-8";\n'
f" {name}*0*=us-ascii''a;\n"
f" {name}*1*=bcdefg;\n"
f" {name}*2*=h\n")

def test_fold_mime_parameters_long_name_round_trip(self):
# gh-138223
values = ['abcdefgh', 'abc def 123', 'b' * 100,
'\xe9', '\u65e5', '\U0001F600',
'\u65e5' * 20, '\U0001F600' * 20]
for name_len in (40, 60, 61, 62, 63, 64, 66, 70, 80, 100, 120):
for value in values:
with self.subTest(name_len=name_len, value=value):
name = 'x' * name_len
m = self._make_message()
m.set_content('hi')
m.set_param(name, value)
m2 = message_from_string(
m.as_string(), policy=policy.default)
self.assertEqual(m2['Content-Type'].params[name], value)

if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
:mod:`email.message`: fix an infinite loop in :meth:`EmailMessage.as_string
<email.message.EmailMessage.as_string>` when :meth:`~email.message.EmailMessage.add_attachment`
is called with very long parameter keys.

This could cause denial of service by hanging the application indefinitely
during MIME parameter folding and :rfc:`2231` encoding.
Loading