From 842ce19a0c0b58d61591e8f6a708c38db1fb94e4 Mon Sep 17 00:00:00 2001
From: "Miss Islington (bot)"
 <31488909+miss-islington@users.noreply.github.com>
Date: Sun, 25 Jan 2026 18:09:56 +0100
Subject: [PATCH] [3.11] gh-144125: email: verify headers are sound in
 BytesGenerator (#144189)

gh-144125: email: verify headers are sound in BytesGenerator
(cherry picked from commit 052e55e7d44718fe46cbba0ca995cb8fcc359413)

Co-authored-by: Seth Michael Larson <seth@python.org>
Co-authored-by: Denis Ledoux <dle@odoo.com>
Co-authored-by: Denis Ledoux <5822488+beledouxdenis@users.noreply.github.com>
Co-authored-by: Petr Viktorin <302922+encukou@users.noreply.github.com>
Co-authored-by: Bas Bloemsaat <1586868+basbloemsaat@users.noreply.github.com>
---
 Lib/email/generator.py                               | 12 +++++++++++-
 Lib/test/test_email/test_generator.py                |  4 +++-
 Lib/test/test_email/test_policy.py                   |  6 +++++-
 .../2026-01-21-12-34-05.gh-issue-144125.TAz5uo.rst   |  4 ++++
 4 files changed, 23 insertions(+), 3 deletions(-)
 create mode 100644 Misc/NEWS.d/next/Security/2026-01-21-12-34-05.gh-issue-144125.TAz5uo.rst

diff --git a/Lib/email/generator.py b/Lib/email/generator.py
index 563ca170726943..61dc942b83f8fb 100644
--- a/Lib/email/generator.py
+++ b/Lib/email/generator.py
@@ -22,6 +22,7 @@
 NLCRE = re.compile(r'\r\n|\r|\n')
 fcre = re.compile(r'^From ', re.MULTILINE)
 NEWLINE_WITHOUT_FWSP = re.compile(r'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]')
+NEWLINE_WITHOUT_FWSP_BYTES = re.compile(br'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]')
 
 
 class Generator:
@@ -429,7 +430,16 @@ def _write_headers(self, msg):
         # This is almost the same as the string version, except for handling
         # strings with 8bit bytes.
         for h, v in msg.raw_items():
-            self._fp.write(self.policy.fold_binary(h, v))
+            folded = self.policy.fold_binary(h, v)
+            if self.policy.verify_generated_headers:
+                linesep = self.policy.linesep.encode()
+                if not folded.endswith(linesep):
+                    raise HeaderWriteError(
+                        f'folded header does not end with {linesep!r}: {folded!r}')
+                if NEWLINE_WITHOUT_FWSP_BYTES.search(folded.removesuffix(linesep)):
+                    raise HeaderWriteError(
+                        f'folded header contains newline: {folded!r}')
+            self._fp.write(folded)
         # A blank line always separates headers from body
         self.write(self._NL)
 
diff --git a/Lib/test/test_email/test_generator.py b/Lib/test/test_email/test_generator.py
index d29400f0ed1dbb..a641f871dd7d67 100644
--- a/Lib/test/test_email/test_generator.py
+++ b/Lib/test/test_email/test_generator.py
@@ -264,7 +264,7 @@ class TestGenerator(TestGeneratorBase, TestEmailBase):
     typ = str
 
     def test_verify_generated_headers(self):
-        """gh-121650: by default the generator prevents header injection"""
+        # gh-121650: by default the generator prevents header injection
         class LiteralHeader(str):
             name = 'Header'
             def fold(self, **kwargs):
@@ -285,6 +285,8 @@ def fold(self, **kwargs):
 
                 with self.assertRaises(email.errors.HeaderWriteError):
                     message.as_string()
+                with self.assertRaises(email.errors.HeaderWriteError):
+                    message.as_bytes()
 
 
 class TestBytesGenerator(TestGeneratorBase, TestEmailBase):
diff --git a/Lib/test/test_email/test_policy.py b/Lib/test/test_email/test_policy.py
index baa35fd68e49c5..71ec0febb0fd86 100644
--- a/Lib/test/test_email/test_policy.py
+++ b/Lib/test/test_email/test_policy.py
@@ -296,7 +296,7 @@ def test_short_maxlen_error(self):
                     policy.fold("Subject", subject)
 
     def test_verify_generated_headers(self):
-        """Turning protection off allows header injection"""
+        # Turning protection off allows header injection
         policy = email.policy.default.clone(verify_generated_headers=False)
         for text in (
             'Header: Value\r\nBad: Injection\r\n',
@@ -319,6 +319,10 @@ def fold(self, **kwargs):
                     message.as_string(),
                     f"{text}\nBody",
                 )
+                self.assertEqual(
+                    message.as_bytes(),
+                    f"{text}\nBody".encode(),
+                )
 
     # XXX: Need subclassing tests.
     # For adding subclassed objects, make sure the usual rules apply (subclass
diff --git a/Misc/NEWS.d/next/Security/2026-01-21-12-34-05.gh-issue-144125.TAz5uo.rst b/Misc/NEWS.d/next/Security/2026-01-21-12-34-05.gh-issue-144125.TAz5uo.rst
new file mode 100644
index 00000000000000..e6333e724972c5
--- /dev/null
+++ b/Misc/NEWS.d/next/Security/2026-01-21-12-34-05.gh-issue-144125.TAz5uo.rst
@@ -0,0 +1,4 @@
+:mod:`~email.generator.BytesGenerator` will now refuse to serialize (write) headers
+that are unsafely folded or delimited; see
+:attr:`~email.policy.Policy.verify_generated_headers`. (Contributed by Bas
+Bloemsaat and Petr Viktorin in :gh:`121650`).
