From e31a51a536cf05ca0548a28d616f0f7c99f52993 Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Mon, 19 Oct 2026 16:53:21 -0400 Subject: [PATCH 1/2] 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. --- Lib/email/_header_value_parser.py | 5 +- Lib/test/test_email/test_policy.py | 60 +++++++++++++++++++ ...-10-19-17-06-00.gh-issue-107862.mB7kWe.rst | 3 + 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst diff --git a/Lib/email/_header_value_parser.py b/Lib/email/_header_value_parser.py index f396f4f9b32a9a..fed35c101c9b5e 100644 --- a/Lib/email/_header_value_parser.py +++ b/Lib/email/_header_value_parser.py @@ -3125,12 +3125,15 @@ def _fold_as_ew(to_encode, lines, maxlen, last_ew, ew_combine_allowed, charset, to_encode_word = to_encode[:text_space] encoded_word = _ew.encode(to_encode_word, charset=encode_as) excess = len(encoded_word) - remaining_space - while excess > 0: + while excess > 0 and len(to_encode_word) > 1: # Since the chunk to encode is guaranteed to fit into less than 100 characters, # shrinking it by one at a time shouldn't take long. to_encode_word = to_encode_word[:-1] encoded_word = _ew.encode(to_encode_word, charset=encode_as) excess = len(encoded_word) - remaining_space + # A single character may not fit even on a fresh line (a four byte + # UTF-8 sequence needs eight base64 characters). Emit it anyway, + # going over maxlen, rather than looping forever on an empty word. lines[-1] += encoded_word to_encode = to_encode[len(to_encode_word):] diff --git a/Lib/test/test_email/test_policy.py b/Lib/test/test_email/test_policy.py index 90e8e5580295f9..26d61d19600bc9 100644 --- a/Lib/test/test_email/test_policy.py +++ b/Lib/test/test_email/test_policy.py @@ -8,6 +8,7 @@ import email.generator import email.message from email import headerregistry +from test.support.hypothesis_helper import hypothesis def make_defaults(base_defaults, differences): defaults = base_defaults.copy() @@ -295,6 +296,65 @@ def test_short_maxlen_error(self): with self.assertRaises(email.errors.HeaderParseError): policy.fold("Subject", subject) + def test_wide_char_too_long_for_line_does_not_cause_inf_loop(self): + # A four byte UTF-8 character needs 12 characters in a q-encoded + # word, so with the RFC 2047 chrome and the leading space it cannot + # fit on a 20 character line at all. Rather than looping forever + # trying to shrink the word, the line is allowed to run over. + policy = email.policy.default.clone(max_line_length=20) + actual = policy.fold('Subject', '\U0001f600') + self.assertEqual(actual, 'Subject:\n =?utf-8?q?=F0=9F=98=80?=\n') + + # Pieces chosen to exercise folding: words, runs of whitespace, RFC 5322 + # specials, characters needing 2, 3 and 4 byte UTF-8 encodings, and the + # delimiters of RFC 2047 encoded words. The pieces cannot combine into a + # well-formed encoded word (there is no lone '?'). + _fold_pieces = hypothesis.strategies.sampled_from([ + 'a', 'word', 'longwordlongwordlongword', ' ', ' ', '\t', ',', ';', + ':', '(', ')', '<', '>', '"', '@', '\\', '.', '-', '_', '\xa0', + '\xe9', '\u0444', '\u3042', '\U0001f600', '=?', '?=', + ]) + + @hypothesis.given( + subject=hypothesis.strategies.lists( + _fold_pieces, max_size=12).map(''.join), + maxlen=(hypothesis.strategies.integers(1, 120) + | hypothesis.strategies.sampled_from([0, None])), + ) + # Looped forever before the single character case was handled. + @hypothesis.example(subject='\U0001f600', maxlen=20) + @hypothesis.example(subject='\U0001f600a', maxlen=16) + @hypothesis.example(subject='\U0001f600\U0001f600', maxlen=20) + # bpo-33529 and bpo-36564, the earlier infinite loops. + @hypothesis.example(subject='\u0105' * 12, maxlen=20) + @hypothesis.example(subject='Melt away the pounds! 1234567890', maxlen=9) + # Encoded words that must be recombined on reparse (test_generator). + @hypothesis.example(subject='aaaaaaaaa \u0444\u0444\u0444\u0444\u0444\u0444\u0444\u0444 ', maxlen=30) + @hypothesis.example(subject='aaa aaaaaaa aaa \u0444\u0444\u0444 \u0444\u0444\u0444\u0444 ', maxlen=30) + # No wrapping at all, and nothing but whitespace. + @hypothesis.example(subject='word \U0001f600 word', maxlen=None) + @hypothesis.example(subject='word \U0001f600 word', maxlen=0) + @hypothesis.example(subject=' ', maxlen=78) + @hypothesis.example(subject='', maxlen=78) + def test_fold_unstructured_round_trip(self, subject, maxlen): + policy = email.policy.default.clone(max_line_length=maxlen) + try: + folded = policy.fold('Subject', subject) + except email.errors.HeaderParseError: + # The utf-8 RFC 2047 chrome is 12 characters, so no encoded + # word fits on a line of 13 characters or fewer; see + # test_short_maxlen_error. Any other width must fold. + self.assertIsNotNone(maxlen) + self.assertLessEqual(maxlen, 13) + return + reparsed = email.message_from_string( + folded, policy=email.policy.default)['Subject'] + # Leading whitespace is dropped by the parser; everything else, + # including trailing whitespace, survives. + self.assertEqual(reparsed.strip(' \t'), subject.strip(' \t')) + if subject == subject.lstrip(' \t'): + self.assertEqual(reparsed, subject) + def test_verify_generated_headers(self): # Turning protection off allows header injection policy = email.policy.default.clone(verify_generated_headers=False) diff --git a/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst b/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst new file mode 100644 index 00000000000000..033a8ceaaf418e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-19-17-06-00.gh-issue-107862.mB7kWe.rst @@ -0,0 +1,3 @@ +Fix an infinite loop in :mod:`email` header folding when a single character's +:rfc:`2047` encoded word is wider than the policy's ``max_line_length``; the +line now exceeds the limit instead. From 1cb3b3b604d1154f66be5c162ed3d020ae3a536f Mon Sep 17 00:00:00 2001 From: Paul Ganssle Date: Wed, 21 Oct 2026 19:27:16 -0400 Subject: [PATCH 2/2] gh-107862: Note why the folding strategy avoids encoded word look-alikes The round trip property cannot cover text that is itself a well-formed encoded word, because such text is written out unencoded and decodes to something else on reparse. That is now tracked separately as gh-160148, so point at it from the strategy rather than carrying an expected failure here. --- Lib/test/test_email/test_policy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_email/test_policy.py b/Lib/test/test_email/test_policy.py index 26d61d19600bc9..ad467d4337e00f 100644 --- a/Lib/test/test_email/test_policy.py +++ b/Lib/test/test_email/test_policy.py @@ -308,7 +308,8 @@ def test_wide_char_too_long_for_line_does_not_cause_inf_loop(self): # Pieces chosen to exercise folding: words, runs of whitespace, RFC 5322 # specials, characters needing 2, 3 and 4 byte UTF-8 encodings, and the # delimiters of RFC 2047 encoded words. The pieces cannot combine into a - # well-formed encoded word (there is no lone '?'). + # well-formed encoded word (there is no lone '?'), since such text is + # not encoded and so does not round trip; that is gh-160148. _fold_pieces = hypothesis.strategies.sampled_from([ 'a', 'word', 'longwordlongwordlongword', ' ', ' ', '\t', ',', ';', ':', '(', ')', '<', '>', '"', '@', '\\', '.', '-', '_', '\xa0',