From 162a9695a47a41ed005ae4bdfa5156209ccb2211f3acc2c93988b88c7b192d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Thu, 18 Dec 2025 16:07:31 +0100 Subject: [PATCH 1/2] Fix CVE-2025-13836, CVE-2025-12084, and CVE-2025-13837. - Add CVE-2025-13836-http-resp-cont-len.patch (bsc#1254400, CVE-2025-13836) to prevent reading an HTTP response from a server, if no read amount is specified, with using Content-Length per default as the length. - Add CVE-2025-12084-minidom-quad-search.patch prevent quadratic behavior in node ID cache clearing (CVE-2025-12084, bsc#1254997). - Add CVE-2025-13837-plistlib-mailicious-length.patch protect against OOM when loading malicious content (CVE-2025-13837, bsc#1254401). --- CVE-2025-12084-minidom-quad-search.patch | 93 ++++++++++ CVE-2025-13836-http-resp-cont-len.patch | 155 +++++++++++++++++ ...025-13837-plistlib-mailicious-length.patch | 160 ++++++++++++++++++ python311.changes | 14 ++ python311.spec | 9 + 5 files changed, 431 insertions(+) create mode 100644 CVE-2025-12084-minidom-quad-search.patch create mode 100644 CVE-2025-13836-http-resp-cont-len.patch create mode 100644 CVE-2025-13837-plistlib-mailicious-length.patch diff --git a/CVE-2025-12084-minidom-quad-search.patch b/CVE-2025-12084-minidom-quad-search.patch new file mode 100644 index 0000000..91c1691 --- /dev/null +++ b/CVE-2025-12084-minidom-quad-search.patch @@ -0,0 +1,93 @@ +From b95c10349956d95e258553def0fcc52ea3ef8f82 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Wed, 3 Dec 2025 01:16:37 -0600 +Subject: [PATCH] gh-142145: Remove quadratic behavior in node ID cache + clearing (GH-142146) + +* Remove quadratic behavior in node ID cache clearing + +Co-authored-by: Jacob Walls <38668450+jacobtylerwalls@users.noreply.github.com> + +* Add news fragment + +--------- +(cherry picked from commit 08d8e18ad81cd45bc4a27d6da478b51ea49486e4) + +Co-authored-by: Seth Michael Larson +Co-authored-by: Jacob Walls <38668450+jacobtylerwalls@users.noreply.github.com> +--- + Lib/test/test_minidom.py | 18 ++++++++++ + Lib/xml/dom/minidom.py | 9 ----- + Misc/NEWS.d/next/Security/2025-12-01-09-36-45.gh-issue-142145.tcAUhg.rst | 1 + 3 files changed, 20 insertions(+), 8 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2025-12-01-09-36-45.gh-issue-142145.tcAUhg.rst + +Index: Python-3.11.14/Lib/test/test_minidom.py +=================================================================== +--- Python-3.11.14.orig/Lib/test/test_minidom.py 2025-12-19 22:55:59.547417036 +0100 ++++ Python-3.11.14/Lib/test/test_minidom.py 2025-12-19 22:56:07.607956864 +0100 +@@ -2,6 +2,7 @@ + + import copy + import pickle ++import time + import io + from test import support + import unittest +@@ -176,6 +177,23 @@ + self.confirm(dom.documentElement.childNodes[-1].data == "Hello") + dom.unlink() + ++ def testAppendChildNoQuadraticComplexity(self): ++ impl = getDOMImplementation() ++ ++ newdoc = impl.createDocument(None, "some_tag", None) ++ top_element = newdoc.documentElement ++ children = [newdoc.createElement(f"child-{i}") for i in range(1, 2 ** 15 + 1)] ++ element = top_element ++ ++ start = time.time() ++ for child in children: ++ element.appendChild(child) ++ element = child ++ end = time.time() ++ ++ # This example used to take at least 30 seconds. ++ self.assertLess(end - start, 1) ++ + def testAppendChildFragment(self): + dom, orig, c1, c2, c3, frag = self._create_fragment_test_nodes() + dom.documentElement.appendChild(frag) +Index: Python-3.11.14/Lib/xml/dom/minidom.py +=================================================================== +--- Python-3.11.14.orig/Lib/xml/dom/minidom.py 2025-10-09 18:16:55.000000000 +0200 ++++ Python-3.11.14/Lib/xml/dom/minidom.py 2025-12-19 22:56:07.608359083 +0100 +@@ -292,13 +292,6 @@ + childNodes.append(node) + node.parentNode = self + +-def _in_document(node): +- # return True iff node is part of a document tree +- while node is not None: +- if node.nodeType == Node.DOCUMENT_NODE: +- return True +- node = node.parentNode +- return False + + def _write_data(writer, data): + "Writes datachars to writer." +@@ -1539,7 +1532,7 @@ + if node.nodeType == Node.DOCUMENT_NODE: + node._id_cache.clear() + node._id_search_stack = None +- elif _in_document(node): ++ elif node.ownerDocument: + node.ownerDocument._id_cache.clear() + node.ownerDocument._id_search_stack= None + +Index: Python-3.11.14/Misc/NEWS.d/next/Security/2025-12-01-09-36-45.gh-issue-142145.tcAUhg.rst +=================================================================== +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ Python-3.11.14/Misc/NEWS.d/next/Security/2025-12-01-09-36-45.gh-issue-142145.tcAUhg.rst 2025-12-19 22:56:07.608664851 +0100 +@@ -0,0 +1 @@ ++Remove quadratic behavior in ``xml.minidom`` node ID cache clearing. diff --git a/CVE-2025-13836-http-resp-cont-len.patch b/CVE-2025-13836-http-resp-cont-len.patch new file mode 100644 index 0000000..0fb88e7 --- /dev/null +++ b/CVE-2025-13836-http-resp-cont-len.patch @@ -0,0 +1,155 @@ +From 4f2bc24b750a82d3b439f174e7717fc09820bfeb Mon Sep 17 00:00:00 2001 +From: Serhiy Storchaka +Date: Mon, 1 Dec 2025 17:26:07 +0200 +Subject: [PATCH] gh-119451: Fix a potential denial of service in http.client + (GH-119454) + +Reading the whole body of the HTTP response could cause OOM if +the Content-Length value is too large even if the server does not send +a large amount of data. Now the HTTP client reads large data by chunks, +therefore the amount of consumed memory is proportional to the amount +of sent data. +(cherry picked from commit 5a4c4a033a4a54481be6870aa1896fad732555b5) + +Co-authored-by: Serhiy Storchaka +--- + Lib/http/client.py | 28 ++++++-- + Lib/test/test_httplib.py | 66 +++++++++++++++++++ + ...-05-23-11-47-48.gh-issue-119451.qkJe9-.rst | 5 ++ + 3 files changed, 95 insertions(+), 4 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2024-05-23-11-47-48.gh-issue-119451.qkJe9-.rst + +diff --git a/Lib/http/client.py b/Lib/http/client.py +index 91ee1b470cfd47..c977612732afbc 100644 +--- a/Lib/http/client.py ++++ b/Lib/http/client.py +@@ -111,6 +111,11 @@ + _MAXLINE = 65536 + _MAXHEADERS = 100 + ++# Data larger than this will be read in chunks, to prevent extreme ++# overallocation. ++_MIN_READ_BUF_SIZE = 1 << 20 ++ ++ + # Header name/value ABNF (http://tools.ietf.org/html/rfc7230#section-3.2) + # + # VCHAR = %x21-7E +@@ -635,10 +640,25 @@ def _safe_read(self, amt): + reading. If the bytes are truly not available (due to EOF), then the + IncompleteRead exception can be used to detect the problem. + """ +- data = self.fp.read(amt) +- if len(data) < amt: +- raise IncompleteRead(data, amt-len(data)) +- return data ++ cursize = min(amt, _MIN_READ_BUF_SIZE) ++ data = self.fp.read(cursize) ++ if len(data) >= amt: ++ return data ++ if len(data) < cursize: ++ raise IncompleteRead(data, amt - len(data)) ++ ++ data = io.BytesIO(data) ++ data.seek(0, 2) ++ while True: ++ # This is a geometric increase in read size (never more than ++ # doubling out the current length of data per loop iteration). ++ delta = min(cursize, amt - cursize) ++ data.write(self.fp.read(delta)) ++ if data.tell() >= amt: ++ return data.getvalue() ++ cursize += delta ++ if data.tell() < cursize: ++ raise IncompleteRead(data.getvalue(), amt - data.tell()) + + def _safe_readinto(self, b): + """Same as _safe_read, but for reading into a buffer.""" +diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py +index 8b9d49ec094813..55363413b3b140 100644 +--- a/Lib/test/test_httplib.py ++++ b/Lib/test/test_httplib.py +@@ -1390,6 +1390,72 @@ def run_server(): + thread.join() + self.assertEqual(result, b"proxied data\n") + ++ def test_large_content_length(self): ++ serv = socket.create_server((HOST, 0)) ++ self.addCleanup(serv.close) ++ ++ def run_server(): ++ [conn, address] = serv.accept() ++ with conn: ++ while conn.recv(1024): ++ conn.sendall( ++ b"HTTP/1.1 200 Ok\r\n" ++ b"Content-Length: %d\r\n" ++ b"\r\n" % size) ++ conn.sendall(b'A' * (size//3)) ++ conn.sendall(b'B' * (size - size//3)) ++ ++ thread = threading.Thread(target=run_server) ++ thread.start() ++ self.addCleanup(thread.join, 1.0) ++ ++ conn = client.HTTPConnection(*serv.getsockname()) ++ try: ++ for w in range(15, 27): ++ size = 1 << w ++ conn.request("GET", "/") ++ with conn.getresponse() as response: ++ self.assertEqual(len(response.read()), size) ++ finally: ++ conn.close() ++ thread.join(1.0) ++ ++ def test_large_content_length_truncated(self): ++ serv = socket.create_server((HOST, 0)) ++ self.addCleanup(serv.close) ++ ++ def run_server(): ++ while True: ++ [conn, address] = serv.accept() ++ with conn: ++ conn.recv(1024) ++ if not size: ++ break ++ conn.sendall( ++ b"HTTP/1.1 200 Ok\r\n" ++ b"Content-Length: %d\r\n" ++ b"\r\n" ++ b"Text" % size) ++ ++ thread = threading.Thread(target=run_server) ++ thread.start() ++ self.addCleanup(thread.join, 1.0) ++ ++ conn = client.HTTPConnection(*serv.getsockname()) ++ try: ++ for w in range(18, 65): ++ size = 1 << w ++ conn.request("GET", "/") ++ with conn.getresponse() as response: ++ self.assertRaises(client.IncompleteRead, response.read) ++ conn.close() ++ finally: ++ conn.close() ++ size = 0 ++ conn.request("GET", "/") ++ conn.close() ++ thread.join(1.0) ++ + def test_putrequest_override_domain_validation(self): + """ + It should be possible to override the default validation +diff --git a/Misc/NEWS.d/next/Security/2024-05-23-11-47-48.gh-issue-119451.qkJe9-.rst b/Misc/NEWS.d/next/Security/2024-05-23-11-47-48.gh-issue-119451.qkJe9-.rst +new file mode 100644 +index 00000000000000..6d6f25cd2f8bf7 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2024-05-23-11-47-48.gh-issue-119451.qkJe9-.rst +@@ -0,0 +1,5 @@ ++Fix a potential memory denial of service in the :mod:`http.client` module. ++When connecting to a malicious server, it could cause ++an arbitrary amount of memory to be allocated. ++This could have led to symptoms including a :exc:`MemoryError`, swapping, out ++of memory (OOM) killed processes or containers, or even system crashes. diff --git a/CVE-2025-13837-plistlib-mailicious-length.patch b/CVE-2025-13837-plistlib-mailicious-length.patch new file mode 100644 index 0000000..cef5154 --- /dev/null +++ b/CVE-2025-13837-plistlib-mailicious-length.patch @@ -0,0 +1,160 @@ +From aa9edbb11a2bf7805fd5046cdd5c2d3864aa39f2 Mon Sep 17 00:00:00 2001 +From: Serhiy Storchaka +Date: Mon, 1 Dec 2025 17:28:15 +0200 +Subject: [PATCH] [3.11] gh-119342: Fix a potential denial of service in + plistlib (GH-119343) + +Reading a specially prepared small Plist file could cause OOM because file's +read(n) preallocates a bytes object for reading the specified amount of +data. Now plistlib reads large data by chunks, therefore the upper limit of +consumed memory is proportional to the size of the input file. +(cherry picked from commit 694922cf40aa3a28f898b5f5ee08b71b4922df70) + +Co-authored-by: Serhiy Storchaka +--- + Lib/plistlib.py | 31 ++++++++++------ + Lib/test/test_plistlib.py | 37 +++++++++++++++++-- + ...-05-21-22-11-31.gh-issue-119342.BTFj4Z.rst | 5 +++ + 3 files changed, 59 insertions(+), 14 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2024-05-21-22-11-31.gh-issue-119342.BTFj4Z.rst + +diff --git a/Lib/plistlib.py b/Lib/plistlib.py +index 53e718f063b3ec..63fefbd5f6d499 100644 +--- a/Lib/plistlib.py ++++ b/Lib/plistlib.py +@@ -73,6 +73,9 @@ + PlistFormat = enum.Enum('PlistFormat', 'FMT_XML FMT_BINARY', module=__name__) + globals().update(PlistFormat.__members__) + ++# Data larger than this will be read in chunks, to prevent extreme ++# overallocation. ++_MIN_READ_BUF_SIZE = 1 << 20 + + class UID: + def __init__(self, data): +@@ -499,12 +502,24 @@ def _get_size(self, tokenL): + + return tokenL + ++ def _read(self, size): ++ cursize = min(size, _MIN_READ_BUF_SIZE) ++ data = self._fp.read(cursize) ++ while True: ++ if len(data) != cursize: ++ raise InvalidFileException ++ if cursize == size: ++ return data ++ delta = min(cursize, size - cursize) ++ data += self._fp.read(delta) ++ cursize += delta ++ + def _read_ints(self, n, size): +- data = self._fp.read(size * n) ++ data = self._read(size * n) + if size in _BINARY_FORMAT: + return struct.unpack(f'>{n}{_BINARY_FORMAT[size]}', data) + else: +- if not size or len(data) != size * n: ++ if not size: + raise InvalidFileException() + return tuple(int.from_bytes(data[i: i + size], 'big') + for i in range(0, size * n, size)) +@@ -561,22 +576,16 @@ def _read_object(self, ref): + + elif tokenH == 0x40: # data + s = self._get_size(tokenL) +- result = self._fp.read(s) +- if len(result) != s: +- raise InvalidFileException() ++ result = self._read(s) + + elif tokenH == 0x50: # ascii string + s = self._get_size(tokenL) +- data = self._fp.read(s) +- if len(data) != s: +- raise InvalidFileException() ++ data = self._read(s) + result = data.decode('ascii') + + elif tokenH == 0x60: # unicode string + s = self._get_size(tokenL) * 2 +- data = self._fp.read(s) +- if len(data) != s: +- raise InvalidFileException() ++ data = self._read(s) + result = data.decode('utf-16be') + + elif tokenH == 0x80: # UID +diff --git a/Lib/test/test_plistlib.py b/Lib/test/test_plistlib.py +index 95b7a649774dca..2bc64afdbe932f 100644 +--- a/Lib/test/test_plistlib.py ++++ b/Lib/test/test_plistlib.py +@@ -841,8 +841,7 @@ def test_xml_plist_with_entity_decl(self): + + class TestBinaryPlistlib(unittest.TestCase): + +- @staticmethod +- def decode(*objects, offset_size=1, ref_size=1): ++ def build(self, *objects, offset_size=1, ref_size=1): + data = [b'bplist00'] + offset = 8 + offsets = [] +@@ -854,7 +853,11 @@ def decode(*objects, offset_size=1, ref_size=1): + len(objects), 0, offset) + data.extend(offsets) + data.append(tail) +- return plistlib.loads(b''.join(data), fmt=plistlib.FMT_BINARY) ++ return b''.join(data) ++ ++ def decode(self, *objects, offset_size=1, ref_size=1): ++ data = self.build(*objects, offset_size=offset_size, ref_size=ref_size) ++ return plistlib.loads(data, fmt=plistlib.FMT_BINARY) + + def test_nonstandard_refs_size(self): + # Issue #21538: Refs and offsets are 24-bit integers +@@ -963,6 +966,34 @@ def test_invalid_binary(self): + with self.assertRaises(plistlib.InvalidFileException): + plistlib.loads(b'bplist00' + data, fmt=plistlib.FMT_BINARY) + ++ def test_truncated_large_data(self): ++ self.addCleanup(os_helper.unlink, os_helper.TESTFN) ++ def check(data): ++ with open(os_helper.TESTFN, 'wb') as f: ++ f.write(data) ++ # buffered file ++ with open(os_helper.TESTFN, 'rb') as f: ++ with self.assertRaises(plistlib.InvalidFileException): ++ plistlib.load(f, fmt=plistlib.FMT_BINARY) ++ # unbuffered file ++ with open(os_helper.TESTFN, 'rb', buffering=0) as f: ++ with self.assertRaises(plistlib.InvalidFileException): ++ plistlib.load(f, fmt=plistlib.FMT_BINARY) ++ for w in range(20, 64): ++ s = 1 << w ++ # data ++ check(self.build(b'\x4f\x13' + s.to_bytes(8, 'big'))) ++ # ascii string ++ check(self.build(b'\x5f\x13' + s.to_bytes(8, 'big'))) ++ # unicode string ++ check(self.build(b'\x6f\x13' + s.to_bytes(8, 'big'))) ++ # array ++ check(self.build(b'\xaf\x13' + s.to_bytes(8, 'big'))) ++ # dict ++ check(self.build(b'\xdf\x13' + s.to_bytes(8, 'big'))) ++ # number of objects ++ check(b'bplist00' + struct.pack('>6xBBQQQ', 1, 1, s, 0, 8)) ++ + + class TestKeyedArchive(unittest.TestCase): + def test_keyed_archive_data(self): +diff --git a/Misc/NEWS.d/next/Security/2024-05-21-22-11-31.gh-issue-119342.BTFj4Z.rst b/Misc/NEWS.d/next/Security/2024-05-21-22-11-31.gh-issue-119342.BTFj4Z.rst +new file mode 100644 +index 00000000000000..04fd8faca4cf7e +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2024-05-21-22-11-31.gh-issue-119342.BTFj4Z.rst +@@ -0,0 +1,5 @@ ++Fix a potential memory denial of service in the :mod:`plistlib` module. ++When reading a Plist file received from untrusted source, it could cause ++an arbitrary amount of memory to be allocated. ++This could have led to symptoms including a :exc:`MemoryError`, swapping, out ++of memory (OOM) killed processes or containers, or even system crashes. diff --git a/python311.changes b/python311.changes index abcc4b1..1e38464 100644 --- a/python311.changes +++ b/python311.changes @@ -1,3 +1,17 @@ +------------------------------------------------------------------- +Thu Dec 18 10:33:44 UTC 2025 - Matej Cepl + +- Add CVE-2025-13836-http-resp-cont-len.patch (bsc#1254400, + CVE-2025-13836) to prevent reading an HTTP response from + a server, if no read amount is specified, with using + Content-Length per default as the length. +- Add CVE-2025-12084-minidom-quad-search.patch prevent quadratic + behavior in node ID cache clearing (CVE-2025-12084, + bsc#1254997). +- Add CVE-2025-13837-plistlib-mailicious-length.patch protect + against OOM when loading malicious content (CVE-2025-13837, + bsc#1254401). + ------------------------------------------------------------------- Thu Nov 13 17:13:03 UTC 2025 - Matej Cepl diff --git a/python311.spec b/python311.spec index 597cf4c..6f26349 100644 --- a/python311.spec +++ b/python311.spec @@ -191,6 +191,15 @@ Patch25: gh139257-Support-docutils-0.22.patch # PATCH-FIX-UPSTREAM CVE-2025-6075-expandvars-perf-degrad.patch bsc#1252974 mcepl@suse.com # Avoid potential quadratic complexity vulnerabilities in path modules Patch26: CVE-2025-6075-expandvars-perf-degrad.patch +# PATCH-FIX-UPSTREAM CVE-2025-13836-http-resp-cont-len.patch bsc#1254400 mcepl@suse.com +# Avoid loading possibly compromised length of HTTP response +Patch27: CVE-2025-13836-http-resp-cont-len.patch +# PATCH-FIX-UPSTREAM CVE-2025-12084-minidom-quad-search.patch bsc#1254997 mcepl@suse.com +# prevent quadratic behavior in node ID cache clearing +Patch28: CVE-2025-12084-minidom-quad-search.patch +# PATCH-FIX-UPSTREAM CVE-2025-13837-plistlib-mailicious-length.patch bsc#1254401 mcepl@suse.com +# protect against OOM when loading malicious content +Patch29: CVE-2025-13837-plistlib-mailicious-length.patch BuildRequires: autoconf-archive BuildRequires: automake BuildRequires: crypto-policies-scripts From 02f09793e7d8de9408fdb33b16f874ae6bf78aec023b36785fbdb2181659c23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Cepl?= Date: Wed, 11 Feb 2026 23:40:28 +0100 Subject: [PATCH 2/2] Fix seven CVEs CVE-2025-11468: preserving parens when folding comments in email headers (bsc#1257029, gh#python/cpython#143935). CVE-2025-11468-email-hdr-fold-comment.patch CVE-2026-0672: rejects control characters in http cookies. (bsc#1257031, gh#python/cpython#143919) CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch CVE-2026-0865: rejecting control characters in wsgiref.headers.Headers, which could be abused for injecting false HTTP headers. (bsc#1257042, gh#python/cpython#143916) CVE-2026-0865-wsgiref-ctrl-chars.patch CVE-2025-15366: basically the same as the previous patch for IMAP protocol. (bsc#1257044, gh#python/cpython#143921) CVE-2025-15366-imap-ctrl-chars.patch CVE-2025-15282: basically the same as the previous patch for urllib library. (bsc#1257046, gh#python/cpython#143925) CVE-2025-15282-urllib-ctrl-chars.patch CVE-2025-15367: basically the same as the previous patch for poplib library. (bsc#1257041, gh#python/cpython#143923) CVE-2025-15367-poplib-ctrl-chars.patch CVE-2025-12781: fix decoding with non-standard Base64 alphabet (bsc#1257108, gh#python/cpython#125346) CVE-2025-12781-b64decode-alt-chars.patch --- CVE-2025-11468-email-hdr-fold-comment.patch | 109 ++++++++++ CVE-2025-12781-b64decode-alt-chars.patch | 193 ++++++++++++++++++ CVE-2025-15282-urllib-ctrl-chars.patch | 65 ++++++ CVE-2025-15366-imap-ctrl-chars.patch | 38 ++++ CVE-2025-15367-poplib-ctrl-chars.patch | 56 +++++ ...6-0672-http-hdr-inject-cookie-Morsel.patch | 184 +++++++++++++++++ CVE-2026-0865-wsgiref-ctrl-chars.patch | 178 ++++++++++++++++ python311.changes | 26 +++ python311.spec | 25 +++ 9 files changed, 874 insertions(+) create mode 100644 CVE-2025-11468-email-hdr-fold-comment.patch create mode 100644 CVE-2025-12781-b64decode-alt-chars.patch create mode 100644 CVE-2025-15282-urllib-ctrl-chars.patch create mode 100644 CVE-2025-15366-imap-ctrl-chars.patch create mode 100644 CVE-2025-15367-poplib-ctrl-chars.patch create mode 100644 CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch create mode 100644 CVE-2026-0865-wsgiref-ctrl-chars.patch diff --git a/CVE-2025-11468-email-hdr-fold-comment.patch b/CVE-2025-11468-email-hdr-fold-comment.patch new file mode 100644 index 0000000..e65267d --- /dev/null +++ b/CVE-2025-11468-email-hdr-fold-comment.patch @@ -0,0 +1,109 @@ +From dfaeb01d7859417f4e4aab8c3e6c88028c878056 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Mon, 19 Jan 2026 06:38:22 -0600 +Subject: [PATCH] gh-143935: Email preserve parens when folding comments + (GH-143936) + +Fix a bug in the folding of comments when flattening an email message +using a modern email policy. Comments consisting of a very long sequence of +non-foldable characters could trigger a forced line wrap that omitted the +required leading space on the continuation line, causing the remainder of +the comment to be interpreted as a new header field. This enabled header +injection with carefully crafted inputs. +(cherry picked from commit 17d1490aa97bd6b98a42b1a9b324ead84e7fd8a2) + +Co-authored-by: Seth Michael Larson +Co-authored-by: Denis Ledoux +--- + Lib/email/_header_value_parser.py | 15 +++++++++++- + .../test_email/test__header_value_parser.py | 23 +++++++++++++++++++ + ...-01-16-14-40-31.gh-issue-143935.U2YtKl.rst | 6 +++++ + 3 files changed, 43 insertions(+), 1 deletion(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-14-40-31.gh-issue-143935.U2YtKl.rst + +diff --git a/Lib/email/_header_value_parser.py b/Lib/email/_header_value_parser.py +index 0183a1508b1219..89950c825b6fff 100644 +--- a/Lib/email/_header_value_parser.py ++++ b/Lib/email/_header_value_parser.py +@@ -101,6 +101,12 @@ def make_quoted_pairs(value): + return str(value).replace('\\', '\\\\').replace('"', '\\"') + + ++def make_parenthesis_pairs(value): ++ """Escape parenthesis and backslash for use within a comment.""" ++ return str(value).replace('\\', '\\\\') \ ++ .replace('(', '\\(').replace(')', '\\)') ++ ++ + def quote_string(value): + escaped = make_quoted_pairs(value) + return f'"{escaped}"' +@@ -927,7 +933,7 @@ def value(self): + return ' ' + + def startswith_fws(self): +- return True ++ return self and self[0] in WSP + + + class ValueTerminal(Terminal): +@@ -2883,6 +2889,13 @@ def _refold_parse_tree(parse_tree, *, policy): + [ValueTerminal(make_quoted_pairs(p), 'ptext') + for p in newparts] + + [ValueTerminal('"', 'ptext')]) ++ if part.token_type == 'comment': ++ newparts = ( ++ [ValueTerminal('(', 'ptext')] + ++ [ValueTerminal(make_parenthesis_pairs(p), 'ptext') ++ if p.token_type == 'ptext' else p ++ for p in newparts] + ++ [ValueTerminal(')', 'ptext')]) + if not part.as_ew_allowed: + wrap_as_ew_blocked += 1 + newparts.append(end_ew_not_allowed) +diff --git a/Lib/test/test_email/test__header_value_parser.py b/Lib/test/test_email/test__header_value_parser.py +index 6025b34ac4a0f8..45ff73b5905fde 100644 +--- a/Lib/test/test_email/test__header_value_parser.py ++++ b/Lib/test/test_email/test__header_value_parser.py +@@ -3050,6 +3050,29 @@ def test_address_list_with_specials_in_long_quoted_string(self): + with self.subTest(to=to): + self._test(parser.get_address_list(to)[0], folded, policy=policy) + ++ def test_address_list_with_long_unwrapable_comment(self): ++ policy = self.policy.clone(max_line_length=40) ++ cases = [ ++ # (to, folded) ++ ('(loremipsumdolorsitametconsecteturadipi)', ++ '(loremipsumdolorsitametconsecteturadipi)\n'), ++ ('(loremipsumdolorsitametconsecteturadipi)', ++ '(loremipsumdolorsitametconsecteturadipi)\n'), ++ ('(loremipsum dolorsitametconsecteturadipi)', ++ '(loremipsum dolorsitametconsecteturadipi)\n'), ++ ('(loremipsum dolorsitametconsecteturadipi)', ++ '(loremipsum\n dolorsitametconsecteturadipi)\n'), ++ ('(Escaped \\( \\) chars \\\\ in comments stay escaped)', ++ '(Escaped \\( \\) chars \\\\ in comments stay\n escaped)\n'), ++ ('((loremipsum)(loremipsum)(loremipsum)(loremipsum))', ++ '((loremipsum)(loremipsum)(loremipsum)(loremipsum))\n'), ++ ('((loremipsum)(loremipsum)(loremipsum) (loremipsum))', ++ '((loremipsum)(loremipsum)(loremipsum)\n (loremipsum))\n'), ++ ] ++ for (to, folded) in cases: ++ with self.subTest(to=to): ++ self._test(parser.get_address_list(to)[0], folded, policy=policy) ++ + # XXX Need tests with comments on various sides of a unicode token, + # and with unicode tokens in the comments. Spaces inside the quotes + # currently don't do the right thing. +diff --git a/Misc/NEWS.d/next/Security/2026-01-16-14-40-31.gh-issue-143935.U2YtKl.rst b/Misc/NEWS.d/next/Security/2026-01-16-14-40-31.gh-issue-143935.U2YtKl.rst +new file mode 100644 +index 00000000000000..c3d864936884ac +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-01-16-14-40-31.gh-issue-143935.U2YtKl.rst +@@ -0,0 +1,6 @@ ++Fixed a bug in the folding of comments when flattening an email message ++using a modern email policy. Comments consisting of a very long sequence of ++non-foldable characters could trigger a forced line wrap that omitted the ++required leading space on the continuation line, causing the remainder of ++the comment to be interpreted as a new header field. This enabled header ++injection with carefully crafted inputs. diff --git a/CVE-2025-12781-b64decode-alt-chars.patch b/CVE-2025-12781-b64decode-alt-chars.patch new file mode 100644 index 0000000..2028e2a --- /dev/null +++ b/CVE-2025-12781-b64decode-alt-chars.patch @@ -0,0 +1,193 @@ +From f922c02c529d25d61aa9c28a8192639c1fce8d4d Mon Sep 17 00:00:00 2001 +From: Serhiy Storchaka +Date: Wed, 5 Nov 2025 20:12:31 +0200 +Subject: [PATCH] gh-125346: Add more base64 tests + +Add more tests for the altchars argument of b64decode() and for the map01 +argument of b32decode(). +--- + Doc/library/base64.rst | 18 +++- + Lib/base64.py | 40 ++++++++- + Lib/test/test_base64.py | 42 +++++++++- + Misc/NEWS.d/next/Library/2025-11-06-12-03-29.gh-issue-125346.7Gfpgw.rst | 5 + + 4 files changed, 91 insertions(+), 14 deletions(-) + +Index: Python-3.11.14/Doc/library/base64.rst +=================================================================== +--- Python-3.11.14.orig/Doc/library/base64.rst 2025-10-09 18:16:55.000000000 +0200 ++++ Python-3.11.14/Doc/library/base64.rst 2026-02-11 23:44:54.612595397 +0100 +@@ -74,15 +74,20 @@ + A :exc:`binascii.Error` exception is raised + if *s* is incorrectly padded. + +- If *validate* is ``False`` (the default), characters that are neither ++ If *validate* is false (the default), characters that are neither + in the normal base-64 alphabet nor the alternative alphabet are +- discarded prior to the padding check. If *validate* is ``True``, +- these non-alphabet characters in the input result in a +- :exc:`binascii.Error`. ++ discarded prior to the padding check, but the ``+`` and ``/`` characters ++ keep their meaning if they are not in *altchars* (they will be discarded ++ in future Python versions). ++ If *validate* is true, these non-alphabet characters in the input ++ result in a :exc:`binascii.Error`. + + For more information about the strict base64 check, see :func:`binascii.a2b_base64` + +- May assert or raise a :exc:`ValueError` if the length of *altchars* is not 2. ++ .. deprecated:: next ++ Accepting the ``+`` and ``/`` characters with an alternative alphabet ++ is now deprecated. ++ + + .. function:: standard_b64encode(s) + +@@ -113,6 +118,9 @@ + ``/`` in the standard Base64 alphabet, and return the decoded + :class:`bytes`. + ++ .. deprecated:: next ++ Accepting the ``+`` and ``/`` characters is now deprecated. ++ + + .. function:: b32encode(s) + +Index: Python-3.11.14/Lib/base64.py +=================================================================== +--- Python-3.11.14.orig/Lib/base64.py 2026-02-11 23:44:42.099270109 +0100 ++++ Python-3.11.14/Lib/base64.py 2026-02-11 23:44:54.613055284 +0100 +@@ -71,20 +71,39 @@ + The result is returned as a bytes object. A binascii.Error is raised if + s is incorrectly padded. + +- If validate is False (the default), characters that are neither in the ++ If validate is false (the default), characters that are neither in the + normal base-64 alphabet nor the alternative alphabet are discarded prior +- to the padding check. If validate is True, these non-alphabet characters ++ to the padding check. If validate is true, these non-alphabet characters + in the input result in a binascii.Error. + For more information about the strict base64 check, see: + + https://docs.python.org/3.11/library/binascii.html#binascii.a2b_base64 + """ + s = _bytes_from_decode_data(s) ++ badchar = None + if altchars is not None: + altchars = _bytes_from_decode_data(altchars) +- assert len(altchars) == 2, repr(altchars) ++ if len(altchars) != 2: ++ raise ValueError(f'invalid altchars: {altchars!r}') ++ for b in b'+/': ++ if b not in altchars and b in s: ++ badchar = b ++ break + s = s.translate(bytes.maketrans(altchars, b'+/')) +- return binascii.a2b_base64(s, strict_mode=validate) ++ result = binascii.a2b_base64(s, strict_mode=validate) ++ if badchar is not None: ++ import warnings ++ if validate: ++ warnings.warn(f'invalid character {chr(badchar)!a} in Base64 data ' ++ f'with altchars={altchars!r} and validate=True ' ++ f'will be an error in future Python versions', ++ DeprecationWarning, stacklevel=2) ++ else: ++ warnings.warn(f'invalid character {chr(badchar)!a} in Base64 data ' ++ f'with altchars={altchars!r} and validate=False ' ++ f'will be discarded in future Python versions', ++ FutureWarning, stacklevel=2) ++ return result + + + def standard_b64encode(s): +@@ -129,8 +148,19 @@ + The alphabet uses '-' instead of '+' and '_' instead of '/'. + """ + s = _bytes_from_decode_data(s) ++ badchar = None ++ for b in b'+/': ++ if b in s: ++ badchar = b ++ break + s = s.translate(_urlsafe_decode_translation) +- return b64decode(s) ++ result = binascii.a2b_base64(s, strict_mode=False) ++ if badchar is not None: ++ import warnings ++ warnings.warn(f'invalid character {chr(badchar)!a} in URL-safe Base64 data ' ++ f'will be discarded in future Python versions', ++ FutureWarning, stacklevel=2) ++ return result + + + +Index: Python-3.11.14/Lib/test/test_base64.py +=================================================================== +--- Python-3.11.14.orig/Lib/test/test_base64.py 2026-02-11 23:44:44.270637438 +0100 ++++ Python-3.11.14/Lib/test/test_base64.py 2026-02-11 23:44:54.613405604 +0100 +@@ -228,6 +228,25 @@ + b'\xd3V\xbeo\xf7\x1d') + self.check_decode_type_errors(base64.urlsafe_b64decode) + ++ def test_b64decode_altchars(self): ++ # Test with arbitrary alternative characters ++ eq = self.assertEqual ++ res = b'\xd3V\xbeo\xf7\x1d' ++ for altchars in b'*$', b'+/', b'/+', b'+_', b'-+', b'-/', b'/_': ++ data = b'01a%cb%ccd' % tuple(altchars) ++ data_str = data.decode('ascii') ++ altchars_str = altchars.decode('ascii') ++ ++ eq(base64.b64decode(data, altchars=altchars), res) ++ eq(base64.b64decode(data_str, altchars=altchars), res) ++ eq(base64.b64decode(data, altchars=altchars_str), res) ++ eq(base64.b64decode(data_str, altchars=altchars_str), res) ++ ++ self.assertRaises(ValueError, base64.b64decode, b'', altchars=b'+') ++ self.assertRaises(ValueError, base64.b64decode, b'', altchars=b'+/-') ++ self.assertRaises(ValueError, base64.b64decode, '', altchars='+') ++ self.assertRaises(ValueError, base64.b64decode, '', altchars='+/-') ++ + def test_b64decode_padding_error(self): + self.assertRaises(binascii.Error, base64.b64decode, b'abc') + self.assertRaises(binascii.Error, base64.b64decode, 'abc') +@@ -259,10 +278,25 @@ + with self.assertRaises(binascii.Error): + base64.b64decode(bstr.decode('ascii'), validate=True) + +- # Normal alphabet characters not discarded when alternative given +- res = b'\xFB\xEF\xBE\xFF\xFF\xFF' +- self.assertEqual(base64.b64decode(b'++[[//]]', b'[]'), res) +- self.assertEqual(base64.urlsafe_b64decode(b'++--//__'), res) ++ # Normal alphabet characters will be discarded when alternative given ++ with self.assertWarns(FutureWarning): ++ self.assertEqual(base64.b64decode(b'++++', altchars=b'-_'), ++ b'\xfb\xef\xbe') ++ with self.assertWarns(FutureWarning): ++ self.assertEqual(base64.b64decode(b'////', altchars=b'-_'), ++ b'\xff\xff\xff') ++ with self.assertWarns(DeprecationWarning): ++ self.assertEqual(base64.b64decode(b'++++', altchars=b'-_', validate=True), ++ b'\xfb\xef\xbe') ++ with self.assertWarns(DeprecationWarning): ++ self.assertEqual(base64.b64decode(b'////', altchars=b'-_', validate=True), ++ b'\xff\xff\xff') ++ with self.assertWarns(FutureWarning): ++ self.assertEqual(base64.urlsafe_b64decode(b'++++'), b'\xfb\xef\xbe') ++ with self.assertWarns(FutureWarning): ++ self.assertEqual(base64.urlsafe_b64decode(b'////'), b'\xff\xff\xff') ++ with self.assertRaises(binascii.Error): ++ base64.b64decode(b'+/!', altchars=b'-_') + + def test_b32encode(self): + eq = self.assertEqual +Index: Python-3.11.14/Misc/NEWS.d/next/Library/2025-11-06-12-03-29.gh-issue-125346.7Gfpgw.rst +=================================================================== +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ Python-3.11.14/Misc/NEWS.d/next/Library/2025-11-06-12-03-29.gh-issue-125346.7Gfpgw.rst 2026-02-11 23:44:54.613764682 +0100 +@@ -0,0 +1,5 @@ ++Accepting ``+`` and ``/`` characters with an alternative alphabet in ++:func:`base64.b64decode` and :func:`base64.urlsafe_b64decode` is now ++deprecated. ++In future Python versions they will be errors in the strict mode and ++discarded in the non-strict mode. diff --git a/CVE-2025-15282-urllib-ctrl-chars.patch b/CVE-2025-15282-urllib-ctrl-chars.patch new file mode 100644 index 0000000..f72ad46 --- /dev/null +++ b/CVE-2025-15282-urllib-ctrl-chars.patch @@ -0,0 +1,65 @@ +From 66c966a2d07cfcf555117309ef6aa088001bc487 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Tue, 20 Jan 2026 14:45:58 -0600 +Subject: [PATCH] [3.11] gh-143925: Reject control characters in data: URL + mediatypes (cherry picked from commit + f25509e78e8be6ea73c811ac2b8c928c28841b9f) (cherry picked from commit + 2c9c746077d8119b5bcf5142316992e464594946) + +Co-authored-by: Seth Michael Larson +--- + Lib/test/test_urllib.py | 8 ++++++++ + Lib/urllib/request.py | 5 +++++ + .../2026-01-16-11-51-19.gh-issue-143925.mrtcHW.rst | 1 + + 3 files changed, 14 insertions(+) + create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-51-19.gh-issue-143925.mrtcHW.rst + +diff --git a/Lib/test/test_urllib.py b/Lib/test/test_urllib.py +index f067560ca6caa1..497372a38e392a 100644 +--- a/Lib/test/test_urllib.py ++++ b/Lib/test/test_urllib.py +@@ -12,6 +12,7 @@ + from test.support import os_helper + from test.support import socket_helper + from test.support import warnings_helper ++from test.support import control_characters_c0 + import os + try: + import ssl +@@ -683,6 +684,13 @@ def test_invalid_base64_data(self): + # missing padding character + self.assertRaises(ValueError,urllib.request.urlopen,'data:;base64,Cg=') + ++ def test_invalid_mediatype(self): ++ for c0 in control_characters_c0(): ++ self.assertRaises(ValueError,urllib.request.urlopen, ++ f'data:text/html;{c0},data') ++ for c0 in control_characters_c0(): ++ self.assertRaises(ValueError,urllib.request.urlopen, ++ f'data:text/html{c0};base64,ZGF0YQ==') + + class urlretrieve_FileTests(unittest.TestCase): + """Test urllib.urlretrieve() on local files""" +diff --git a/Lib/urllib/request.py b/Lib/urllib/request.py +index d98ba5dd1983b9..3abb7ae1b049b7 100644 +--- a/Lib/urllib/request.py ++++ b/Lib/urllib/request.py +@@ -1654,6 +1654,11 @@ def data_open(self, req): + scheme, data = url.split(":",1) + mediatype, data = data.split(",",1) + ++ # Disallow control characters within mediatype. ++ if re.search(r"[\x00-\x1F\x7F]", mediatype): ++ raise ValueError( ++ "Control characters not allowed in data: mediatype") ++ + # even base64 encoded data URLs might be quoted so unquote in any case: + data = unquote_to_bytes(data) + if mediatype.endswith(";base64"): +diff --git a/Misc/NEWS.d/next/Security/2026-01-16-11-51-19.gh-issue-143925.mrtcHW.rst b/Misc/NEWS.d/next/Security/2026-01-16-11-51-19.gh-issue-143925.mrtcHW.rst +new file mode 100644 +index 00000000000000..46109dfbef3ee7 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-01-16-11-51-19.gh-issue-143925.mrtcHW.rst +@@ -0,0 +1 @@ ++Reject control characters in ``data:`` URL media types. diff --git a/CVE-2025-15366-imap-ctrl-chars.patch b/CVE-2025-15366-imap-ctrl-chars.patch new file mode 100644 index 0000000..e42abab --- /dev/null +++ b/CVE-2025-15366-imap-ctrl-chars.patch @@ -0,0 +1,38 @@ +From 7485ee5e2cf81d3e5ad0d9c3be73cecd2ab4eec7 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Fri, 16 Jan 2026 10:54:09 -0600 +Subject: [PATCH 1/2] Add 'test.support' fixture for C0 control characters + +--- + Lib/imaplib.py | 4 +++- + Misc/NEWS.d/next/Security/2026-01-16-11-41-06.gh-issue-143921.AeCOor.rst | 1 + + 2 files changed, 4 insertions(+), 1 deletion(-) + +Index: Python-3.12.12/Lib/imaplib.py +=================================================================== +--- Python-3.12.12.orig/Lib/imaplib.py 2026-02-10 22:15:03.417592955 +0100 ++++ Python-3.12.12/Lib/imaplib.py 2026-02-10 22:18:02.094605035 +0100 +@@ -132,7 +132,7 @@ + # We compile these in _mode_xxx. + _Literal = br'.*{(?P\d+)}$' + _Untagged_status = br'\* (?P\d+) (?P[A-Z-]+)( (?P.*))?' +- ++_control_chars = re.compile(b'[\x00-\x1F\x7F]') + + + class IMAP4: +@@ -994,6 +994,8 @@ + if arg is None: continue + if isinstance(arg, str): + arg = bytes(arg, self._encoding) ++ if _control_chars.search(arg): ++ raise ValueError("Control characters not allowed in commands") + data = data + b' ' + arg + + literal = self.literal +Index: Python-3.12.12/Misc/NEWS.d/next/Security/2026-01-16-11-41-06.gh-issue-143921.AeCOor.rst +=================================================================== +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ Python-3.12.12/Misc/NEWS.d/next/Security/2026-01-16-11-41-06.gh-issue-143921.AeCOor.rst 2026-02-10 22:18:02.095167966 +0100 +@@ -0,0 +1 @@ ++Reject control characters in IMAP commands. diff --git a/CVE-2025-15367-poplib-ctrl-chars.patch b/CVE-2025-15367-poplib-ctrl-chars.patch new file mode 100644 index 0000000..a6308c4 --- /dev/null +++ b/CVE-2025-15367-poplib-ctrl-chars.patch @@ -0,0 +1,56 @@ +From b6f733b285b1c4f27dacb5c2e1f292c914e8b933 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Fri, 16 Jan 2026 10:54:09 -0600 +Subject: [PATCH 1/2] Add 'test.support' fixture for C0 control characters + +--- + Lib/poplib.py | 2 ++ + Lib/test/test_poplib.py | 8 ++++++++ + Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst | 1 + + 3 files changed, 11 insertions(+) + +Index: Python-3.11.14/Lib/poplib.py +=================================================================== +--- Python-3.11.14.orig/Lib/poplib.py 2025-10-09 18:16:55.000000000 +0200 ++++ Python-3.11.14/Lib/poplib.py 2026-02-11 23:38:35.281675745 +0100 +@@ -122,6 +122,8 @@ + def _putcmd(self, line): + if self._debugging: print('*cmd*', repr(line)) + line = bytes(line, self.encoding) ++ if re.search(b'[\x00-\x1F\x7F]', line): ++ raise ValueError('Control characters not allowed in commands') + self._putline(line) + + +Index: Python-3.11.14/Lib/test/test_poplib.py +=================================================================== +--- Python-3.11.14.orig/Lib/test/test_poplib.py 2025-10-09 18:16:55.000000000 +0200 ++++ Python-3.11.14/Lib/test/test_poplib.py 2026-02-11 23:39:24.009682813 +0100 +@@ -16,6 +16,7 @@ + from test.support import socket_helper + from test.support import threading_helper + from test.support import warnings_helper ++from test.support import control_characters_c0 + + + asynchat = warnings_helper.import_deprecated('asynchat') +@@ -367,6 +368,13 @@ + self.assertIsNone(self.client.sock) + self.assertIsNone(self.client.file) + ++ def test_control_characters(self): ++ for c0 in control_characters_c0(): ++ with self.assertRaises(ValueError): ++ self.client.user(f'user{c0}') ++ with self.assertRaises(ValueError): ++ self.client.pass_(f'{c0}pass') ++ + @requires_ssl + def test_stls_capa(self): + capa = self.client.capa() +Index: Python-3.11.14/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst +=================================================================== +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ Python-3.11.14/Misc/NEWS.d/next/Security/2026-01-16-11-43-47.gh-issue-143923.DuytMe.rst 2026-02-11 23:38:35.282276228 +0100 +@@ -0,0 +1 @@ ++Reject control characters in POP3 commands. diff --git a/CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch b/CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch new file mode 100644 index 0000000..697a101 --- /dev/null +++ b/CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch @@ -0,0 +1,184 @@ +From c2d345e3e4dc8932e85dace6599e5c69a144c748 Mon Sep 17 00:00:00 2001 +From: Seth Michael Larson +Date: Tue, 20 Jan 2026 15:23:42 -0600 +Subject: [PATCH] gh-143919: Reject control characters in http cookies (cherry + picked from commit 95746b3a13a985787ef53b977129041971ed7f70) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +Co-authored-by: Seth Michael Larson +Co-authored-by: Bartosz Sławecki +Co-authored-by: sobolevn +--- + Doc/library/http.cookies.rst | 4 +- + Lib/http/cookies.py | 25 +++++++-- + Lib/test/test_http_cookies.py | 52 +++++++++++++++++-- + ...-01-16-11-13-15.gh-issue-143919.kchwZV.rst | 1 + + 4 files changed, 73 insertions(+), 9 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst + +diff --git a/Doc/library/http.cookies.rst b/Doc/library/http.cookies.rst +index e91972fe621a48..e2abb31149ff10 100644 +--- a/Doc/library/http.cookies.rst ++++ b/Doc/library/http.cookies.rst +@@ -272,9 +272,9 @@ The following example demonstrates how to use the :mod:`http.cookies` module. + Set-Cookie: chips=ahoy + Set-Cookie: vienna=finger + >>> C = cookies.SimpleCookie() +- >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=\\012;";') ++ >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=;";') + >>> print(C) +- Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=\012;" ++ Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=;" + >>> C = cookies.SimpleCookie() + >>> C["oreo"] = "doublestuff" + >>> C["oreo"]["path"] = "/" +diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py +index 2c1f021d0abede..5cfa7a8072c7f7 100644 +--- a/Lib/http/cookies.py ++++ b/Lib/http/cookies.py +@@ -87,9 +87,9 @@ + such trickeries do not confuse it. + + >>> C = cookies.SimpleCookie() +- >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=\\012;";') ++ >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=;";') + >>> print(C) +- Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=\012;" ++ Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=;" + + Each element of the Cookie also supports all of the RFC 2109 + Cookie attributes. Here's an example which sets the Path +@@ -170,6 +170,15 @@ class CookieError(Exception): + }) + + _is_legal_key = re.compile('[%s]+' % re.escape(_LegalChars)).fullmatch ++_control_character_re = re.compile(r'[\x00-\x1F\x7F]') ++ ++ ++def _has_control_character(*val): ++ """Detects control characters within a value. ++ Supports any type, as header values can be any type. ++ """ ++ return any(_control_character_re.search(str(v)) for v in val) ++ + + def _quote(str): + r"""Quote a string for use in a cookie header. +@@ -292,12 +301,16 @@ def __setitem__(self, K, V): + K = K.lower() + if not K in self._reserved: + raise CookieError("Invalid attribute %r" % (K,)) ++ if _has_control_character(K, V): ++ raise CookieError(f"Control characters are not allowed in cookies {K!r} {V!r}") + dict.__setitem__(self, K, V) + + def setdefault(self, key, val=None): + key = key.lower() + if key not in self._reserved: + raise CookieError("Invalid attribute %r" % (key,)) ++ if _has_control_character(key, val): ++ raise CookieError("Control characters are not allowed in cookies %r %r" % (key, val,)) + return dict.setdefault(self, key, val) + + def __eq__(self, morsel): +@@ -333,6 +346,9 @@ def set(self, key, val, coded_val): + raise CookieError('Attempt to set a reserved key %r' % (key,)) + if not _is_legal_key(key): + raise CookieError('Illegal key %r' % (key,)) ++ if _has_control_character(key, val, coded_val): ++ raise CookieError( ++ "Control characters are not allowed in cookies %r %r %r" % (key, val, coded_val,)) + + # It's a good key, so save it. + self._key = key +@@ -484,7 +500,10 @@ def output(self, attrs=None, header="Set-Cookie:", sep="\015\012"): + result = [] + items = sorted(self.items()) + for key, value in items: +- result.append(value.output(attrs, header)) ++ value_output = value.output(attrs, header) ++ if _has_control_character(value_output): ++ raise CookieError("Control characters are not allowed in cookies") ++ result.append(value_output) + return sep.join(result) + + __str__ = output +diff --git a/Lib/test/test_http_cookies.py b/Lib/test/test_http_cookies.py +index 8879902a6e2f41..2438c57ef40458 100644 +--- a/Lib/test/test_http_cookies.py ++++ b/Lib/test/test_http_cookies.py +@@ -17,10 +17,10 @@ def test_basic(self): + 'repr': "", + 'output': 'Set-Cookie: chips=ahoy\nSet-Cookie: vienna=finger'}, + +- {'data': 'keebler="E=mc2; L=\\"Loves\\"; fudge=\\012;"', +- 'dict': {'keebler' : 'E=mc2; L="Loves"; fudge=\012;'}, +- 'repr': '''''', +- 'output': 'Set-Cookie: keebler="E=mc2; L=\\"Loves\\"; fudge=\\012;"'}, ++ {'data': 'keebler="E=mc2; L=\\"Loves\\"; fudge=;"', ++ 'dict': {'keebler' : 'E=mc2; L="Loves"; fudge=;'}, ++ 'repr': '''''', ++ 'output': 'Set-Cookie: keebler="E=mc2; L=\\"Loves\\"; fudge=;"'}, + + # Check illegal cookies that have an '=' char in an unquoted value + {'data': 'keebler=E=mc2', +@@ -517,6 +517,50 @@ def test_repr(self): + r'Set-Cookie: key=coded_val; ' + r'expires=\w+, \d+ \w+ \d+ \d+:\d+:\d+ \w+') + ++ def test_control_characters(self): ++ for c0 in support.control_characters_c0(): ++ morsel = cookies.Morsel() ++ ++ # .__setitem__() ++ with self.assertRaises(cookies.CookieError): ++ morsel[c0] = "val" ++ with self.assertRaises(cookies.CookieError): ++ morsel["path"] = c0 ++ ++ # .setdefault() ++ with self.assertRaises(cookies.CookieError): ++ morsel.setdefault("path", c0) ++ with self.assertRaises(cookies.CookieError): ++ morsel.setdefault(c0, "val") ++ ++ # .set() ++ with self.assertRaises(cookies.CookieError): ++ morsel.set(c0, "val", "coded-value") ++ with self.assertRaises(cookies.CookieError): ++ morsel.set("path", c0, "coded-value") ++ with self.assertRaises(cookies.CookieError): ++ morsel.set("path", "val", c0) ++ ++ def test_control_characters_output(self): ++ # Tests that even if the internals of Morsel are modified ++ # that a call to .output() has control character safeguards. ++ for c0 in support.control_characters_c0(): ++ morsel = cookies.Morsel() ++ morsel.set("key", "value", "coded-value") ++ morsel._key = c0 # Override private variable. ++ cookie = cookies.SimpleCookie() ++ cookie["cookie"] = morsel ++ with self.assertRaises(cookies.CookieError): ++ cookie.output() ++ ++ morsel = cookies.Morsel() ++ morsel.set("key", "value", "coded-value") ++ morsel._coded_value = c0 # Override private variable. ++ cookie = cookies.SimpleCookie() ++ cookie["cookie"] = morsel ++ with self.assertRaises(cookies.CookieError): ++ cookie.output() ++ + + def load_tests(loader, tests, pattern): + tests.addTest(doctest.DocTestSuite(cookies)) +diff --git a/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst b/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst +new file mode 100644 +index 00000000000000..788c3e4ac2ebf7 +--- /dev/null ++++ b/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst +@@ -0,0 +1 @@ ++Reject control characters in :class:`http.cookies.Morsel` fields and values. diff --git a/CVE-2026-0865-wsgiref-ctrl-chars.patch b/CVE-2026-0865-wsgiref-ctrl-chars.patch new file mode 100644 index 0000000..65d2677 --- /dev/null +++ b/CVE-2026-0865-wsgiref-ctrl-chars.patch @@ -0,0 +1,178 @@ +From 2c84d7875f35d3d1d0fbc170271227cc95752fa5 Mon Sep 17 00:00:00 2001 +From: "Gregory P. Smith" <68491+gpshead@users.noreply.github.com> +Date: Sat, 17 Jan 2026 10:23:57 -0800 +Subject: [PATCH] [3.11] gh-143916: Reject control characters in + wsgiref.headers.Headers (GH-143917) (GH-143973) + +gh-143916: Reject control characters in wsgiref.headers.Headers (GH-143917) + +* Add 'test.support' fixture for C0 control characters +* gh-143916: Reject control characters in wsgiref.headers.Headers + +(cherry picked from commit f7fceed79ca1bceae8dbe5ba5bc8928564da7211) +(cherry picked from commit 22e4d55285cee52bc4dbe061324e5f30bd4dee58) + +Co-authored-by: Gregory P. Smith <68491+gpshead@users.noreply.github.com> +Co-authored-by: Seth Michael Larson +--- + Lib/test/support/__init__.py | 7 ++ + Lib/test/test_wsgiref.py | 18 +++++ + Lib/wsgiref/headers.py | 34 ++++++---- + Misc/NEWS.d/next/Security/2026-01-16-11-07-36.gh-issue-143916.dpWeOD.rst | 2 + 4 files changed, 47 insertions(+), 14 deletions(-) + create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-07-36.gh-issue-143916.dpWeOD.rst + +Index: Python-3.11.14/Lib/test/support/__init__.py +=================================================================== +--- Python-3.11.14.orig/Lib/test/support/__init__.py 2026-02-11 23:22:45.373477280 +0100 ++++ Python-3.11.14/Lib/test/support/__init__.py 2026-02-11 23:23:25.641652256 +0100 +@@ -2294,3 +2294,10 @@ + fails_with_expat_2_6_0 = (unittest.expectedFailure + if is_expat_2_6_0 + else lambda test: test) ++ ++ ++def control_characters_c0() -> list[str]: ++ """Returns a list of C0 control characters as strings. ++ C0 control characters defined as the byte range 0x00-0x1F, and 0x7F. ++ """ ++ return [chr(c) for c in range(0x00, 0x20)] + ["\x7F"] +Index: Python-3.11.14/Lib/test/test_wsgiref.py +=================================================================== +--- Python-3.11.14.orig/Lib/test/test_wsgiref.py 2026-02-11 23:22:38.512011986 +0100 ++++ Python-3.11.14/Lib/test/test_wsgiref.py 2026-02-11 23:24:19.545119499 +0100 +@@ -1,6 +1,6 @@ + from unittest import mock + from test import support +-from test.support import socket_helper ++from test.support import socket_helper, control_characters_c0 + from test.test_httpservers import NoLogRequestHandler + from unittest import TestCase + from wsgiref.util import setup_testing_defaults +@@ -503,6 +503,22 @@ + '\r\n' + ) + ++ def testRaisesControlCharacters(self): ++ for c0 in control_characters_c0(): ++ with self.subTest(c0): ++ headers = Headers() ++ self.assertRaises(ValueError, headers.__setitem__, f"key{c0}", "val") ++ self.assertRaises(ValueError, headers.add_header, f"key{c0}", "val", param="param") ++ # HTAB (\x09) is allowed in values, not names. ++ if c0 == "\t": ++ headers["key"] = f"val{c0}" ++ headers.add_header("key", f"val{c0}") ++ headers.setdefault(f"key", f"val{c0}") ++ else: ++ self.assertRaises(ValueError, headers.__setitem__, "key", f"val{c0}") ++ self.assertRaises(ValueError, headers.add_header, "key", f"val{c0}", param="param") ++ self.assertRaises(ValueError, headers.add_header, "key", "val", param=f"param{c0}") ++ + class ErrorHandler(BaseCGIHandler): + """Simple handler subclass for testing BaseHandler""" + +Index: Python-3.11.14/Lib/wsgiref/headers.py +=================================================================== +--- Python-3.11.14.orig/Lib/wsgiref/headers.py 2026-02-11 23:22:38.927685306 +0100 ++++ Python-3.11.14/Lib/wsgiref/headers.py 2026-02-11 23:24:19.545709612 +0100 +@@ -9,6 +9,11 @@ + # existence of which force quoting of the parameter value. + import re + tspecials = re.compile(r'[ \(\)<>@,;:\\"/\[\]\?=]') ++# Disallowed characters for headers and values. ++# HTAB (\x09) is allowed in header values, but ++# not in header names. (RFC 9110 Section 5.5) ++_name_disallowed_re = re.compile(r'[\x00-\x1F\x7F]') ++_value_disallowed_re = re.compile(r'[\x00-\x08\x0A-\x1F\x7F]') + + def _formatparam(param, value=None, quote=1): + """Convenience function to format and return a key=value pair. +@@ -35,12 +40,15 @@ + self._headers = headers + if __debug__: + for k, v in headers: +- self._convert_string_type(k) +- self._convert_string_type(v) ++ self._convert_string_type(k, name=True) ++ self._convert_string_type(v, name=False) + +- def _convert_string_type(self, value): ++ def _convert_string_type(self, value, *, name): + """Convert/check value type.""" + if type(value) is str: ++ regex = (_name_disallowed_re if name else _value_disallowed_re) ++ if regex.search(value): ++ raise ValueError("Control characters not allowed in headers") + return value + raise AssertionError("Header names/values must be" + " of type str (got {0})".format(repr(value))) +@@ -53,14 +61,14 @@ + """Set the value of a header.""" + del self[name] + self._headers.append( +- (self._convert_string_type(name), self._convert_string_type(val))) ++ (self._convert_string_type(name, name=True), self._convert_string_type(val, name=False))) + + def __delitem__(self,name): + """Delete all occurrences of a header, if present. + + Does *not* raise an exception if the header is missing. + """ +- name = self._convert_string_type(name.lower()) ++ name = self._convert_string_type(name.lower(), name=True) + self._headers[:] = [kv for kv in self._headers if kv[0].lower() != name] + + def __getitem__(self,name): +@@ -87,13 +95,13 @@ + fields deleted and re-inserted are always appended to the header list. + If no fields exist with the given name, returns an empty list. + """ +- name = self._convert_string_type(name.lower()) ++ name = self._convert_string_type(name.lower(), name=True) + return [kv[1] for kv in self._headers if kv[0].lower()==name] + + + def get(self,name,default=None): + """Get the first header value for 'name', or return 'default'""" +- name = self._convert_string_type(name.lower()) ++ name = self._convert_string_type(name.lower(), name=True) + for k,v in self._headers: + if k.lower()==name: + return v +@@ -148,8 +156,8 @@ + and value 'value'.""" + result = self.get(name) + if result is None: +- self._headers.append((self._convert_string_type(name), +- self._convert_string_type(value))) ++ self._headers.append((self._convert_string_type(name, name=True), ++ self._convert_string_type(value, name=False))) + return value + else: + return result +@@ -172,13 +180,13 @@ + """ + parts = [] + if _value is not None: +- _value = self._convert_string_type(_value) ++ _value = self._convert_string_type(_value, name=False) + parts.append(_value) + for k, v in _params.items(): +- k = self._convert_string_type(k) ++ k = self._convert_string_type(k, name=True) + if v is None: + parts.append(k.replace('_', '-')) + else: +- v = self._convert_string_type(v) ++ v = self._convert_string_type(v, name=False) + parts.append(_formatparam(k.replace('_', '-'), v)) +- self._headers.append((self._convert_string_type(_name), "; ".join(parts))) ++ self._headers.append((self._convert_string_type(_name, name=True), "; ".join(parts))) +Index: Python-3.11.14/Misc/NEWS.d/next/Security/2026-01-16-11-07-36.gh-issue-143916.dpWeOD.rst +=================================================================== +--- /dev/null 1970-01-01 00:00:00.000000000 +0000 ++++ Python-3.11.14/Misc/NEWS.d/next/Security/2026-01-16-11-07-36.gh-issue-143916.dpWeOD.rst 2026-02-11 23:22:49.891193395 +0100 +@@ -0,0 +1,2 @@ ++Reject C0 control characters within wsgiref.headers.Headers fields, values, ++and parameters. diff --git a/python311.changes b/python311.changes index 1e38464..43a11fd 100644 --- a/python311.changes +++ b/python311.changes @@ -1,3 +1,29 @@ +------------------------------------------------------------------- +Wed Feb 11 19:09:06 CET 2026 - Matej Cepl + +- CVE-2025-11468: preserving parens when folding comments in + email headers (bsc#1257029, gh#python/cpython#143935). + CVE-2025-11468-email-hdr-fold-comment.patch +- CVE-2026-0672: rejects control characters in http cookies. + (bsc#1257031, gh#python/cpython#143919) + CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch +- CVE-2026-0865: rejecting control characters in + wsgiref.headers.Headers, which could be abused for injecting + false HTTP headers. (bsc#1257042, gh#python/cpython#143916) + CVE-2026-0865-wsgiref-ctrl-chars.patch +- CVE-2025-15366: basically the same as the previous patch for + IMAP protocol. (bsc#1257044, gh#python/cpython#143921) + CVE-2025-15366-imap-ctrl-chars.patch +- CVE-2025-15282: basically the same as the previous patch for + urllib library. (bsc#1257046, gh#python/cpython#143925) + CVE-2025-15282-urllib-ctrl-chars.patch +- CVE-2025-15367: basically the same as the previous patch for + poplib library. (bsc#1257041, gh#python/cpython#143923) + CVE-2025-15367-poplib-ctrl-chars.patch +- CVE-2025-12781: fix decoding with non-standard Base64 alphabet + (bsc#1257108, gh#python/cpython#125346) + CVE-2025-12781-b64decode-alt-chars.patch + ------------------------------------------------------------------- Thu Dec 18 10:33:44 UTC 2025 - Matej Cepl diff --git a/python311.spec b/python311.spec index 6f26349..a33ace6 100644 --- a/python311.spec +++ b/python311.spec @@ -200,6 +200,28 @@ Patch28: CVE-2025-12084-minidom-quad-search.patch # PATCH-FIX-UPSTREAM CVE-2025-13837-plistlib-mailicious-length.patch bsc#1254401 mcepl@suse.com # protect against OOM when loading malicious content Patch29: CVE-2025-13837-plistlib-mailicious-length.patch +# PATCH-FIX-UPSTREAM CVE-2025-11468-email-hdr-fold-comment.patch bsc#1257029 mcepl@suse.com +# this patch makes things totally awesome +Patch30: CVE-2025-11468-email-hdr-fold-comment.patch +# PATCH-FIX-UPSTREAM CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch bsc#1257031 mcepl@suse.com +# rejects control characters in http cookies. +Patch31: CVE-2026-0672-http-hdr-inject-cookie-Morsel.patch +# PATCH-FIX-UPSTREAM CVE-2026-0865-wsgiref-ctrl-chars.patch bsc#1257042 mcepl@suse.com +# Reject control characters in wsgiref.headers.Headers +Patch32: CVE-2026-0865-wsgiref-ctrl-chars.patch +# PATCH-FIX-UPSTREAM CVE-2025-15366-imap-ctrl-chars.patch bsc#1257044 mcepl@suse.com +# Reject control characters in wsgiref.headers.Headers +Patch33: CVE-2025-15366-imap-ctrl-chars.patch +# PATCH-FIX-UPSTREAM CVE-2025-15282-urllib-ctrl-chars.patch bsc#1257046 mcepl@suse.com +# Reject control characters in urllib +Patch34: CVE-2025-15282-urllib-ctrl-chars.patch +# PATCH-FIX-UPSTREAM CVE-2025-15367-poplib-ctrl-chars.patch bsc#1257041 mcepl@suse.com +# Reject control characters in poplib +Patch35: CVE-2025-15367-poplib-ctrl-chars.patch +# PATCH-FIX-UPSTREAM CVE-2025-12781-b64decode-alt-chars.patch bsc#1257108 mcepl@suse.com +# Fix decoding with non-standard Base64 alphabet gh#python/cpython#125346 +Patch36: CVE-2025-12781-b64decode-alt-chars.patch +### END OF PATCHES BuildRequires: autoconf-archive BuildRequires: automake BuildRequires: crypto-policies-scripts @@ -555,6 +577,9 @@ LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH \ %check export SUSE_VERSION="0%{?suse_version}" export SLE_VERSION="0%{?sle_version}" +echo "Show the openssl version" +openssl version -a +echo "" %if %{with general} # exclude test_gdb -- it doesn't run in buildservice anyway, and fails on missing debuginfos # when you install gdb into your test env