Skip to content

Commit 82f6c69

Browse files
authored
Merge branch '3.14' into backport-77bf4ba-3.14
2 parents ccf25a1 + 7877fe4 commit 82f6c69

10 files changed

Lines changed: 99 additions & 5 deletions

Lib/email/_header_value_parser.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ def make_quoted_pairs(value):
101101
return str(value).replace('\\', '\\\\').replace('"', '\\"')
102102

103103

104+
def make_parenthesis_pairs(value):
105+
"""Escape parenthesis and backslash for use within a comment."""
106+
return str(value).replace('\\', '\\\\') \
107+
.replace('(', '\\(').replace(')', '\\)')
108+
109+
104110
def quote_string(value):
105111
escaped = make_quoted_pairs(value)
106112
return f'"{escaped}"'
@@ -939,7 +945,7 @@ def value(self):
939945
return ' '
940946

941947
def startswith_fws(self):
942-
return True
948+
return self and self[0] in WSP
943949

944950

945951
class ValueTerminal(Terminal):
@@ -2959,6 +2965,13 @@ def _refold_parse_tree(parse_tree, *, policy):
29592965
[ValueTerminal(make_quoted_pairs(p), 'ptext')
29602966
for p in newparts] +
29612967
[ValueTerminal('"', 'ptext')])
2968+
if part.token_type == 'comment':
2969+
newparts = (
2970+
[ValueTerminal('(', 'ptext')] +
2971+
[ValueTerminal(make_parenthesis_pairs(p), 'ptext')
2972+
if p.token_type == 'ptext' else p
2973+
for p in newparts] +
2974+
[ValueTerminal(')', 'ptext')])
29622975
if not part.as_ew_allowed:
29632976
wrap_as_ew_blocked += 1
29642977
newparts.append(end_ew_not_allowed)

Lib/email/generator.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
NLCRE = re.compile(r'\r\n|\r|\n')
2323
fcre = re.compile(r'^From ', re.MULTILINE)
2424
NEWLINE_WITHOUT_FWSP = re.compile(r'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]')
25+
NEWLINE_WITHOUT_FWSP_BYTES = re.compile(br'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]')
2526

2627

2728
class Generator:
@@ -429,7 +430,16 @@ def _write_headers(self, msg):
429430
# This is almost the same as the string version, except for handling
430431
# strings with 8bit bytes.
431432
for h, v in msg.raw_items():
432-
self._fp.write(self.policy.fold_binary(h, v))
433+
folded = self.policy.fold_binary(h, v)
434+
if self.policy.verify_generated_headers:
435+
linesep = self.policy.linesep.encode()
436+
if not folded.endswith(linesep):
437+
raise HeaderWriteError(
438+
f'folded header does not end with {linesep!r}: {folded!r}')
439+
if NEWLINE_WITHOUT_FWSP_BYTES.search(folded.removesuffix(linesep)):
440+
raise HeaderWriteError(
441+
f'folded header contains newline: {folded!r}')
442+
self._fp.write(folded)
433443
# A blank line always separates headers from body
434444
self.write(self._NL)
435445

Lib/logging/handlers.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,11 @@ def shouldRollover(self, record):
196196
if self.stream is None: # delay was set...
197197
self.stream = self._open()
198198
if self.maxBytes > 0: # are we rolling over?
199-
pos = self.stream.tell()
199+
try:
200+
pos = self.stream.tell()
201+
except io.UnsupportedOperation:
202+
# gh-143237: Never rollover a named pipe.
203+
return False
200204
if not pos:
201205
# gh-116263: Never rollover an empty file
202206
return False

Lib/test/test_email/test__header_value_parser.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3294,6 +3294,29 @@ def test_address_list_with_specials_in_long_quoted_string(self):
32943294
with self.subTest(to=to):
32953295
self._test(parser.get_address_list(to)[0], folded, policy=policy)
32963296

3297+
def test_address_list_with_long_unwrapable_comment(self):
3298+
policy = self.policy.clone(max_line_length=40)
3299+
cases = [
3300+
# (to, folded)
3301+
('(loremipsumdolorsitametconsecteturadipi)<spy@example.org>',
3302+
'(loremipsumdolorsitametconsecteturadipi)<spy@example.org>\n'),
3303+
('<spy@example.org>(loremipsumdolorsitametconsecteturadipi)',
3304+
'<spy@example.org>(loremipsumdolorsitametconsecteturadipi)\n'),
3305+
('(loremipsum dolorsitametconsecteturadipi)<spy@example.org>',
3306+
'(loremipsum dolorsitametconsecteturadipi)<spy@example.org>\n'),
3307+
('<spy@example.org>(loremipsum dolorsitametconsecteturadipi)',
3308+
'<spy@example.org>(loremipsum\n dolorsitametconsecteturadipi)\n'),
3309+
('(Escaped \\( \\) chars \\\\ in comments stay escaped)<spy@example.org>',
3310+
'(Escaped \\( \\) chars \\\\ in comments stay\n escaped)<spy@example.org>\n'),
3311+
('((loremipsum)(loremipsum)(loremipsum)(loremipsum))<spy@example.org>',
3312+
'((loremipsum)(loremipsum)(loremipsum)(loremipsum))<spy@example.org>\n'),
3313+
('((loremipsum)(loremipsum)(loremipsum) (loremipsum))<spy@example.org>',
3314+
'((loremipsum)(loremipsum)(loremipsum)\n (loremipsum))<spy@example.org>\n'),
3315+
]
3316+
for (to, folded) in cases:
3317+
with self.subTest(to=to):
3318+
self._test(parser.get_address_list(to)[0], folded, policy=policy)
3319+
32973320
# XXX Need tests with comments on various sides of a unicode token,
32983321
# and with unicode tokens in the comments. Spaces inside the quotes
32993322
# currently don't do the right thing.

Lib/test/test_email/test_generator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ def test_flatten_unicode_linesep(self):
313313
self.assertEqual(s.getvalue(), self.typ(expected))
314314

315315
def test_verify_generated_headers(self):
316-
"""gh-121650: by default the generator prevents header injection"""
316+
# gh-121650: by default the generator prevents header injection
317317
class LiteralHeader(str):
318318
name = 'Header'
319319
def fold(self, **kwargs):
@@ -334,6 +334,8 @@ def fold(self, **kwargs):
334334

335335
with self.assertRaises(email.errors.HeaderWriteError):
336336
message.as_string()
337+
with self.assertRaises(email.errors.HeaderWriteError):
338+
message.as_bytes()
337339

338340

339341
class TestBytesGenerator(TestGeneratorBase, TestEmailBase):

Lib/test/test_email/test_policy.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ def test_short_maxlen_error(self):
296296
policy.fold("Subject", subject)
297297

298298
def test_verify_generated_headers(self):
299-
"""Turning protection off allows header injection"""
299+
# Turning protection off allows header injection
300300
policy = email.policy.default.clone(verify_generated_headers=False)
301301
for text in (
302302
'Header: Value\r\nBad: Injection\r\n',
@@ -319,6 +319,10 @@ def fold(self, **kwargs):
319319
message.as_string(),
320320
f"{text}\nBody",
321321
)
322+
self.assertEqual(
323+
message.as_bytes(),
324+
f"{text}\nBody".encode(),
325+
)
322326

323327
# XXX: Need subclassing tests.
324328
# For adding subclassed objects, make sure the usual rules apply (subclass

Lib/test/test_logging.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import codecs
2727
import configparser
28+
import contextlib
2829
import copy
2930
import datetime
3031
import pathlib
@@ -6343,6 +6344,32 @@ def test_should_not_rollover_non_file(self):
63436344
self.assertFalse(rh.shouldRollover(self.next_rec()))
63446345
rh.close()
63456346

6347+
@unittest.skipUnless(hasattr(os, "mkfifo"), 'requires os.mkfifo()')
6348+
def test_should_not_rollover_named_pipe(self):
6349+
# gh-143237 - test with non-seekable special file (named pipe)
6350+
filename = os_helper.TESTFN
6351+
self.addCleanup(os_helper.unlink, filename)
6352+
try:
6353+
os.mkfifo(filename)
6354+
except PermissionError as e:
6355+
self.skipTest('os.mkfifo(): %s' % e)
6356+
6357+
data = 'not read'
6358+
def other_side():
6359+
nonlocal data
6360+
with open(filename, 'rb') as f:
6361+
data = f.read()
6362+
6363+
thread = threading.Thread(target=other_side)
6364+
with threading_helper.start_threads([thread]):
6365+
rh = logging.handlers.RotatingFileHandler(
6366+
filename, encoding="utf-8", maxBytes=1)
6367+
with contextlib.closing(rh):
6368+
m = self.next_rec()
6369+
self.assertFalse(rh.shouldRollover(m))
6370+
rh.emit(m)
6371+
self.assertEqual(data.decode(), m.msg + os.linesep)
6372+
63466373
def test_should_rollover(self):
63476374
with open(self.fn, 'wb') as f:
63486375
f.write(b'\n')
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix support of named pipes in the rotating :mod:`logging` handlers.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Fixed a bug in the folding of comments when flattening an email message
2+
using a modern email policy. Comments consisting of a very long sequence of
3+
non-foldable characters could trigger a forced line wrap that omitted the
4+
required leading space on the continuation line, causing the remainder of
5+
the comment to be interpreted as a new header field. This enabled header
6+
injection with carefully crafted inputs.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:mod:`~email.generator.BytesGenerator` will now refuse to serialize (write) headers
2+
that are unsafely folded or delimited; see
3+
:attr:`~email.policy.Policy.verify_generated_headers`. (Contributed by Bas
4+
Bloemsaat and Petr Viktorin in :gh:`121650`).

0 commit comments

Comments
 (0)