Skip to content

Commit beb0862

Browse files
committed
gh-107862: Fix infinite loop folding a character too wide for the line
When an encoded word does not fit in the remaining space of a line, _fold_as_ew shrinks the chunk being encoded one character at a time until it fits. Nothing stops it from shrinking the chunk to nothing, and an empty chunk makes no progress: the loop appends an empty encoded word and a new continuation line forever. This is reachable whenever a single character's encoded form is wider than a fresh continuation line, e.g. any character outside the BMP with max_line_length=20 (the q-encoded word is 24 characters). The two earlier fixes in this area (bpo-33529, bpo-36564) handled lines too short for the RFC 2047 chrome, but not lines too short for the chrome plus one character. This keeps at least one character in the chunk, so the loop always advances and the line runs over max_line_length instead. That matches what _refold_parse_tree already does when it cannot fold a part at all, and avoids raising for widths that work for every other input. The regression test pins the exact output, as the earlier two did. The Hypothesis property checks that folding any unstructured value at any width either raises HeaderParseError (only possible at widths of 13 or fewer, where no encoded word fits) or reparses to the same value modulo leading whitespace, which the parser drops.
1 parent 662beae commit beb0862

3 files changed

Lines changed: 67 additions & 1 deletion

File tree

‎Lib/email/_header_value_parser.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3125,12 +3125,15 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset,
31253125
to_encode_word = to_encode[:text_space]
31263126
encoded_word = _ew.encode(to_encode_word, charset=encode_as)
31273127
excess = len(encoded_word) - remaining_space
3128-
while excess > 0:
3128+
while excess > 0 and len(to_encode_word) > 1:
31293129
# Since the chunk to encode is guaranteed to fit into less than 100 characters,
31303130
# shrinking it by one at a time shouldn't take long.
31313131
to_encode_word = to_encode_word[:-1]
31323132
encoded_word = _ew.encode(to_encode_word, charset=encode_as)
31333133
excess = len(encoded_word) - remaining_space
3134+
# A single character may not fit even on a fresh line (a four byte
3135+
# UTF-8 sequence needs eight base64 characters). Emit it anyway,
3136+
# going over maxlen, rather than looping forever on an empty word.
31343137
lines[-1] += encoded_word
31353138
to_encode = to_encode[len(to_encode_word):]
31363139

‎Lib/test/test_email/test_policy.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import email.generator
99
import email.message
1010
from email import headerregistry
11+
from test.support.hypothesis_helper import hypothesis
1112

1213
def make_defaults(base_defaults, differences):
1314
defaults = base_defaults.copy()
@@ -295,6 +296,65 @@ def test_short_maxlen_error(self):
295296
with self.assertRaises(email.errors.HeaderParseError):
296297
policy.fold("Subject", subject)
297298

299+
def test_wide_char_too_long_for_line_does_not_cause_inf_loop(self):
300+
# A four byte UTF-8 character needs 12 characters in a q-encoded
301+
# word, so with the RFC 2047 chrome and the leading space it cannot
302+
# fit on a 20 character line at all. Rather than looping forever
303+
# trying to shrink the word, the line is allowed to run over.
304+
policy = email.policy.default.clone(max_line_length=20)
305+
actual = policy.fold('Subject', '\U0001f600')
306+
self.assertEqual(actual, 'Subject:\n =?utf-8?q?=F0=9F=98=80?=\n')
307+
308+
# Pieces chosen to exercise folding: words, runs of whitespace, RFC 5322
309+
# specials, characters needing 2, 3 and 4 byte UTF-8 encodings, and the
310+
# delimiters of RFC 2047 encoded words. The pieces cannot combine into a
311+
# well-formed encoded word (there is no lone '?').
312+
_fold_pieces = hypothesis.strategies.sampled_from([
313+
'a', 'word', 'longwordlongwordlongword', ' ', ' ', '\t', ',', ';',
314+
':', '(', ')', '<', '>', '"', '@', '\\', '.', '-', '_', '\xa0',
315+
'\xe9', '\u0444', '\u3042', '\U0001f600', '=?', '?=',
316+
])
317+
318+
@hypothesis.given(
319+
subject=hypothesis.strategies.lists(
320+
_fold_pieces, max_size=12).map(''.join),
321+
maxlen=(hypothesis.strategies.integers(1, 120)
322+
| hypothesis.strategies.sampled_from([0, None])),
323+
)
324+
# Looped forever before the single character case was handled.
325+
@hypothesis.example(subject='\U0001f600', maxlen=20)
326+
@hypothesis.example(subject='\U0001f600a', maxlen=16)
327+
@hypothesis.example(subject='\U0001f600\U0001f600', maxlen=20)
328+
# bpo-33529 and bpo-36564, the earlier infinite loops.
329+
@hypothesis.example(subject='\u0105' * 12, maxlen=20)
330+
@hypothesis.example(subject='Melt away the pounds! 1234567890', maxlen=9)
331+
# Encoded words that must be recombined on reparse (test_generator).
332+
@hypothesis.example(subject='aaaaaaaaa \u0444\u0444\u0444\u0444\u0444\u0444\u0444\u0444 ', maxlen=30)
333+
@hypothesis.example(subject='aaa aaaaaaa aaa \u0444\u0444\u0444 \u0444\u0444\u0444\u0444 ', maxlen=30)
334+
# No wrapping at all, and nothing but whitespace.
335+
@hypothesis.example(subject='word \U0001f600 word', maxlen=None)
336+
@hypothesis.example(subject='word \U0001f600 word', maxlen=0)
337+
@hypothesis.example(subject=' ', maxlen=78)
338+
@hypothesis.example(subject='', maxlen=78)
339+
def test_fold_unstructured_round_trip(self, subject, maxlen):
340+
policy = email.policy.default.clone(max_line_length=maxlen)
341+
try:
342+
folded = policy.fold('Subject', subject)
343+
except email.errors.HeaderParseError:
344+
# The utf-8 RFC 2047 chrome is 12 characters, so no encoded
345+
# word fits on a line of 13 characters or fewer; see
346+
# test_short_maxlen_error. Any other width must fold.
347+
self.assertIsNotNone(maxlen)
348+
self.assertLessEqual(maxlen, 13)
349+
return
350+
reparsed = email.message_from_string(
351+
folded, policy=email.policy.default)['Subject']
352+
# Leading whitespace is dropped by the parser; everything else,
353+
# including trailing whitespace, survives.
354+
self.assertEqual(reparsed.strip(' \t'), subject.strip(' \t'))
355+
if subject == subject.lstrip(' \t'):
356+
self.assertEqual(reparsed, subject)
357+
298358
def test_verify_generated_headers(self):
299359
# Turning protection off allows header injection
300360
policy = email.policy.default.clone(verify_generated_headers=False)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix an infinite loop in :mod:`email` header folding when a single character's
2+
:rfc:`2047` encoded word is wider than the policy's ``max_line_length``; the
3+
line now exceeds the limit instead.

0 commit comments

Comments
 (0)