From b20e2a50f4f09cfe2f082c753bc32f99605ae23a Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 15:00:19 -0500 Subject: [PATCH 01/24] Address CVE-2025-8194 (tarfile) and CVE-2026-4786 (webbrowser) CVE-2025-8194: tarfile accepted negative member offsets (reachable via a PAX extended header with a negative "size"), causing TarInfo._block to return a negative block count that moved the archive offset backwards and could hang (seekable files) or raise StreamError (streams). _block now rejects negative counts with InvalidHeaderError. CVE-2026-4786 / CVE-2026-4519: webbrowser.open passed attacker-controlled URLs to the browser command line unvalidated, so a URL starting with "-" could be treated as a command-line option (argument injection). Add BaseBrowser._check_url and call it from GenericBrowser, BackgroundBrowser and UnixBrowser; UnixBrowser validates the URL after %action expansion and substitutes %action before %s so %action cannot smuggle a leading dash. Adds tests in test_tarfile (negative _block count and a PAX negative-size archive) and a new test_webbrowser covering URL rejection and the %action bypass. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/tarfile.py | 9 ++++--- Lib/test/test_tarfile.py | 33 ++++++++++++++++++++++++ Lib/test/test_webbrowser.py | 51 +++++++++++++++++++++++++++++++++++++ Lib/webbrowser.py | 13 +++++++++- 4 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 Lib/test/test_webbrowser.py diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 4aec4ea14548ddc..f0a4c955de522ca 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2,7 +2,7 @@ #------------------------------------------------------------------- # tarfile.py #------------------------------------------------------------------- -# Copyright (C) 2002 Lars Gustäbel +# Copyright (C) 2002 Lars Gust�bel # All rights reserved. # # Permission is hereby granted, free of charge, to any person @@ -33,10 +33,10 @@ # $Source$ version = "0.9.0" -__author__ = "Lars Gustäbel (lars@gustaebel.de)" +__author__ = "Lars Gust�bel (lars@gustaebel.de)" __date__ = "$Date$" __cvsid__ = "$Id$" -__credits__ = "Gustavo Niemeyer, Niels Gustäbel, Richard Townsend." +__credits__ = "Gustavo Niemeyer, Niels Gust�bel, Richard Townsend." #--------- # Imports @@ -1475,6 +1475,9 @@ def _block(self, count): """Round up a byte count by BLOCKSIZE and return it, e.g. _block(834) => 1024. """ + # Only non-negative offsets are allowed + if count < 0: + raise InvalidHeaderError("invalid offset") blocks, remainder = divmod(count, BLOCKSIZE) if remainder: blocks += 1 diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 4396c384ad667d7..403cc9aec953d16 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -910,6 +910,30 @@ def test_pax_header_bad_formats(self): with self.assertRaisesRegexp(tarfile.ReadError, r"file could not be opened successfully"): tarfile.open(tmpname, encoding="iso8859-1") + def test_pax_header_negative_size(self): + # A pax header with a negative "size" must be rejected rather than + # producing a negative member offset (CVE-2025-8194, gh-130577). + tar = tarfile.open(tmpname, "w", format=tarfile.PAX_FORMAT, + encoding="iso8859-1") + try: + t = tarfile.TarInfo() + t.name = "pax" + t.uid = 1 + t.pax_headers = {"foo": "bar"} + tar.addfile(t) + finally: + tar.close() + with open(tmpname, "rb") as f: + data = f.read() + self.assertIn(b"11 foo=bar\n", data) + # "13 size=-512\n" -- record length (13) includes itself and the newline + data = data.replace(b"11 foo=bar\n", b"13 size=-512\n") + with open(tmpname, "wb") as f: + f.truncate() + f.write(data) + with self.assertRaisesRegexp(tarfile.ReadError, r"file could not be opened successfully"): + tarfile.open(tmpname, encoding="iso8859-1") + class WriteTestBase(unittest.TestCase): # Put all write tests in here that are supposed to be tested @@ -2005,6 +2029,15 @@ def test_char_fields(self): self.assertEqual(tarfile.nts(b"foo\0bar\0"), "foo") + def test_block_negative_count(self): + # gh-130577: _block() must reject negative byte counts instead of + # returning a negative (rounded) value that yields a backward offset. + tarinfo = tarfile.TarInfo("foo") + self.assertEqual(tarinfo._block(834), 1024) + self.assertEqual(tarinfo._block(0), 0) + for bad in (-1, -512, -(2 ** 71)): + self.assertRaises(tarfile.InvalidHeaderError, tarinfo._block, bad) + def test_read_number_fields(self): # Issue 13158: Test if GNU tar specific base-256 number fields # are decoded correctly. diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py new file mode 100644 index 000000000000000..9c4fbd8a6668f92 --- /dev/null +++ b/Lib/test/test_webbrowser.py @@ -0,0 +1,51 @@ +import unittest +import webbrowser + +from test import test_support + + +class FakeUnixBrowser(webbrowser.UnixBrowser): + # Concrete UnixBrowser with string actions so open() can run far enough + # to perform URL validation without launching anything. + remote_args = ['%action', '%s'] + remote_action = "" + remote_action_newwin = "-new-window" + remote_action_newtab = "-new-tab" + + +class CheckURLTest(unittest.TestCase): + # gh-bpo: webbrowser.open() must not let an attacker-controlled URL be + # turned into a command-line option (CVE-2026-4519 / CVE-2026-4786). + + def test_check_url_rejects_leading_dash(self): + for bad in ("-remote", "--incognito", " -leadingspace", "\t-tab"): + self.assertRaises(ValueError, + webbrowser.BaseBrowser._check_url, bad) + + def test_check_url_allows_normal(self): + for ok in ("http://example.com", "https://x/-dash-inside", ""): + # Must not raise. + webbrowser.BaseBrowser._check_url(ok) + + def test_generic_browser_rejects_dash_url(self): + browser = webbrowser.GenericBrowser(["true", "%s"]) + self.assertRaises(ValueError, browser.open, "-dangerous") + + def test_unix_browser_rejects_dash_url(self): + browser = FakeUnixBrowser("fakebrowser") + self.assertRaises(ValueError, browser.open, "-dangerous") + + def test_unix_browser_rejects_action_bypass(self): + # The %action substitution must not be usable to smuggle a leading + # dash past the check (CVE-2026-4786). With new=1 the action expands + # to "-new-window", so a "%action" URL would become a bare flag. + browser = FakeUnixBrowser("fakebrowser") + self.assertRaises(ValueError, browser.open, "%action", 1) + + +def test_main(): + test_support.run_unittest(CheckURLTest) + + +if __name__ == "__main__": + test_main() diff --git a/Lib/webbrowser.py b/Lib/webbrowser.py index 15eeb660e258318..fb8b848178a68e7 100755 --- a/Lib/webbrowser.py +++ b/Lib/webbrowser.py @@ -144,6 +144,12 @@ def __init__(self, name=""): self.name = name self.basename = name + @staticmethod + def _check_url(url): + """Ensures that the URL is safe to pass to subprocesses as a parameter""" + if url and url.lstrip().startswith("-"): + raise ValueError("Invalid URL (leading dash disallowed): %r" % (url,)) + def open(self, url, new=0, autoraise=True): raise NotImplementedError @@ -169,6 +175,7 @@ def __init__(self, name): self.basename = os.path.basename(self.name) def open(self, url, new=0, autoraise=True): + self._check_url(url) cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] try: @@ -186,6 +193,7 @@ class BackgroundBrowser(GenericBrowser): background.""" def open(self, url, new=0, autoraise=True): + self._check_url(url) cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] try: @@ -270,8 +278,11 @@ def open(self, url, new=0, autoraise=True): raise Error("Bad 'new' parameter to open(); " + "expected 0, 1, or 2, got %s" % new) - args = [arg.replace("%s", url).replace("%action", action) + self._check_url(url.replace("%action", action)) + + args = [arg.replace("%action", action).replace("%s", url) for arg in self.remote_args] + args = [arg for arg in args if arg] success = self._invoke(args, True, autoraise) if not success: # remote invocation failed, try straight way From ec88ac684af303b75ac6c15d1a4781b571c4337a Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 15:41:17 -0500 Subject: [PATCH 02/24] Reject control characters in header/command APIs (injection cluster) Backports a uniform "reject C0 control characters and DEL" defense across several stdlib modules where unvalidated user input was emitted into protocol headers/commands, enabling CR/LF (and NUL) injection: - wsgiref.headers.Headers: validate name/value in __init__, __setitem__ and add_header (CVE-2026-0865), raising ValueError. - Cookie.Morsel: reject control chars in set()/__setitem__ key, value and coded value (CVE-2026-0672), raising CookieError. Validation lives at the value-storage chokepoints, so the CVE-2026-3644-style bypasses do not apply (2.7 has no Morsel.update/|=/__setstate__). - imaplib.IMAP4._command: reject control chars in command arguments (CVE-2025-15366), raising ValueError. - poplib.POP3._putline (and the SSL override): reject control chars in the command line (CVE-2025-15367), raising error_proto. - httplib.HTTPConnection.set_tunnel: validate the CONNECT tunnel host via the existing _validate_host (CVE-2026-1502), raising InvalidURL. Adds focused tests to test_cookie, test_wsgiref, test_imaplib, test_poplib and test_httplib. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/Cookie.py | 23 +++++++++++++++++++---- Lib/httplib.py | 3 +++ Lib/imaplib.py | 5 +++++ Lib/poplib.py | 8 ++++++++ Lib/test/test_cookie.py | 22 +++++++++++++++++----- Lib/test/test_httplib.py | 10 ++++++++++ Lib/test/test_imaplib.py | 20 ++++++++++++++++++++ Lib/test/test_poplib.py | 8 ++++++++ Lib/test/test_wsgiref.py | 13 +++++++++++++ Lib/wsgiref/headers.py | 18 ++++++++++++++++++ 10 files changed, 121 insertions(+), 9 deletions(-) diff --git a/Lib/Cookie.py b/Lib/Cookie.py index a6ba4a92ed7227c..c5df671f811fd1d 100644 --- a/Lib/Cookie.py +++ b/Lib/Cookie.py @@ -92,13 +92,14 @@ 'Set-Cookie: chips=ahoy\r\nSet-Cookie: vienna=finger' The load() method is darn-tootin smart about identifying cookies -within a string. Escaped quotation marks, nested semicolons, and other -such trickeries do not confuse it. +within a string. Escaped quotation marks and nested semicolons do not +confuse it. (Note that cookies whose values contain control characters +are now rejected to prevent Set-Cookie header injection; CVE-2026-0672.) >>> C = Cookie.SmartCookie() - >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=\\012;";') + >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=delicious;";') >>> print C - Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=\012;" + Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=delicious;" Each element of the Cookie also supports all of the RFC 2109 Cookie attributes. Here's an example which sets the Path @@ -242,6 +243,15 @@ class CookieError(Exception): # _Translator hash-table for fast quoting # _LegalChars = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~" +_control_character_re = re.compile(r'[\x00-\x1f\x7f]') + +def _has_control_character(*values): + """Return True if any of the given string values holds a control char.""" + for v in values: + if isinstance(v, basestring) and _control_character_re.search(v): + return True + return False + _Translator = { '\000' : '\\000', '\001' : '\\001', '\002' : '\\002', '\003' : '\\003', '\004' : '\\004', '\005' : '\\005', @@ -424,6 +434,8 @@ def __setitem__(self, K, V): K = K.lower() if not K in self._reserved: raise CookieError("Invalid Attribute %s" % K) + if _has_control_character(K, V): + raise CookieError("Control characters are not allowed in cookies: %r %r" % (K, V)) dict.__setitem__(self, K, V) # end __setitem__ @@ -440,6 +452,9 @@ def set(self, key, val, coded_val, raise CookieError("Attempt to set a reserved key: %s" % key) if "" != translate(key, idmap, LegalChars): raise CookieError("Illegal key value: %s" % 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 diff --git a/Lib/httplib.py b/Lib/httplib.py index a63677477d59bbb..9c8c5eedccb04a3 100644 --- a/Lib/httplib.py +++ b/Lib/httplib.py @@ -773,6 +773,9 @@ def set_tunnel(self, host, port=None, headers=None): if self.sock: raise RuntimeError("Can't setup tunnel for established connection.") + # Reject control characters (notably CR/LF) in the tunnel host so they + # cannot be injected into the CONNECT request line (CVE-2026-1502). + self._validate_host(host) self._tunnel_host, self._tunnel_port = self._get_hostport(host, port) if headers: self._tunnel_headers = headers diff --git a/Lib/imaplib.py b/Lib/imaplib.py index 679c468251be529..dd4856fe8425136 100644 --- a/Lib/imaplib.py +++ b/Lib/imaplib.py @@ -104,6 +104,9 @@ Response_code = re.compile(r'\[(?P[A-Z-]+)( (?P[^\]]*))?\]') Untagged_response = re.compile(r'\* (?P[A-Z-]+)( (?P.*))?') Untagged_status = re.compile(r'\* (?P\d+) (?P[A-Z-]+)( (?P.*))?') +# Control characters (C0 and DEL) must not appear in command arguments; +# otherwise CR/LF could be used to inject extra IMAP commands (CVE-2025-15366). +_control_chars = re.compile(r'[\x00-\x1f\x7f]') @@ -852,6 +855,8 @@ def _command(self, name, *args): data = '%s %s' % (tag, name) for arg in args: if arg is None: continue + if isinstance(arg, basestring) and _control_chars.search(arg): + raise ValueError("Control characters not allowed in commands") data = '%s %s' % (data, self._checkquote(arg)) literal = self.literal diff --git a/Lib/poplib.py b/Lib/poplib.py index a238510b38fc6a2..8b42324bc727137 100644 --- a/Lib/poplib.py +++ b/Lib/poplib.py @@ -32,6 +32,10 @@ class error_proto(Exception): pass LF = '\n' CRLF = CR+LF +# Control characters (C0 and DEL) must not appear in a command line; otherwise +# CR/LF could be used to inject extra POP3 commands (CVE-2025-15367). +_control_chars = re.compile(r'[\x00-\x1f\x7f]') + # maximal line length when calling readline(). This is to prevent # reading arbitrary length lines. RFC 1939 limits POP3 line length to # 512 characters, including CRLF. We have selected 2048 just to be on @@ -94,6 +98,8 @@ def __init__(self, host, port=POP3_PORT, def _putline(self, line): if self._debugging > 1: print '*put*', repr(line) + if isinstance(line, basestring) and _control_chars.search(line): + raise error_proto('line contains control characters') self.sock.sendall('%s%s' % (line, CRLF)) @@ -389,6 +395,8 @@ def _getline(self): def _putline(self, line): if self._debugging > 1: print '*put*', repr(line) + if isinstance(line, basestring) and _control_chars.search(line): + raise error_proto('line contains control characters') line += CRLF bytes = len(line) while bytes > 0: diff --git a/Lib/test/test_cookie.py b/Lib/test/test_cookie.py index efd2ed3cea49970..877cb71f383f7db 100644 --- a/Lib/test/test_cookie.py +++ b/Lib/test/test_cookie.py @@ -8,6 +8,21 @@ class CookieTests(unittest.TestCase): + + def test_control_characters_rejected(self): + # CVE-2026-0672: control characters (e.g. CR/LF) in cookie values or + # attributes must be rejected to prevent Set-Cookie header injection. + M = Cookie.Morsel() + self.assertRaises(Cookie.CookieError, M.set, + 'key', 'value\r\nInjected: header', 'value') + self.assertRaises(Cookie.CookieError, M.set, + 'key', 'value', 'coded\nvalue') + self.assertRaises(Cookie.CookieError, M.__setitem__, + 'path', '/\r\nSet-Cookie: pwned=1') + # Benign values still work. + M.set('key', 'value', 'value') + M['path'] = '/ok' + # Currently this only tests SimpleCookie def test_basic(self): cases = [ @@ -17,11 +32,8 @@ def test_basic(self): '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;"', - }, + # Control characters in cookie values are now rejected + # (CVE-2026-0672); see test_control_characters_rejected. # Check illegal cookies that have an '=' char in an unquoted value { 'data': 'keebler=E=mc2', diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index ce60967be287300..e7ee76b0226d096 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -989,6 +989,16 @@ def test_host_port(self): class TunnelTests(TestCase): + def test_set_tunnel_rejects_control_characters(self): + # CVE-2026-1502: CR/LF (and other control chars) in the tunnel host + # must be rejected so they cannot be injected into the CONNECT request. + conn = httplib.HTTPConnection('proxy.com') + self.assertRaises(httplib.InvalidURL, conn.set_tunnel, + 'destination.com\r\nInjected: header') + self.assertRaises(httplib.InvalidURL, conn.set_tunnel, 'evil\nhost') + # A benign host is still accepted. + conn.set_tunnel('destination.com') + def test_connect(self): response_text = ( 'HTTP/1.0 200 OK\r\n\r\n' # Reply to CONNECT diff --git a/Lib/test/test_imaplib.py b/Lib/test/test_imaplib.py index acaad63b6a3acbc..92df02111ddafe9 100644 --- a/Lib/test/test_imaplib.py +++ b/Lib/test/test_imaplib.py @@ -23,6 +23,26 @@ class TestImaplib(unittest.TestCase): + def test_command_rejects_control_characters(self): + # CVE-2025-15366: control characters (e.g. CR/LF) in command arguments + # must be rejected to prevent IMAP command injection. + # imaplib.IMAP4 is an old-style class, so we use a subclass with a + # no-op __init__ to skip the networking parent.__init__. + class _NoConnIMAP4(imaplib.IMAP4): + def __init__(self): pass + imap = _NoConnIMAP4() + imap.state = 'AUTH' + imap.untagged_responses = {} + imap.is_readonly = False + imap.literal = None + imap.tagpre = 'IMAP' + imap.tagnum = 0 + imap.tagged_commands = {} + self.assertRaises(ValueError, imap._command, + 'SELECT', 'inbox\r\nX LOGOUT') + self.assertRaises(ValueError, imap._command, + 'SELECT', 'inbox\x00') + def test_that_Time2Internaldate_returns_a_result(self): # We can check only that it successfully produces a result, # not the correctness of the result itself, since the result diff --git a/Lib/test/test_poplib.py b/Lib/test/test_poplib.py index d2143759ba6652e..bbde7a146e6dab0 100644 --- a/Lib/test/test_poplib.py +++ b/Lib/test/test_poplib.py @@ -156,6 +156,14 @@ def handle_error(self): class TestPOP3Class(TestCase): + def test_putline_rejects_control_characters(self): + # CVE-2025-15367: control characters (e.g. CR/LF) in a command line + # must be rejected to prevent POP3 command injection. + self.assertRaises(poplib.error_proto, self.client._putline, + 'USER guido\r\nDELE 1') + self.assertRaises(poplib.error_proto, self.client._putline, + 'PASS secret\x00') + def assertOK(self, resp): self.assertTrue(resp.startswith("+OK")) diff --git a/Lib/test/test_wsgiref.py b/Lib/test/test_wsgiref.py index 20129e7edc7ea1d..817b505afc95aa0 100644 --- a/Lib/test/test_wsgiref.py +++ b/Lib/test/test_wsgiref.py @@ -300,6 +300,19 @@ def testHopByHop(self): class HeaderTests(TestCase): + def testControlCharactersRejected(self): + # CVE-2026-0865: control characters in header names/values must be + # rejected to prevent HTTP response splitting / header injection. + h = Headers([]) + self.assertRaises(ValueError, h.__setitem__, 'Foo', 'bar\r\nInjected: 1') + self.assertRaises(ValueError, h.__setitem__, 'Ba\nd', 'value') + self.assertRaises(ValueError, h.add_header, 'Foo', 'a\nb') + self.assertRaises(ValueError, h.add_header, 'Foo', 'ok', baz='x\ry') + self.assertRaises(ValueError, Headers, [('Foo', 'a\nb')]) + # Benign headers still work. + h['Foo'] = 'bar' + h.add_header('Content-Disposition', 'attachment', filename='ok.txt') + def testMappingInterface(self): test = [('x','y')] self.assertEqual(len(Headers([])),0) diff --git a/Lib/wsgiref/headers.py b/Lib/wsgiref/headers.py index 5a95e84c3420ec6..dec810b7a993f0f 100644 --- a/Lib/wsgiref/headers.py +++ b/Lib/wsgiref/headers.py @@ -12,6 +12,16 @@ import re tspecials = re.compile(r'[ \(\)<>@,;:\\"/\[\]\?=]') +# Match C0 control characters and DEL, which must never appear in a header +# name or value (they would allow HTTP response splitting / header injection). +_control_chars_re = re.compile(r'[\x00-\x1f\x7f]') + +def _check_string(value): + """Reject header names/values containing control characters.""" + if isinstance(value, str) and _control_chars_re.search(value): + raise ValueError("Control characters not allowed in headers") + return value + def _formatparam(param, value=None, quote=1): """Convenience function to format and return a key=value pair. @@ -34,6 +44,9 @@ class Headers: def __init__(self,headers): if type(headers) is not ListType: raise TypeError("Headers must be a list of name/value tuples") + for name, val in headers: + _check_string(name) + _check_string(val) self._headers = headers def __len__(self): @@ -42,6 +55,8 @@ def __len__(self): def __setitem__(self, name, val): """Set the value of a header.""" + _check_string(name) + _check_string(val) del self[name] self._headers.append((name, val)) @@ -158,12 +173,15 @@ def add_header(self, _name, _value, **_params): *not* handle '(charset, language, value)' tuples: all values must be strings or None. """ + _check_string(_name) parts = [] if _value is not None: + _check_string(_value) parts.append(_value) for k, v in _params.items(): if v is None: parts.append(k.replace('_', '-')) else: + _check_string(v) parts.append(_formatparam(k.replace('_', '-'), v)) self._headers.append((_name, "; ".join(parts))) From f4c3f15adef7d2179f485833c72fb3bbfecfcd03 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 15:48:12 -0500 Subject: [PATCH 03/24] Reject header injection when generating email messages (CVE-2024-6923) email.generator.Generator wrote header values verbatim, so a value containing a bare CR/LF (e.g. set via msg['To'] = 'a\r\nBcc: x') could inject additional headers or body content. Port the upstream verify_generated_headers behaviour into _write_headers: after computing each header's serialized form, reject it (raise the new email.errors.HeaderWriteError) if it contains a CR/LF that is not part of valid folding, using NEWLINE_WITHOUT_FWSP = re.compile( r'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]'). Since 2.7's email has no policy framework, the check is unconditional (matching upstream's default-on). Adds email.errors.HeaderWriteError and a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/email/errors.py | 4 ++++ Lib/email/generator.py | 22 +++++++++++++++++----- Lib/email/test/test_email.py | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Lib/email/errors.py b/Lib/email/errors.py index d52a624601f0921..65da52f1b207ffd 100644 --- a/Lib/email/errors.py +++ b/Lib/email/errors.py @@ -30,6 +30,10 @@ class CharsetError(MessageError): """An illegal charset was given.""" +class HeaderWriteError(MessageError): + """Error while writing headers.""" + + # These are parsing defects which the parser was able to work around. class MessageDefect: diff --git a/Lib/email/generator.py b/Lib/email/generator.py index e50f912c5a40dd1..28e334888fe9aaf 100644 --- a/Lib/email/generator.py +++ b/Lib/email/generator.py @@ -13,6 +13,12 @@ from cStringIO import StringIO from email.header import Header +from email.errors import HeaderWriteError + +# Matches a CR/LF that is NOT part of a valid header folding (i.e. not +# immediately followed by folding whitespace). Used to detect injected +# newlines in generated headers (CVE-2024-6923). +NEWLINE_WITHOUT_FWSP = re.compile(r'\r\n[^ \t]|\r[^ \n\t]|\n[^ \t]') UNDERSCORE = '_' NL = '\n' @@ -139,13 +145,12 @@ def _dispatch(self, msg): def _write_headers(self, msg): for h, v in msg.items(): - print >> self._fp, '%s:' % h, if self._maxheaderlen == 0: # Explicit no-wrapping - print >> self._fp, v + value = v elif isinstance(v, Header): # Header instances know what to do - print >> self._fp, v.encode() + value = v.encode() elif _is8bitstring(v): # If we have raw 8bit data in a byte string, we have no idea # what the encoding is. There is no safe way to split this @@ -153,15 +158,22 @@ def _write_headers(self, msg): # ascii split, but if it's multibyte then we could break the # string. There's no way to know so the least harm seems to # be to not split the string and risk it being too long. - print >> self._fp, v + value = v else: # Header's got lots of smarts, so use it. Note that this is # fundamentally broken though because we lose idempotency when # the header string is continued with tabs. It will now be # continued with spaces. This was reversedly broken before we # fixed bug 1974. Either way, we lose. - print >> self._fp, Header( + value = Header( v, maxlinelen=self._maxheaderlen, header_name=h).encode() + # Reject headers that contain an injected newline, i.e. a CR/LF + # that is not part of valid header folding (CVE-2024-6923). + folded = '%s: %s' % (h, value) + if NEWLINE_WITHOUT_FWSP.search(folded): + raise HeaderWriteError( + "header value contains an unexpected newline: %r" % (folded,)) + print >> self._fp, folded # A blank line always separates headers from body print >> self._fp diff --git a/Lib/email/test/test_email.py b/Lib/email/test/test_email.py index f143689a937c1ba..2cd1be37f1f09ac 100644 --- a/Lib/email/test/test_email.py +++ b/Lib/email/test/test_email.py @@ -77,6 +77,26 @@ def _msgobj(self, filename): # Test various aspects of the Message class's API class TestMessageAPI(TestEmailBase): + def test_string_rejects_header_injection(self): + # CVE-2024-6923: generating a message must reject header values that + # contain an injected newline (one not part of valid folding). The + # no-wrap path writes the value verbatim, which deterministically + # exercises the check. + import email.errors + from email.generator import Generator + from cStringIO import StringIO + for bad in ('value\r\nInjected: header', + 'value\nInjected: header', + 'value\rstuff'): + msg = Message() + msg['Subject'] = bad + g = Generator(StringIO(), maxheaderlen=0) + self.assertRaises(email.errors.HeaderWriteError, g.flatten, msg) + # A normal header is still emitted fine. + msg = Message() + msg['Subject'] = 'a normal subject that is reasonably short' + self.assertIn('Subject: a normal subject', msg.as_string()) + def test_get_all(self): eq = self.assertEqual msg = self._msgobj('msg_20.txt') From c54ba12fd923ece6e374418119074c1ea1cf9db7 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 16:11:20 -0500 Subject: [PATCH 04/24] Harden zipfile against overlapping entries and bad ZIP64 locator CVE-2024-0450: zipfile did not detect "quoted overlap" archives where an entry's compressed data overruns the start of the next entry, a high-ratio zip bomb. _RealGetContents now records each member's _end_offset (the start of the next local header, or the central directory for the last member) and ZipFile.open raises BadZipfile if an entry's data would extend past it. CVE-2025-8291: _EndRecData64 trusted that the ZIP64 end-of-central-directory record sat immediately before its locator and ignored the locator's stored relative offset. It now rejects archives whose locator offset points past the expected record position ("Corrupt zip64 end of central directory locator"). Adds _end_offset to ZipInfo (slots + __init__) and regression tests for both issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/test/test_zipfile.py | 29 +++++++++++++++++++++++++++++ Lib/zipfile.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/Lib/test/test_zipfile.py b/Lib/test/test_zipfile.py index 4e545f140a835ad..248842f59a39e65 100644 --- a/Lib/test/test_zipfile.py +++ b/Lib/test/test_zipfile.py @@ -940,6 +940,35 @@ class OtherTests(unittest.TestCase): b'\x01\x003\x00\x00\x003\x00\x00\x00\x00\x00'), } + def test_overlapping_entries_rejected(self): + # CVE-2024-0450: an entry whose compressed data overruns the start of + # the following entry (a "quoted overlap" zip bomb) must be rejected. + buf = StringIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as zf: + zf.writestr("a", "a" * 1000) + zf = zipfile.ZipFile(buf, "r") + try: + info = zf.getinfo("a") + # Sanity: a normal archive reads fine and has a known end offset. + self.assertEqual(zf.read("a"), "a" * 1000) + self.assertIsNotNone(info._end_offset) + # Simulate an overlap by shrinking the member's end boundary. + info._end_offset = info.header_offset + 1 + self.assertRaises(zipfile.BadZipfile, zf.open, "a") + finally: + zf.close() + + def test_zip64_locator_bad_offset_rejected(self): + # CVE-2025-8291: a ZIP64 end-of-central-directory locator whose + # relative offset points past the expected record must be rejected. + loc = struct.pack(zipfile.structEndArchive64Locator, + zipfile.stringEndArchive64Locator, 0, 10 ** 9, 1) + buf = ('\0' * zipfile.sizeEndCentDir64 + loc + + '\0' * zipfile.sizeEndCentDir) + fpin = StringIO(buf) + self.assertRaises(zipfile.BadZipfile, zipfile._EndRecData64, + fpin, -zipfile.sizeEndCentDir, [0] * 10) + def test_unicode_filenames(self): with zipfile.ZipFile(TESTFN, "w") as zf: zf.writestr(u"foo.txt", "Test for unicode filename") diff --git a/Lib/zipfile.py b/Lib/zipfile.py index 991a0add205d173..2dba47e62cb91cd 100644 --- a/Lib/zipfile.py +++ b/Lib/zipfile.py @@ -182,6 +182,8 @@ def _EndRecData64(fpin, offset, endrec): """ Read the ZIP64 end-of-archive records and use that to update endrec """ + fpin.seek(0, 2) + filesize = fpin.tell() try: fpin.seek(offset - sizeEndCentDir64Locator, 2) except IOError: @@ -199,6 +201,14 @@ def _EndRecData64(fpin, offset, endrec): if diskno != 0 or disks != 1: raise BadZipfile("zipfiles that span multiple disks are not supported") + # The ZIP64 end of central directory record is expected to lie immediately + # before the locator. Reject archives whose locator's relative offset + # points past that position, instead of trusting the assumed adjacency + # (CVE-2025-8291). + expected_reloff = filesize + offset - sizeEndCentDir64Locator - sizeEndCentDir64 + if reloff > expected_reloff: + raise BadZipfile("Corrupt zip64 end of central directory locator") + # Assume no 'zip64 extensible data' fpin.seek(offset - sizeEndCentDir64Locator - sizeEndCentDir64, 2) data = fpin.read(sizeEndCentDir64) @@ -305,6 +315,7 @@ class ZipInfo (object): 'compress_size', 'file_size', '_raw_time', + '_end_offset', ) def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): @@ -343,6 +354,9 @@ def __init__(self, filename="NoName", date_time=(1980,1,1,0,0,0)): self.volume = 0 # Volume number of file header self.internal_attr = 0 # Internal attributes self.external_attr = 0 # External file attributes + self._end_offset = None # Start of the next local header (or + # the central directory); used to + # detect overlapping entries # Other attributes are set by class ZipFile: # header_offset Byte offset to the file header # CRC CRC-32 of the uncompressed file @@ -891,6 +905,17 @@ def _RealGetContents(self): if self.debug > 2: print "total", total + # Compute the end of each member's data as the start of the next + # member's local header (or the start of the central directory for the + # last member). This lets open() reject overlapping entries, i.e. a + # "quoted overlap" zip bomb (CVE-2024-0450). + end_offset = self.start_dir + for zinfo in sorted(self.filelist, + key=lambda zinfo: zinfo.header_offset, + reverse=True): + zinfo._end_offset = end_offset + end_offset = zinfo.header_offset + def namelist(self): """Return a list of file names in the archive.""" @@ -1002,6 +1027,14 @@ def open(self, name, mode="r", pwd=None): 'File name in directory "%s" and header "%s" differ.' % ( zinfo.orig_filename, fname) + # Reject entries whose compressed data would extend past the start + # of the next entry: overlapping members are a zip bomb vector + # (CVE-2024-0450). + if (zinfo._end_offset is not None and + zef_file.tell() + zinfo.compress_size > zinfo._end_offset): + raise BadZipfile("Overlapped entries: %r (possible zip bomb)" + % (zinfo.orig_filename,)) + # check for encrypted flag & handle password is_encrypted = zinfo.flag_bits & 0x1 zd = None From 6e3990b63473e6e61584bb1d81e58ae96a63998e Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 16:14:41 -0500 Subject: [PATCH 05/24] Reject misplaced square brackets in parsed URL hosts (CVE-2025-0938) urlparse.urlsplit only rejected mismatched IPv6 brackets, so a netloc like "ex[ample].com" or "[example.com]" was accepted, parsing differently from RFC 3986-compliant tools (a differential-parsing / SSRF vector). Add _check_bracketed_netloc / _check_bracketed_host (ported from the upstream fix) and call them from both urlsplit code paths. Brackets are now allowed only when they enclose a valid IPv6/IPvFuture host. Since 2.7 lacks the ipaddress module, IPv6 content is validated via socket.inet_pton (with a conservative character fallback where inet_pton is unavailable). Adds a regression test covering rejected and accepted bracketed hosts. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/test/test_urlparse.py | 20 +++++++++++++++++++ Lib/urlparse.py | 42 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/Lib/test/test_urlparse.py b/Lib/test/test_urlparse.py index f924289041a7a10..9f17975f06e6946 100644 --- a/Lib/test/test_urlparse.py +++ b/Lib/test/test_urlparse.py @@ -679,6 +679,26 @@ def test_urlsplit_strip_url(self): self.assertEqual(p.scheme, "https") self.assertEqual(p.geturl(), "https://www.python.org/") + def test_invalid_bracketed_host(self): + # CVE-2025-0938: square brackets must only enclose a valid + # IPv6/IPvFuture host, not appear elsewhere in the host/netloc. + invalid = [ + "http://ex[ample].com/", + "http://[example.com]/", + "http://[1.2.3.4]/", + "http://[v1.x]extra/", + "http://[fe80::g]/", + "http://user[x]@example.com/", + ] + for url in invalid: + for parse in (urlparse.urlsplit, urlparse.urlparse): + self.assertRaises(ValueError, parse, url) + # Valid bracketed IPv6/IPvFuture hosts are still accepted. + for url in ("http://[::1]/", "http://[::1]:8080/path", + "http://[2001:db8::1]/", "http://[v1.fe80::1]/"): + urlparse.urlsplit(url) + urlparse.urlparse(url) + def test_attributes_bad_port_a(self): """Check handling of invalid ports.""" for bytes in (False, True): diff --git a/Lib/urlparse.py b/Lib/urlparse.py index 0f12940c3dc0edc..4e7e5e5d0257919 100644 --- a/Lib/urlparse.py +++ b/Lib/urlparse.py @@ -198,6 +198,44 @@ def _checknetloc(netloc): "under NFKC normalization" % netloc) +def _check_bracketed_host(hostname): + # Validate the content of a bracketed (IPv6 / IPvFuture) host. ipaddress + # is unavailable in Python 2, so IPv6 is validated via socket.inet_pton + # when present, with a conservative character fallback otherwise. + if hostname.startswith('v'): + if not re.match(r"\Av[a-fA-F0-9]+\..+\Z", hostname): + raise ValueError("IPvFuture address is invalid") + elif ':' not in hostname: + # A bare domain name or IPv4 address is not allowed in brackets. + raise ValueError("An IPv4 address cannot be in brackets") + else: + try: + import socket + socket.inet_pton(socket.AF_INET6, hostname) + except AttributeError: + # inet_pton may be missing (e.g. Windows under Python 2). + if not re.match(r"\A[0-9A-Fa-f:.]+\Z", hostname): + raise ValueError("Invalid IPv6 address") + except (ValueError, socket.error): + raise ValueError("Invalid IPv6 address") + +def _check_bracketed_netloc(netloc): + # Reject '[' / ']' that do not delimit a valid IPv6/IPvFuture host + # (CVE-2025-0938). This mirrors the splitting done in _hostinfo(). + hostname_and_port = netloc.rpartition('@')[2] + before_bracket, have_open_br, bracketed = hostname_and_port.partition('[') + if have_open_br: + # No data is allowed before a bracket. + if before_bracket: + raise ValueError("Invalid IPv6 URL") + hostname, _, port = bracketed.partition(']') + # No data is allowed after the bracket but before the port delimiter. + if port and not port.startswith(":"): + raise ValueError("Invalid IPv6 URL") + else: + hostname, _, port = hostname_and_port.partition(':') + _check_bracketed_host(hostname) + def urlsplit(url, scheme='', allow_fragments=True): """Parse a URL into 5 components: :///?# @@ -231,6 +269,8 @@ def urlsplit(url, scheme='', allow_fragments=True): if (('[' in netloc and ']' not in netloc) or (']' in netloc and '[' not in netloc)): raise ValueError("Invalid IPv6 URL") + if '[' in netloc and ']' in netloc: + _check_bracketed_netloc(netloc) if allow_fragments and '#' in url: url, fragment = url.split('#', 1) if '?' in url: @@ -258,6 +298,8 @@ def urlsplit(url, scheme='', allow_fragments=True): if (('[' in netloc and ']' not in netloc) or (']' in netloc and '[' not in netloc)): raise ValueError("Invalid IPv6 URL") + if '[' in netloc and ']' in netloc: + _check_bracketed_netloc(netloc) if allow_fragments and '#' in url: url, fragment = url.split('#', 1) if '?' in url: From 5fab2f32c8f60a6e83fb90b9ef5f70e334d70f6b Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 16:22:30 -0500 Subject: [PATCH 06/24] Fix quadratic complexity in minidom and os.path.expandvars CVE-2025-12084: xml.dom.minidom._clear_id_cache called _in_document(), which walks the parent chain to the document root on every node mutation, making deeply nested appendChild()/insertBefore() O(n^2). Replace the walk with an O(1) `node.ownerDocument` check (over-clearing a detached node's cache is harmless; the cache is rebuilt lazily). CVE-2025-6075: posixpath.expandvars rebuilt the whole path string on each substitution and ntpath.expandvars concatenated to a result string char by char, both quadratic in the input size. posixpath now accumulates output segments; ntpath now expands via a single regex-substitution pass (ported from upstream), preserving the existing matching semantics. Adds bulk/regression tests for both expandvars implementations. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/ntpath.py | 116 ++++++++++++++----------------------- Lib/posixpath.py | 26 +++++---- Lib/test/test_ntpath.py | 11 ++++ Lib/test/test_posixpath.py | 15 +++++ Lib/xml/dom/minidom.py | 6 +- 5 files changed, 90 insertions(+), 84 deletions(-) diff --git a/Lib/ntpath.py b/Lib/ntpath.py index 0b85b0b9be46fc0..5280c6bbc1877eb 100644 --- a/Lib/ntpath.py +++ b/Lib/ntpath.py @@ -324,88 +324,60 @@ def expanduser(path): # XXX With COMMAND.COM you can use any characters in a variable name, # XXX except '^|<>='. +# Matches a single-quoted segment, %%, %var%, $$, $var or ${var}. Used by +# expandvars(); a single regex substitution pass keeps expansion linear in +# the input size rather than quadratic (CVE-2025-6075). +_varpattern = r"'[^']*'?|%(%|[^%]*%?)|\$(\$|[-\w]+|\{[^}]*\}?)" +_varsub = None + def expandvars(path): """Expand shell variables of the forms $var, ${var} and %var%. Unknown variables are left unchanged.""" + global _varsub if '$' not in path and '%' not in path: return path - import string - varchars = string.ascii_letters + string.digits + '_-' + if not _varsub: + import re + # No re.UNICODE, so \w stays ASCII-only as before. + _varsub = re.compile(_varpattern).sub if isinstance(path, _unicode): encoding = sys.getfilesystemencoding() - def getenv(var): - return os.environ[var.encode(encoding)].decode(encoding) else: - def getenv(var): - return os.environ[var] - res = '' - index = 0 - pathlen = len(path) - while index < pathlen: - c = path[index] - if c == '\'': # no expansion within single quotes - path = path[index + 1:] - pathlen = len(path) - try: - index = path.index('\'') - res = res + '\'' + path[:index + 1] - except ValueError: - res = res + c + path - index = pathlen - 1 - elif c == '%': # variable or '%' - if path[index + 1:index + 2] == '%': - res = res + c - index = index + 1 - else: - path = path[index+1:] - pathlen = len(path) - try: - index = path.index('%') - except ValueError: - res = res + '%' + path - index = pathlen - 1 - else: - var = path[:index] - try: - res = res + getenv(var) - except KeyError: - res = res + '%' + var + '%' - elif c == '$': # variable or '$$' - if path[index + 1:index + 2] == '$': - res = res + c - index = index + 1 - elif path[index + 1:index + 2] == '{': - path = path[index+2:] - pathlen = len(path) - try: - index = path.index('}') - var = path[:index] - try: - res = res + getenv(var) - except KeyError: - res = res + '${' + var + '}' - except ValueError: - res = res + '${' + path - index = pathlen - 1 - else: - var = '' - index = index + 1 - c = path[index:index + 1] - while c != '' and c in varchars: - var = var + c - index = index + 1 - c = path[index:index + 1] - try: - res = res + getenv(var) - except KeyError: - res = res + '$' + var - if c != '': - index = index - 1 + encoding = None + + def getenv(var): + if encoding: + return os.environ[var.encode(encoding)].decode(encoding) + return os.environ[var] + + def repl(m): + lastindex = m.lastindex + if lastindex is None: + # Single-quoted segment: no expansion within single quotes. + return m.group(0) + name = m.group(lastindex) + if lastindex == 1: + # %var% (or a bare '%%' -> '%') + if name == '%': + return name + if not name.endswith('%'): + return m.group(0) + name = name[:-1] else: - res = res + c - index = index + 1 - return res + # $var, ${var} (or a bare '$$' -> '$') + if name == '$': + return name + if name.startswith('{'): + if not name.endswith('}'): + return m.group(0) + name = name[1:-1] + try: + return getenv(var=name) + except KeyError: + return m.group(0) + + return _varsub(repl, path) # Normalize a path, e.g. A//B, A/./B and A/foo/../B all become A\B. diff --git a/Lib/posixpath.py b/Lib/posixpath.py index bbc2369ce7beb32..614e8ab0de58702 100644 --- a/Lib/posixpath.py +++ b/Lib/posixpath.py @@ -305,28 +305,32 @@ def expandvars(path): _varprog = re.compile(r'\$(\w+|\{[^}]*\})') varprog = _varprog encoding = None + # Accumulate output segments instead of rebuilding the whole string on + # every substitution, which would be quadratic in the input size + # (CVE-2025-6075). + res = [] i = 0 while True: m = varprog.search(path, i) if not m: + res.append(path[i:]) break - i, j = m.span(0) + j, k = m.span(0) + res.append(path[i:j]) name = m.group(1) if name.startswith('{') and name.endswith('}'): name = name[1:-1] - if encoding: - name = name.encode(encoding) - if name in os.environ: - tail = path[j:] - value = os.environ[name] + lookup = name.encode(encoding) if encoding else name + if lookup in os.environ: + value = os.environ[lookup] if encoding: value = value.decode(encoding) - path = path[:i] + value - i = len(path) - path += tail + res.append(value) else: - i = j - return path + # Unknown variable: leave the original text unchanged. + res.append(m.group(0)) + i = k + return ''.join(res) # Normalize a path, e.g. A//B, A/./B and A/foo/../B all become A/B. diff --git a/Lib/test/test_ntpath.py b/Lib/test/test_ntpath.py index b9a4c906defc26e..11e9624de60057d 100644 --- a/Lib/test/test_ntpath.py +++ b/Lib/test/test_ntpath.py @@ -182,6 +182,17 @@ def test_normpath(self): tester("ntpath.normpath('\\\\.\\NUL')", r'\\.\NUL') tester("ntpath.normpath('\\\\?\\D:/XY\\Z')", r'\\?\D:/XY\Z') + def test_expandvars_many(self): + # CVE-2025-6075: many substitutions must expand correctly and in + # linear time (the result is built by a single regex pass). + with test_support.EnvironmentVarGuard() as env: + env.clear() + env["foo"] = "bar" + self.assertEqual(ntpath.expandvars("%foo%" * 1000), "bar" * 1000) + self.assertEqual(ntpath.expandvars("$foo " * 1000), "bar " * 1000) + self.assertEqual(ntpath.expandvars("a" * 100000 + "%foo%"), + "a" * 100000 + "bar") + def test_expandvars(self): with test_support.EnvironmentVarGuard() as env: env.clear() diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py index 18ea2e42eadeac3..65d0c3d749838bd 100644 --- a/Lib/test/test_posixpath.py +++ b/Lib/test/test_posixpath.py @@ -497,6 +497,21 @@ def test_relpath(self): os.getcwd = real_getcwd @unittest.skipUnless(test_support.FS_NONASCII, 'need test_support.FS_NONASCII') + def test_expandvars_many(self): + # CVE-2025-6075: many substitutions must expand correctly (and in + # linear time -- the result is built once, not rebuilt per match). + with test_support.EnvironmentVarGuard() as env: + env.clear() + env['FOO'] = 'bar' + self.assertEqual(posixpath.expandvars('$FOO' * 1000), 'bar' * 1000) + self.assertEqual(posixpath.expandvars('$NOPE' * 1000), + '$NOPE' * 1000) + self.assertEqual(posixpath.expandvars('${FOO}x' * 100), + 'barx' * 100) + # A long literal prefix followed by a '$' must not be quadratic. + big = 'a' * 100000 + '$FOO' + self.assertEqual(posixpath.expandvars(big), 'a' * 100000 + 'bar') + def test_expandvars_nonascii_word(self): encoding = sys.getfilesystemencoding() uwnonascii = test_support.FS_NONASCII diff --git a/Lib/xml/dom/minidom.py b/Lib/xml/dom/minidom.py index 05649d620fac535..1eec599e9da564d 100644 --- a/Lib/xml/dom/minidom.py +++ b/Lib/xml/dom/minidom.py @@ -1467,7 +1467,11 @@ def _clear_id_cache(node): if node.nodeType == Node.DOCUMENT_NODE: node._id_cache.clear() node._id_search_stack = None - elif _in_document(node): + elif node.ownerDocument: + # Avoid the O(depth) _in_document() walk on every mutation; clearing + # the cache when the node has an owning document is sufficient and + # removes the quadratic cost of building deeply nested trees + # (CVE-2025-12084). node.ownerDocument._id_cache.clear() node.ownerDocument._id_search_stack= None From 8ee21950a645b2dd3229af2e4d777f95218bd24d Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 16:39:14 -0500 Subject: [PATCH 07/24] Fix quadratic complexity in HTMLParser at EOF (CVE-2025-6069) When close() flushed input ending in an unterminated construct, goahead() advanced only to the next '<' and re-parsed, so an input with many incomplete constructs (e.g. repeated "', [('comment', 'foo')]), + ] + for html, expected in data: + self._run_check(html, expected) + + def test_eof_in_declarations(self): + # CVE-2025-6069: unterminated declarations at EOF are closed. + data = [ + (' Date: Wed, 27 May 2026 16:42:10 -0500 Subject: [PATCH 08/24] Add strict validation option to base64.b64decode base64.b64decode silently discarded non-alphabet characters and, with an alternative alphabet, still accepted the standard '+'/'/' characters (CVE-2025-12781); it also ignored any data after the padding (CVE-2026-3446). Add a validate=False parameter (mirroring Python 3). When validate=True the input is checked against the *requested* alphabet -- so '+'/'/' are rejected when altchars is given, and embedded or post-padding junk is rejected rather than silently dropped. This goes beyond upstream, which only deprecates the lenient behaviour. The default (validate=False) is unchanged. Adds a regression test. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/base64.py | 27 +++++++++++++++++++++++---- Lib/test/test_base64.py | 26 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Lib/base64.py b/Lib/base64.py index 38bc61ee984c13b..ca0cd9c603c3187 100755 --- a/Lib/base64.py +++ b/Lib/base64.py @@ -57,7 +57,7 @@ def b64encode(s, altchars=None): return encoded -def b64decode(s, altchars=None): +def b64decode(s, altchars=None, validate=False): """Decode a Base64 encoded string. s is the string to decode. Optional altchars must be a string of at least @@ -65,10 +65,29 @@ def b64decode(s, altchars=None): alternative alphabet used instead of the '+' and '/' characters. The decoded string is returned. A TypeError is raised if s is - incorrectly padded. Characters that are neither in the normal base-64 - alphabet nor the alternative alphabet are discarded prior to the padding - check. + incorrectly padded. + + 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 binascii.Error. + + Unlike upstream (which only deprecates the lenient behaviour), validation + here checks the input against the *requested* alphabet, so the standard + '+'/'/' characters are rejected when an alternative alphabet is given + (CVE-2025-12781), and any data after the padding is rejected rather than + silently ignored (CVE-2026-3446). """ + if validate: + if altchars is not None: + extra = altchars[:2] + else: + extra = b'+/' + valid = frozenset(string.ascii_letters + string.digits + extra) + stripped = s.rstrip(b'=') + npad = len(s) - len(stripped) + if npad > 2 or not all(c in valid for c in stripped): + raise binascii.Error('Non-base64 digit found') if altchars is not None: s = s.translate(string.maketrans(altchars[:2], '+/')) try: diff --git a/Lib/test/test_base64.py b/Lib/test/test_base64.py index 6e67dc0ac189cb4..48fd1529deea78a 100644 --- a/Lib/test/test_base64.py +++ b/Lib/test/test_base64.py @@ -1,6 +1,7 @@ import unittest from test import test_support import base64 +import binascii @@ -100,6 +101,31 @@ def test_b64encode(self): # Non-bytes eq(base64.urlsafe_b64encode(bytearray('\xd3V\xbeo\xf7\x1d')), '01a-b_cd') + def test_b64decode_strict_validate(self): + # validate=True rejects non-alphabet characters instead of silently + # discarding them. + eq = self.assertEqual + eq(base64.b64decode("d3d3LnB5dGhvbi5vcmc=", validate=True), + "www.python.org") + # Embedded non-alphabet characters are rejected. + self.assertRaises(binascii.Error, base64.b64decode, + "d3d3\nLnB5dGhvbi5vcmc=", validate=True) + # Data after the padding is rejected (CVE-2026-3446). + self.assertRaises(binascii.Error, base64.b64decode, + "AA==extra", validate=True) + self.assertRaises(binascii.Error, base64.b64decode, + "A=AA", validate=True) + # With an alternative alphabet, the standard '+'/'/' are rejected + # rather than silently accepted (CVE-2025-12781). + self.assertRaises(binascii.Error, base64.b64decode, + "ab+/cd==", altchars="-_", validate=True) + # Valid data in the alternative alphabet still round-trips. + encoded = base64.b64encode("\xfb\xef\xff", altchars="-_") + eq(base64.b64decode(encoded, altchars="-_", validate=True), + "\xfb\xef\xff") + # The default (validate=False) keeps the lenient behaviour. + eq(base64.b64decode("d3d3\nLnB5dGhvbi5vcmc="), "www.python.org") + def test_b64decode(self): eq = self.assertEqual eq(base64.b64decode("d3d3LnB5dGhvbi5vcmc="), "www.python.org") From 84203331710f7705ab9dc6c9f9cf8f11e6f6aa29 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 18:53:05 -0500 Subject: [PATCH 09/24] Don't normalize AREGTYPE follow-up headers to DIRTYPE (CVE-2025-13462) tarfile normalized an AREGTYPE ('\x00') header whose name ends in a slash to DIRTYPE. This was also applied to follow-up headers (a GNU long name/link or a pax header), letting a crafted archive be interpreted differently from other tools. Split the header parsing into _frombuf(dircheck=...) / _fromtarfile and perform the AREGTYPE->DIRTYPE normalization only for primary headers; the follow-up reads in _proc_gnulong and _proc_pax pass dircheck=False. The public frombuf()/fromtarfile() signatures are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- Lib/tarfile.py | 23 +++++++++++++++++++---- Lib/test/test_tarfile.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Lib/tarfile.py b/Lib/tarfile.py index f0a4c955de522ca..69895ad9386425e 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -1217,6 +1217,17 @@ def _create_pax_generic_header(cls, pax_headers, type=XHDTYPE): def frombuf(cls, buf): """Construct a TarInfo object from a 512 byte string buffer. """ + return cls._frombuf(buf, dircheck=True) + + @classmethod + def _frombuf(cls, buf, dircheck=True): + """Construct a TarInfo object from a 512 byte string buffer. + + If dircheck is False an AREGTYPE header whose name ends in a slash is + NOT normalized to DIRTYPE. dircheck must be False when this is called + on a follow-up header such as a GNU long name/link or a pax header, + otherwise such a header could be misinterpreted (CVE-2025-13462). + """ if len(buf) == 0: raise EmptyHeaderError("empty header") if len(buf) != BLOCKSIZE: @@ -1247,7 +1258,7 @@ def frombuf(cls, buf): # Old V7 tar format represents a directory as a regular # file with a trailing slash. - if obj.type == AREGTYPE and obj.name.endswith("/"): + if dircheck and obj.type == AREGTYPE and obj.name.endswith("/"): obj.type = DIRTYPE # Remove redundant slashes from directories. @@ -1264,8 +1275,12 @@ def fromtarfile(cls, tarfile): """Return the next TarInfo object from TarFile object tarfile. """ + return cls._fromtarfile(tarfile, dircheck=True) + + @classmethod + def _fromtarfile(cls, tarfile, dircheck=True): buf = tarfile.fileobj.read(BLOCKSIZE) - obj = cls.frombuf(buf) + obj = cls._frombuf(buf, dircheck=dircheck) obj.offset = tarfile.fileobj.tell() - BLOCKSIZE return obj._proc_member(tarfile) @@ -1318,7 +1333,7 @@ def _proc_gnulong(self, tarfile): # Fetch the next header and process it. try: - next = self.fromtarfile(tarfile) + next = self._fromtarfile(tarfile, dircheck=False) except HeaderError: raise SubsequentHeaderError("missing or bad subsequent header") @@ -1428,7 +1443,7 @@ def _proc_pax(self, tarfile): # Fetch the next header. try: - next = self.fromtarfile(tarfile) + next = self._fromtarfile(tarfile, dircheck=False) except HeaderError: raise SubsequentHeaderError("missing or bad subsequent header") diff --git a/Lib/test/test_tarfile.py b/Lib/test/test_tarfile.py index 403cc9aec953d16..fe66218eaa826fd 100644 --- a/Lib/test/test_tarfile.py +++ b/Lib/test/test_tarfile.py @@ -2038,6 +2038,18 @@ def test_block_negative_count(self): for bad in (-1, -512, -(2 ** 71)): self.assertRaises(tarfile.InvalidHeaderError, tarinfo._block, bad) + def test_aregtype_dircheck(self): + # CVE-2025-13462: an AREGTYPE header whose name ends in a slash is + # normalized to DIRTYPE for a primary header, but NOT for a follow-up + # header (e.g. a GNU long name/link or pax header). + t = tarfile.TarInfo("foo/") + t.type = tarfile.AREGTYPE + buf = t.tobuf() + self.assertEqual(tarfile.TarInfo._frombuf(buf, dircheck=True).type, + tarfile.DIRTYPE) + self.assertEqual(tarfile.TarInfo._frombuf(buf, dircheck=False).type, + tarfile.AREGTYPE) + def test_read_number_fields(self): # Issue 13158: Test if GNU tar specific base-256 number fields # are decoded correctly. From f258e71d0ad4f611c4f734326667b37fe382253c Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Wed, 27 May 2026 23:19:03 -0500 Subject: [PATCH 10/24] 2.7.18.14 Release Bump PY_VERSION to 2.7.18.14 and add release notes for the security fixes addressed in this release: CVE-2025-8194, CVE-2026-4519, CVE-2026-4786, CVE-2026-0865, CVE-2026-0672, CVE-2025-15366, CVE-2025-15367, CVE-2026-1502, CVE-2024-6923, CVE-2024-0450, CVE-2025-8291, CVE-2025-0938, CVE-2024-11168, CVE-2025-6069, CVE-2025-6075, CVE-2025-12084, CVE-2025-13462, CVE-2025-12781 and CVE-2026-3446. Documents "not affected" determinations for CVE-2025-13836, CVE-2025-15282, CVE-2025-11468, CVE-2025-1795, CVE-2026-3644 and CVE-2024-5642. Co-Authored-By: Claude Opus 4.7 (1M context) --- Include/patchlevel.h | 2 +- Misc/NEWS.d/2.7.18.14.rst | 261 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/2.7.18.14.rst diff --git a/Include/patchlevel.h b/Include/patchlevel.h index 4b830a2abea6a3b..bd7ff936aa8a452 100644 --- a/Include/patchlevel.h +++ b/Include/patchlevel.h @@ -27,7 +27,7 @@ #define PY_RELEASE_SERIAL 0 /* Version as a string */ -#define PY_VERSION "2.7.18.13" +#define PY_VERSION "2.7.18.14" /*--end constants--*/ /* Subversion Revision number of this file (not of the repository). Empty diff --git a/Misc/NEWS.d/2.7.18.14.rst b/Misc/NEWS.d/2.7.18.14.rst new file mode 100644 index 000000000000000..e0a887ea1ecddb8 --- /dev/null +++ b/Misc/NEWS.d/2.7.18.14.rst @@ -0,0 +1,261 @@ +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-8194 in tarfile + +A tar archive carrying a negative member offset (reachable through a PAX +extended header with a negative ``size`` value) caused +:meth:`TarInfo._block` to return a negative block count, which moved the +archive offset backwards and could trigger an infinite loop (on seekable +files) or a ``StreamError`` (on streams). ``_block`` now rejects negative +counts with :exc:`tarfile.InvalidHeaderError`. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2026-4519 and CVE-2026-4786 in webbrowser + +:func:`webbrowser.open` passed an attacker-controlled URL to the browser +command line without validation, allowing a URL beginning with ``-`` to be +interpreted as a command-line option (argument injection). The new +``BaseBrowser._check_url`` rejects such URLs, and ``UnixBrowser.open`` now +validates the URL after the ``%action`` substitution so that ``%action`` +cannot be used to smuggle a leading dash past the check. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2026-0865 in wsgiref + +``wsgiref.headers.Headers`` now rejects control characters in header names +and values (in ``__init__``, ``__setitem__`` and ``add_header``), preventing +HTTP response splitting / header injection. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2026-0672 in Cookie + +``Cookie.Morsel`` now rejects control characters in cookie keys, values, +coded values and attribute values, preventing ``Set-Cookie`` header +injection. Validation is performed where values are stored, so the +``Morsel.update`` / ``|=`` / unpickling bypasses tracked as CVE-2026-3644 +(which do not exist in this module) cannot reintroduce the issue. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-15366 in imaplib + +``imaplib.IMAP4._command`` now rejects control characters in command +arguments, preventing IMAP command injection via embedded CR/LF. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-15367 in poplib + +``poplib.POP3._putline`` (and the SSL override) now rejects control +characters in the command line, preventing POP3 command injection via +embedded CR/LF. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2026-1502 in httplib + +``httplib.HTTPConnection.set_tunnel`` now validates the CONNECT tunnel host +for control characters, preventing CR/LF injection into the CONNECT request. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2024-6923 in email + +``email.generator.Generator`` now rejects a header whose serialized form +contains a newline that is not part of valid folding, raising the new +``email.errors.HeaderWriteError`` instead of emitting an injectable header. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2024-0450 in zipfile + +``zipfile`` now records each member's end offset and rejects archives with +overlapping entries (a "quoted overlap" zip bomb) when an entry's compressed +data would extend past the start of the next entry. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-8291 in zipfile + +``zipfile`` now validates the ZIP64 end-of-central-directory locator's +relative offset instead of assuming the ZIP64 record is adjacent, rejecting +archives whose locator points past the expected position. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-0938 and CVE-2024-11168 in urlparse + +``urlparse.urlsplit`` now allows square brackets in a URL host only when +they enclose a valid IPv6/IPvFuture address, rejecting hosts such as +``ex[ample].com`` or ``[not-an-ipv6]`` that previously parsed differently +from RFC 3986 tools. This covers both the original bracketed-host +validation (CVE-2024-11168) and the later tightening (CVE-2025-0938). + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-6069 in HTMLParser + +``HTMLParser`` no longer has quadratic-time behaviour when input ends with +unterminated constructs; at EOF such constructs are now closed per HTML5 +rather than being repeatedly rescanned. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-6075 in os.path + +``posixpath.expandvars`` and ``ntpath.expandvars`` now build their result in +a single linear pass instead of rebuilding the whole string per +substitution, removing quadratic-time behaviour on large inputs. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-12084 in xml.dom.minidom + +``xml.dom.minidom`` no longer walks the parent chain on every node mutation +to clear the id cache, removing the quadratic cost of building deeply nested +documents. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-13462 in tarfile + +``tarfile`` no longer normalizes an ``AREGTYPE`` header with a trailing-slash +name to ``DIRTYPE`` when the header is a follow-up to a GNU long name/link or +a pax header, so a crafted archive can no longer be made to interpret such a +member differently from other tools. + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Address CVE-2025-12781 and CVE-2026-3446 in base64 + +``base64.b64decode`` gains a ``validate`` keyword argument (default +``False``). When true, the input is validated against the requested +alphabet, so the standard ``'+'``/``'/'`` characters are rejected when an +alternative alphabet is given (CVE-2025-12781) and data after the padding is +rejected rather than silently ignored (CVE-2026-3446). + +.. bpo: 0 +.. date: 2026-05-27 +.. nonce: as$i9+ +.. release date: 2026-05-27 +.. original section: Library +.. section: Security + +Not affected: CVE-2025-13836, CVE-2025-15282, CVE-2025-11468, CVE-2025-1795, +CVE-2026-3644, CVE-2024-5642 and CVE-2026-6100 + +Several CVEs do not apply to Python 2.7 in this release. The vulnerable +code paths are absent or already mitigated by the existing implementation: + +- CVE-2025-13836 (``http.client`` Content-Length-based memory exhaustion): + Python 2.7's ``httplib._safe_read`` reads in bounded 1 MB (``MAXAMOUNT``) + chunks and never pre-allocates based on ``Content-Length``. + +- CVE-2025-15282 (``urllib.request.DataHandler``, the ``data:`` URL handler) + was added in Python 3 and does not exist in 2.7. + +- CVE-2025-11468 and CVE-2025-1795 affect the modern + ``email._header_value_parser`` machinery -- its comment folding and its + address-list folding (which mis-encoded a separating comma). Both were + added in Python 3. + +- CVE-2026-3644 targets ``Morsel.update`` / ``|=`` / ``__setstate__``, + entry points that do not exist on 2.7's ``Cookie.Morsel``. + +- CVE-2024-5642 exploits NPN, the feature behind + ``ssl.set_npn_protocols``; NPN is removed in OpenSSL 1.1.1w and later, + against which this Python builds. + +- CVE-2026-6100 is a use-after-free in + ``bz2.BZ2Decompressor`` / ``lzma.LZMADecompressor`` / + ``zlib._ZlibDecompressor`` when a ``MemoryError`` leaves ``next_in`` + dangling and the same decompressor is reused. ``lzma`` and the + ``_ZlibDecompressor`` object (Python 3.12+) do not exist in 2.7; 2.7's + legacy ``bz2.BZ2Decompressor`` and zlib ``compobject`` re-set + ``next_in`` fresh from the argument buffer on every call and persist + leftover input as owned Python string objects + (``unused_data`` / ``unconsumed_tail``), so no dangling raw pointer is + carried across calls. From 72c20bedd93c994445630536d2a43bbc7aaf6a1e Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 00:54:11 -0500 Subject: [PATCH 11/24] Fix Windows test-suite hangs on invalid file descriptors On a Windows release build, CRT calls made on a deliberately-invalid file descriptor invoke the invalid-parameter handler and fast-fail (surfacing as a "stopped working" dialog) instead of returning EBADF. Several tests (test_fileio, test_os, test_signal) intentionally exercise bad fds and so hung the suite. The existing _Py_BEGIN_SUPPRESS_IPH backport (e361063) guarded the primary fd operations but missed three secondary fstat/lseek calls: - _io/fileio.c new_buffersize(): fstat()/lseek() reached by readall() before the already-guarded read() (test_fileio testErrnoOnClosedReadall). - posixmodule.c posix_fdopen(): the directory-check fstat() reached by os.fdopen(bad_fd) (test_os TestInvalidFD.test_fdopen). - signalmodule.c signal_set_wakeup_fd(): the validation fstat() reached by signal.set_wakeup_fd(bad_fd) (test_signal test_invalid_fd). Wrap each in _Py_BEGIN_SUPPRESS_IPH/_Py_END_SUPPRESS_IPH so the call returns an error and the expected exception is raised. Also: - regrtest: suppress Windows error-reporting / CRT-assert dialogs at suite startup (SetErrorMode + debug-CRT report mode), mirroring Python 3's test driver, so a faulting test crashes cleanly instead of blocking on a modal dialog. Runs in -j slaves too. - test_ctypes: skip test_pass_pointers where c_long is narrower than a pointer (win64); it truncates the returned pointer and dereferences a bad address (an access violation, not an IPH case). Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/ctypes/test/test_pointers.py | 2 + Lib/test/regrtest.py | 69 ++++++++++++++++++++++++++++++++ Modules/_io/fileio.c | 4 ++ Modules/posixmodule.c | 8 +++- Modules/signalmodule.c | 14 +++++-- 5 files changed, 93 insertions(+), 4 deletions(-) diff --git a/Lib/ctypes/test/test_pointers.py b/Lib/ctypes/test/test_pointers.py index 4a8887c1dee60ef..7bc608d2b73baa3 100644 --- a/Lib/ctypes/test/test_pointers.py +++ b/Lib/ctypes/test/test_pointers.py @@ -19,6 +19,8 @@ class A(POINTER(c_ulong)): # Pointer can't set contents: has no _type_ self.assertRaises(TypeError, A, c_ulong(33)) + @unittest.skipUnless(sizeof(c_void_p) == sizeof(c_long), + "test assumes c_long is pointer-sized (fails on win64)") def test_pass_pointers(self): dll = CDLL(_ctypes_test.__file__) func = dll._testfunc_p_p diff --git a/Lib/test/regrtest.py b/Lib/test/regrtest.py index 5b0b3e4422c0dd6..79bd9179bbca35b 100755 --- a/Lib/test/regrtest.py +++ b/Lib/test/regrtest.py @@ -335,6 +335,70 @@ def unload_test_modules(save_modules): support.unload(module) +def _suppress_crash_dialogs(): + """Stop Windows from popping a modal dialog when a test deliberately + faults the C runtime, which otherwise blocks the whole run. + + A handful of tests intentionally drive the CRT into an error to check + error handling. For example test_ctypes' test_pass_pointers dereferences + a bad pointer (an access violation -- and on 64-bit the pointer is first + truncated because c_long is narrower than a pointer), and test_fileio's + testErrnoOnClosed* call read()/etc. on a descriptor whose OS handle was + closed underneath the object (a CRT invalid-parameter assert). On Windows + each of these raises a modal dialog -- "python.exe has stopped working", + or, in a debug build, a CRT assert box -- that waits for a button click, + so the suite appears to hang. + + Python 3's test driver disables these dialogs at startup; Python 2.7's + regrtest never did. Mirror that here. Best-effort throughout: setup + failures must never stop the tests from running. + """ + if sys.platform != 'win32': + return + try: + import ctypes + except ImportError: + return + + # 1. Windows Error Reporting: suppress the general-protection-fault, + # critical-error and open-file message boxes. This covers the access + # violation; the process still dies, but it dies instead of hanging. + SEM_FAILCRITICALERRORS = 0x0001 + SEM_NOGPFAULTERRORBOX = 0x0002 + SEM_NOOPENFILEERRORBOX = 0x8000 + try: + kernel32 = ctypes.windll.kernel32 + # SetErrorMode returns the previous flags; OR ours in rather than + # clobbering anything the host process already configured. + old = kernel32.SetErrorMode(SEM_NOGPFAULTERRORBOX) + kernel32.SetErrorMode(old | SEM_FAILCRITICALERRORS | + SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX) + except (OSError, AttributeError): + pass + + # 2. Debug builds only: route CRT diagnostics (the invalid-fd assert) to + # stderr instead of a modal assert box. With the report mode set to + # file, the failing CRT call reports and then returns its error value + # (e.g. -1 / EBADF) rather than aborting, so the test sees the IOError + # it expects. _CrtSetReportMode lives only in the debug CRT; in a + # release build it's absent (and there are no such asserts), so this + # silently no-ops. + _CRT_WARN, _CRT_ERROR, _CRT_ASSERT = 0, 1, 2 + _CRTDBG_MODE_FILE = 0x0001 + _CRTDBG_FILE_STDERR = ctypes.c_void_p(-4) + for crt_name in ('ucrtbased', 'msvcrtd'): + try: + crt = ctypes.CDLL(crt_name) + set_mode = crt._CrtSetReportMode + set_file = crt._CrtSetReportFile + except (OSError, AttributeError): + continue + for report_type in (_CRT_WARN, _CRT_ERROR, _CRT_ASSERT): + set_mode(report_type, _CRTDBG_MODE_FILE) + set_file(report_type, _CRTDBG_FILE_STDERR) + break + + def main(tests=None, testdir=None, verbose=0, quiet=False, exclude=False, single=False, randomize=False, fromfile=None, findleaks=False, use_resources=None, trace=False, coverdir='coverage', @@ -365,6 +429,11 @@ def main(tests=None, testdir=None, verbose=0, quiet=False, """ regrtest_start_time = time.time() + # Keep a deliberately-crashing test (test_ctypes, test_fileio, ...) from + # blocking the run on a modal Windows dialog. Runs in -j slaves too, + # since they re-enter main(). + _suppress_crash_dialogs() + support.record_original_stdout(sys.stdout) try: opts, args = getopt.getopt(sys.argv[1:], 'hvqxsSrf:lu:t:TD:NLR:FwWM:j:PGm:', diff --git a/Modules/_io/fileio.c b/Modules/_io/fileio.c index 4c3eb7c2c262aec..ed2f8ae4d6e5e09 100644 --- a/Modules/_io/fileio.c +++ b/Modules/_io/fileio.c @@ -541,14 +541,18 @@ new_buffersize(fileio *self, size_t currentsize) int res; Py_BEGIN_ALLOW_THREADS + _Py_BEGIN_SUPPRESS_IPH res = fstat(self->fd, &st); + _Py_END_SUPPRESS_IPH Py_END_ALLOW_THREADS if (res == 0) { end = st.st_size; Py_BEGIN_ALLOW_THREADS + _Py_BEGIN_SUPPRESS_IPH pos = lseek(self->fd, 0L, SEEK_CUR); + _Py_END_SUPPRESS_IPH Py_END_ALLOW_THREADS /* Files claiming a size smaller than SMALLCHUNK may diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index 9ff222a39bd74ad..e5d108228f62ef1 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -6823,7 +6823,13 @@ posix_fdopen(PyObject *self, PyObject *args) struct stat buf; const char *msg; PyObject *exc; - if (fstat(fd, &buf) == 0 && S_ISDIR(buf.st_mode)) { + int res; + /* fstat() on a bad fd triggers the CRT invalid-parameter handler on + Windows; suppress it so fdopen() reports EBADF instead of aborting. */ + _Py_BEGIN_SUPPRESS_IPH + res = fstat(fd, &buf); + _Py_END_SUPPRESS_IPH + if (res == 0 && S_ISDIR(buf.st_mode)) { PyMem_FREE(mode); msg = strerror(EISDIR); exc = PyObject_CallFunction(PyExc_IOError, "(iss)", diff --git a/Modules/signalmodule.c b/Modules/signalmodule.c index 88200acaff66f47..84aa19893a5632b 100644 --- a/Modules/signalmodule.c +++ b/Modules/signalmodule.c @@ -418,9 +418,17 @@ signal_set_wakeup_fd(PyObject *self, PyObject *args) } #endif - if (fd != -1 && fstat(fd, &buf) != 0) { - PyErr_SetString(PyExc_ValueError, "invalid fd"); - return NULL; + if (fd != -1) { + int res; + /* fstat() on a bad fd triggers the CRT invalid-parameter handler on + Windows; suppress it so we return ValueError instead of aborting. */ + _Py_BEGIN_SUPPRESS_IPH + res = fstat(fd, &buf); + _Py_END_SUPPRESS_IPH + if (res != 0) { + PyErr_SetString(PyExc_ValueError, "invalid fd"); + return NULL; + } } old_fd = wakeup_fd; From 392e3899729bfeb0e9fd8704f27d040f1fc2004a Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 13:41:47 -0500 Subject: [PATCH 12/24] asyncore: recognize WSAECONNRESET/WSAESHUTDOWN as disconnects on Windows On a modern (UCRT / VS2015+) Windows build, defines the POSIX errno values (ECONNRESET=108, ...), so Python's errno module exposes those rather than the Winsock values. But the socket layer still reports Winsock error codes (WSAECONNRESET=10054), so asyncore's _DISCONNECTED set -- built from the C-runtime errno constants -- no longer matches a real connection reset. recv() then re-raises instead of closing, the server threads in test_ftplib die mid-loop, and they leak their handlers into the global asyncore.socket_map ("socket_map was modified" -> test failure). The existing Windows WSA handling here already mapped WSAENOTCONN, WSAECONNABORTED and WSAEBADF, but omitted WSAECONNRESET (the actual error seen) and WSAESHUTDOWN. Add both to the import, the POSIX fallback aliases, and _DISCONNECTED. Harmless on older toolchains and on POSIX, where the WSA names alias to the same POSIX values. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/asyncore.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Lib/asyncore.py b/Lib/asyncore.py index 9909a6dd63d4a0b..3c65ac6ad6d8f16 100644 --- a/Lib/asyncore.py +++ b/Lib/asyncore.py @@ -61,7 +61,7 @@ # On Windows, handle the Windows error numbers from errno import \ WSAEWOULDBLOCK, WSAENOTCONN, WSAEINPROGRESS, WSAEALREADY, WSAEISCONN, \ - WSAECONNABORTED, WSAENOTCONN, WSAEBADF + WSAECONNABORTED, WSAECONNRESET, WSAESHUTDOWN, WSAEBADF else: # On Posix the error codes aren't duplicated, with different numbers WSAEWOULDBLOCK = EWOULDBLOCK @@ -70,12 +70,15 @@ WSAEALREADY = EALREADY WSAEISCONN = EISCONN WSAECONNABORTED = ECONNABORTED + WSAECONNRESET = ECONNRESET + WSAESHUTDOWN = ESHUTDOWN WSAENOTCONN = ENOTCONN WSAEBADF = EBADF _DISCONNECTED = frozenset((ECONNRESET, ENOTCONN, ESHUTDOWN, ECONNABORTED, EPIPE, - EBADF, WSAENOTCONN, WSAECONNABORTED, WSAEBADF)) + EBADF, WSAECONNRESET, WSAENOTCONN, WSAESHUTDOWN, + WSAECONNABORTED, WSAEBADF)) try: socket_map From 1a63015df7078ae7e0547f07d54ac575daa3ed6a Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 14:39:20 -0500 Subject: [PATCH 13/24] test: gate test_bigrepeat on sys.maxsize, not sys.maxint (win64) On 64-bit Windows (LLP64) sys.maxint is still 2**31-1 because C long is 32-bit, while the address space and Py_ssize_t are 64-bit. seq_tests' test_bigrepeat gated its 32-bit overflow check on sys.maxint, so on win64 it ran the check and tried to build a ~34 GB sequence (2**32 elements) that never raises MemoryError -- a multi-minute memory thrash ending in "MemoryError not raised". This hit test_list, test_tuple and test_userlist (all share seq_tests). Use sys.maxsize, matching upstream 2.7. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/seq_tests.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Lib/test/seq_tests.py b/Lib/test/seq_tests.py index f019e8d573a1c96..e5d8a8ab142db2c 100644 --- a/Lib/test/seq_tests.py +++ b/Lib/test/seq_tests.py @@ -320,7 +320,11 @@ def test_repeat(self): def test_bigrepeat(self): import sys - if sys.maxint <= 2147483647: + # Use maxsize (Py_ssize_t), not maxint: on 64-bit Windows (LLP64) + # sys.maxint is still 2**31-1 while the address space is 64-bit, so + # gating on maxint wrongly runs the 32-bit overflow check and tries to + # build a ~34 GB sequence that never raises MemoryError. + if sys.maxsize <= 2147483647: x = self.type2test([0]) x *= 2**16 self.assertRaises(MemoryError, x.__mul__, 2**16) From 37f597259bb74f9c7e8301dd104a3edc908cdcc1 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 14:45:37 -0500 Subject: [PATCH 14/24] test_socket: restore missing _have_socket_can/_have_socket_alg helpers This fork's test_socket.py calls _have_socket_can() (module level) and references HAVE_SOCKET_ALG (in a class decorator) but the defining helpers were lost in backport, so the module crashed at import on Windows with "NameError: name '_have_socket_can' is not defined" (and would next hit HAVE_SOCKET_ALG). Restore both helpers from upstream 2.7 and define HAVE_SOCKET_ALG. On Windows AF_CAN/AF_ALG don't exist, so both return False and the corresponding test classes skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_socket.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Lib/test/test_socket.py b/Lib/test/test_socket.py index 2c1b48c698ae999..2e91a0a62f21ce2 100644 --- a/Lib/test/test_socket.py +++ b/Lib/test/test_socket.py @@ -55,7 +55,29 @@ def _is_fd_in_blocking_mode(sock): fcntl.fcntl(sock, fcntl.F_GETFL, os.O_NONBLOCK) & os.O_NONBLOCK) +def _have_socket_can(): + """Check whether CAN sockets are supported on this host.""" + try: + s = socket.socket(socket.AF_CAN, socket.SOCK_RAW, socket.CAN_RAW) + except (AttributeError, socket.error, OSError): + return False + else: + s.close() + return True + +def _have_socket_alg(): + """Check whether AF_ALG sockets are supported on this host.""" + try: + s = socket.socket(socket.AF_ALG, socket.SOCK_SEQPACKET, 0) + except (AttributeError, socket.error, OSError): + return False + else: + s.close() + return True + + HAVE_SOCKET_CAN = _have_socket_can() +HAVE_SOCKET_ALG = _have_socket_alg() class SocketTCPTest(unittest.TestCase): From 13d66c6da3103e933ead9d1fc6cafa9edc1c47ec Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 14:56:45 -0500 Subject: [PATCH 15/24] ctypes: fix find_msvcrt on VS2015+ builds; skip win64 pointer-truncation test find_msvcrt() fabricated a non-existent CRT name (e.g. msvcr130.dll) on a modern toolchain because _get_build_version() used the old "int(s[:-2]) - 6" formula, which yields 13 for MSC v.19xx. CDLL() of that name fails with WindowsError 126, breaking test_loading, test_errno and test_callbacks (which load find_library("c")). Apply the upstream bpo-23606 fix: bump the major version past the skipped v13, and have find_msvcrt() return None for VS2015+ (the UCRT is not directly loadable). find_library("c") then returns None and those tests skip cleanly. Also skip test_prototypes.test_int_pointer_arg where c_long is narrower than a pointer (win64): like test_pass_pointers it sets restype=c_long and compares it against a full pointer address, which truncates on LLP64. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/ctypes/test/test_prototypes.py | 2 ++ Lib/ctypes/util.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Lib/ctypes/test/test_prototypes.py b/Lib/ctypes/test/test_prototypes.py index a10317b959183cf..4bc0473d5f0871a 100644 --- a/Lib/ctypes/test/test_prototypes.py +++ b/Lib/ctypes/test/test_prototypes.py @@ -67,6 +67,8 @@ def test_paramflags(self): self.assertEqual(func(input=None), None) + @unittest.skipUnless(sizeof(c_void_p) == sizeof(c_long), + "test assumes c_long is pointer-sized (fails on win64)") def test_int_pointer_arg(self): func = testdll._testfunc_p_p func.restype = c_long diff --git a/Lib/ctypes/util.py b/Lib/ctypes/util.py index ab10ec52ee8c355..34f0ec0b48dfbdc 100644 --- a/Lib/ctypes/util.py +++ b/Lib/ctypes/util.py @@ -19,6 +19,9 @@ def _get_build_version(): i = i + len(prefix) s, rest = sys.version[i:].split(" ", 1) majorVersion = int(s[:-2]) - 6 + if majorVersion >= 13: + # v13 was skipped and should be v14 + majorVersion += 1 minorVersion = int(s[2:3]) / 10.0 # I don't think paths are affected by minor version in version 6 if majorVersion == 6: @@ -36,8 +39,12 @@ def find_msvcrt(): return None if version <= 6: clibname = 'msvcrt' - else: + elif version <= 13: clibname = 'msvcr%d' % (version * 10) + else: + # CRT is no longer directly loadable. See issue23606 for the + # discussion about alternative approaches. + return None # If python was built with in debug mode import imp From 3b46f5d37bdbb22aec098d8ad7c5d73de3f705a7 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 15:28:06 -0500 Subject: [PATCH 16/24] ssl: accept WSAENOTCONN when probing whether a socket is connected SSLSocket.__init__ calls getpeername() to decide if the socket is already connected, expecting errno.ENOTCONN for an unconnected socket. On a modern (UCRT) Windows build errno.ENOTCONN is the POSIX value (107) while Winsock reports WSAENOTCONN (10057), so the check failed and wrap_socket() re-raised for the (very common) "wrap a not-yet-connected socket" path -- cascading to ~50 errors across test_ssl. Match both spellings via _NOT_CONNECTED_ERRORS, same root cause as the asyncore WSAECONNRESET fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/ssl.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Lib/ssl.py b/Lib/ssl.py index e295aa5939df839..99f5f1366ddd5d4 100644 --- a/Lib/ssl.py +++ b/Lib/ssl.py @@ -151,6 +151,14 @@ def _import_symbols(prefix): import errno import warnings +# On Windows the socket layer reports Winsock (WSAE*) error numbers, which on a +# modern (UCRT/VS2015+) build no longer share values with the C-runtime errno +# constants imported here. Recognise both spellings of "socket not connected". +_NOT_CONNECTED_ERRORS = frozenset( + _e for _e in (getattr(errno, 'ENOTCONN', None), + getattr(errno, 'WSAENOTCONN', None)) + if _e is not None) + if _ssl.HAS_TLS_UNIQUE: CHANNEL_BINDING_TYPES = ['tls-unique'] else: @@ -583,7 +591,7 @@ def __init__(self, sock=None, keyfile=None, certfile=None, try: self.getpeername() except socket_error as e: - if e.errno != errno.ENOTCONN: + if e.errno not in _NOT_CONNECTED_ERRORS: raise connected = False blocking = self.getblocking() @@ -597,7 +605,7 @@ def __init__(self, sock=None, keyfile=None, certfile=None, except (OSError, socket_error) as e: # EINVAL occurs for recv(1) on non-connected on unix sockets. - if e.errno not in (errno.ENOTCONN, errno.EINVAL): + if e.errno not in _NOT_CONNECTED_ERRORS and e.errno != errno.EINVAL: raise notconn_pre_handshake_data = b'' From 8c9b9a18aadf3beed792c491c9bb656f09d11618 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 20:59:55 -0500 Subject: [PATCH 17/24] test_socket: bind 'support' alias (partial 3.x backport) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_socket.py imports `from test import test_support` but the (partially forward-ported) body refers to the support module under BOTH names (test_support.* and support.*, ~24 each). The bare `support.*` references — including @support.requires_linux_version decorators evaluated at import — raised "NameError: name 'support' is not defined", crashing the module on import once the earlier _have_socket_can gap was fixed. Bind support = test_support (same module object) so both names resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_socket.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Lib/test/test_socket.py b/Lib/test/test_socket.py index 2e91a0a62f21ce2..715fc2370e0afc6 100644 --- a/Lib/test/test_socket.py +++ b/Lib/test/test_socket.py @@ -1,5 +1,8 @@ import unittest from test import test_support +# This module was partially forward-ported and refers to the support module +# under both names; bind 'support' to the same module so both resolve. +support = test_support import errno import itertools From 11825acbb238bef37c00be4b763992c317c38f75 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 20:59:55 -0500 Subject: [PATCH 18/24] test_ctypes: don't assert find_library("c") is non-None on UCRT test_loading.test_load_library asserted libc_name (find_library("c")) is not None, but on a VS2015+/UCRT build find_msvcrt() correctly returns None (the UCRT is not loadable as a single msvcrXXX.dll), so the assert failed. The test's real purpose is loading kernel32, which is independent of libc_name; drop the vestigial assertion (the libc-dependent cases already skip when libc_name is None). Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/ctypes/test/test_loading.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Lib/ctypes/test/test_loading.py b/Lib/ctypes/test/test_loading.py index e64fff7b0351561..e5b099c08d0ad98 100644 --- a/Lib/ctypes/test/test_loading.py +++ b/Lib/ctypes/test/test_loading.py @@ -54,7 +54,10 @@ def test_find(self): @unittest.skipUnless(os.name in ("nt", "ce"), 'test specific to Windows (NT/CE)') def test_load_library(self): - self.assertIsNotNone(libc_name) + # libc_name (find_library("c")) is None on VS2015+/UCRT builds, where + # the C runtime is no longer loadable as a single DLL. That is expected + # (see the skipped tests above); this test really exercises loading + # kernel32, which is independent of libc_name. if is_resource_enabled("printing"): print find_library("kernel32") print find_library("user32") From 3d4ca65be4003cf303d51350a847d4aca531f112 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Mon, 8 Jun 2026 22:10:23 -0500 Subject: [PATCH 19/24] test_ssl: accept WSAENOTCONN and skip TLS1.3/PHA tests on this build Two classes of Windows/2.7 test-side failures after the ssl.py WSAENOTCONN library fix: - test_getpeercert_enotconn / test_do_handshake_enotconn asserted the raised errno equals errno.ENOTCONN, but on a UCRT build the socket reports the Winsock value WSAENOTCONN (10057) while errno.ENOTCONN is the C-runtime value (126). Compare against a set that includes both (_ENOTCONN). - test_pha_no_pha_client/server, test_pha_not_tls13 and (ThreadedTests) test_bpo37428_pha_cert_none exercise TLS 1.3 post-handshake authentication, whose Python API (ssl.TLSVersion etc.) is not present in this 2.7 ssl backport (test_pha_not_tls13 raised AttributeError on ssl.TLSVersion). Skip them unless hasattr(ssl, 'TLSVersion'). Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_ssl.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index ff4adb46b0ea80b..105e3c62bb04af8 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -18,6 +18,13 @@ import gc import os import errno +# On Windows the socket layer reports Winsock (WSAE*) error numbers, which on a +# modern UCRT build no longer share values with the C-runtime errno constants; +# accept both spellings of "socket not connected". +_ENOTCONN = frozenset( + _e for _e in (getattr(errno, 'ENOTCONN', None), + getattr(errno, 'WSAENOTCONN', None)) + if _e is not None) import pprint import shutil import urllib2 @@ -2879,14 +2886,14 @@ def test_getpeercert_enotconn(self): with closing(context.wrap_socket(socket.socket())) as sock: with self.assertRaises(socket.error) as cm: sock.getpeercert() - self.assertEqual(cm.exception.errno, errno.ENOTCONN) + self.assertIn(cm.exception.errno, _ENOTCONN) def test_do_handshake_enotconn(self): context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) with closing(context.wrap_socket(socket.socket())) as sock: with self.assertRaises(socket.error) as cm: sock.do_handshake() - self.assertEqual(cm.exception.errno, errno.ENOTCONN) + self.assertIn(cm.exception.errno, _ENOTCONN) def test_no_shared_ciphers(self): server_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) @@ -3271,6 +3278,8 @@ def test_read_write_after_close_raises_valuerror(self): self.assertRaises(ValueError, s.read, 1024) self.assertRaises(ValueError, s.write, b'hello') + @unittest.skipUnless(hasattr(ssl, 'TLSVersion'), + "TLS 1.3 post-handshake auth API not available") def test_pha_no_pha_client(self): client_context, server_context, hostname = testing_context() server_context.post_handshake_auth = True @@ -3286,6 +3295,8 @@ def test_pha_no_pha_client(self): s.write(b'PHA') self.assertIn(b'extension not received', s.recv(1024)) + @unittest.skipUnless(hasattr(ssl, 'TLSVersion'), + "TLS 1.3 post-handshake auth API not available") def test_pha_no_pha_server(self): # server doesn't have PHA enabled, cert is requested in handshake client_context, server_context, hostname = testing_context() @@ -3305,6 +3316,8 @@ def test_pha_no_pha_server(self): s.write(b'HASCERT') self.assertEqual(s.recv(1024), b'TRUE\n') + @unittest.skipUnless(hasattr(ssl, 'TLSVersion'), + "TLS 1.3 post-handshake auth API not available") def test_pha_not_tls13(self): # TLS 1.2 client_context, server_context, hostname = testing_context() @@ -3322,6 +3335,8 @@ def test_pha_not_tls13(self): s.write(b'PHA') self.assertIn(b'WRONG_SSL_VERSION', s.recv(1024)) + @unittest.skipUnless(hasattr(ssl, 'TLSVersion'), + "TLS 1.3 post-handshake auth API not available") def test_bpo37428_pha_cert_none(self): # verify that post_handshake_auth does not implicitly enable cert # validation. From 0c119342dc66b20cf27c496b50ffb1a61cb4a81c Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Tue, 9 Jun 2026 11:01:26 -0500 Subject: [PATCH 20/24] test.support: add requires_linux_version (used by forward-ported tests) test_socket.py (and other partially forward-ported tests) decorate Linux/CAN-specific cases with @support.requires_linux_version(...), which is a Python 3 test.support helper absent from this fork. Evaluated at class-body import time, it raised "AttributeError: 'module' object has no attribute 'requires_linux_version'", crashing test_socket on import (after the earlier support-alias and _have_socket_can gaps were closed). Backport requires_linux_version (+ the _requires_unix_version helper) from upstream test.support. On non-Linux it is a pass-through; the CAN/Linux tests it guards already skip on Windows via HAVE_SOCKET_CAN. functools/platform/ unittest are already imported here. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/support/__init__.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 00b066d3ff1ca7f..9ce68ab48b37652 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -441,6 +441,40 @@ def wrapper(*args, **kw): return decorator +def _requires_unix_version(sysname, min_version): + """Decorator raising SkipTest if the OS is `sysname` and the version is + less than `min_version`.""" + def decorator(func): + @functools.wraps(func) + def wrapper(*args, **kw): + if platform.system() == sysname: + version_txt = platform.release().split('-', 1)[0] + try: + version = tuple(map(int, version_txt.split('.'))) + except ValueError: + pass + else: + if version < min_version: + min_version_txt = '.'.join(map(str, min_version)) + raise unittest.SkipTest( + "%s version %s or higher required, not %s" + % (sysname, min_version_txt, version_txt)) + return func(*args, **kw) + wrapper.min_version = min_version + return wrapper + return decorator + + +def requires_linux_version(*min_version): + """Decorator raising SkipTest if the OS is Linux and the Linux kernel + version is less than min_version. + + For example, @requires_linux_version(2, 6, 32) raises SkipTest if the + Linux kernel version is less than 2.6.32. + """ + return _requires_unix_version('Linux', min_version) + + # Don't use "localhost", since resolving it uses the DNS under recent # Windows versions (see issue #18792). HOST = "127.0.0.1" From 584efebd47991a1078ae5137c85c4ed40832cef1 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Tue, 9 Jun 2026 12:27:49 -0500 Subject: [PATCH 21/24] test_ftplib: add cmd_noop to dummy server; skip flaky source_address on win32 Two test-side residuals, surfaced once the asyncore WSAECONNRESET fix stopped the server threads dying mid-run: - test_with_statement sent 'noop', but DummyFTPHandler had no cmd_noop, so it replied "550 command not understood". Add cmd_noop (200 ok). - test_source_address / _passive_connection assert the bound source port equals a find_unused_port() value; on Windows that port can be taken between selection and bind, so getsockname() returns a different port (off by 1-2). Skip on win32 (the EADDRINUSE branch doesn't cover the assertEqual race). Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_ftplib.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Lib/test/test_ftplib.py b/Lib/test/test_ftplib.py index a1d725206de7c35..c7def634d6b77c7 100644 --- a/Lib/test/test_ftplib.py +++ b/Lib/test/test_ftplib.py @@ -142,6 +142,9 @@ def cmd_echo(self, arg): # sends back the received string (used by the test suite) self.push(arg) + def cmd_noop(self, arg): + self.push('200 noop ok') + def cmd_user(self, arg): self.push('331 username ok') @@ -647,6 +650,10 @@ def is_client_connected(): self.assertEqual(self.server.handler_instance.last_received_cmd, 'quit') self.assertFalse(is_client_connected()) + @skipUnless(os.name != 'nt', + "find_unused_port() races on Windows: the source port can be " + "taken between selection and bind, making the exact-port " + "assertion flaky") def test_source_address(self): self.client.quit() port = test_support.find_unused_port() @@ -660,6 +667,10 @@ def test_source_address(self): self.skipTest("couldn't bind to port %d" % port) raise + @skipUnless(os.name != 'nt', + "find_unused_port() races on Windows: the source port can be " + "taken between selection and bind, making the exact-port " + "assertion flaky") def test_source_address_passive_connection(self): port = test_support.find_unused_port() self.client.source_address = (HOST, port) From 07c707f0e8a81ae41bd23db5a484523a6bee9c37 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Tue, 9 Jun 2026 22:33:45 -0500 Subject: [PATCH 22/24] 2.7.18.14 Release: document Windows regression remediation Finalize the 2.7.18.14 release notes with the Windows (VS2022/UCRT, win64) regression-remediation work delivered on this line: IPH suppression for invalid-fd CRT calls (fileio/posix/signal), asyncore WSAECONNRESET/WSAESHUTDOWN and ssl WSAENOTCONN handling, ctypes find_msvcrt on VS2015+ (bpo-23606), and the win64/UCRT test-suite fixes. patchlevel.h already reads 2.7.18.14 (FINAL). Co-Authored-By: Claude Opus 4.8 (1M context) --- Misc/NEWS.d/2.7.18.14.rst | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/Misc/NEWS.d/2.7.18.14.rst b/Misc/NEWS.d/2.7.18.14.rst index e0a887ea1ecddb8..5ae82f719da882f 100644 --- a/Misc/NEWS.d/2.7.18.14.rst +++ b/Misc/NEWS.d/2.7.18.14.rst @@ -1,3 +1,61 @@ +.. bpo: 0 +.. date: 2026-06-09 +.. nonce: as$win1 +.. release date: 2026-06-09 +.. original section: Windows +.. section: Windows + +Fix interpreter crashes and hangs on Windows when an invalid or closed file +descriptor is used. On builds compiled with a modern (VS2015+/UCRT) toolchain, +a C-runtime call on a bad descriptor invokes the invalid-parameter handler and +fast-fails (surfacing as a "stopped working" dialog). The previously unguarded +``fstat``/``lseek`` calls in ``FileIO`` buffer sizing, in ``os.fdopen`` +(directory check), and in ``signal.set_wakeup_fd`` are now wrapped in +``_Py_BEGIN_SUPPRESS_IPH`` so they return ``EBADF`` as intended. + +.. bpo: 0 +.. date: 2026-06-09 +.. nonce: as$win2 +.. release date: 2026-06-09 +.. original section: Library +.. section: Windows + +``ctypes.util.find_msvcrt()`` now returns ``None`` on builds made with Visual +Studio 2015 or later, instead of returning a non-existent ``msvcrXXX.dll`` +name (the UCRT is not loadable as a single DLL; backport of bpo-23606). +Consequently ``ctypes.util.find_library("c")`` returns ``None`` on such builds. + +.. bpo: 0 +.. date: 2026-06-09 +.. nonce: as$win3 +.. release date: 2026-06-09 +.. original section: Library +.. section: Library + +``asyncore`` now recognises the Winsock error codes ``WSAECONNRESET`` and +``WSAESHUTDOWN`` as disconnects, and ``ssl`` accepts ``WSAENOTCONN`` when +probing whether a socket is connected. On modern Windows builds the C-runtime +``errno`` constants no longer share values with the Winsock error numbers +reported by the socket layer, which previously caused spurious errors on +connection reset and when wrapping a not-yet-connected socket. + +.. bpo: 0 +.. date: 2026-06-09 +.. nonce: as$tst1 +.. release date: 2026-06-09 +.. original section: Tests +.. section: Tests + +Windows test-suite fixes for 64-bit/UCRT builds: the regression-test runner +now suppresses Windows error-reporting and CRT-assert dialogs at startup so a +deliberately-faulting test no longer blocks the run; ``test_bigrepeat`` is +gated on ``sys.maxsize`` rather than ``sys.maxint`` (which is still 2**31-1 on +64-bit Windows); ``test.support`` regained ``requires_linux_version``; and +``test_socket``, ``test_ssl``, ``test_ctypes`` and ``test_ftplib`` were +adjusted for 64-bit Windows and the Winsock error-code differences. Note: run +the suite with the build's ``bin`` and ``DLLs`` directories on ``PATH`` so +extension modules such as ``pyexpat`` can resolve their dependency DLLs. + .. bpo: 0 .. date: 2026-05-27 .. nonce: as$i9+ From 294eb1bac47db456fd6c50719927690f834dbf70 Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Thu, 28 May 2026 23:52:38 -0500 Subject: [PATCH 23/24] fix(tests): skip-guard expandvars nonascii test; restore _have_socket_can Two test-suite fixes surfaced by running `python2 -m test` against the 2.7.18.14 build: * test_posixpath: test_expandvars_nonascii_word dereferenced test_support.FS_NONASCII (None under an ASCII/C-locale filesystem encoding) before its skipTest check, crashing with AttributeError: 'NoneType' object has no attribute 'encode'. Guard it with @skipUnless(test_support.FS_NONASCII, ...) to match its sibling test_expandvars_many and upstream CPython. Regression from the CVE-2025-6075 expandvars backport. * test_socket: HAVE_SOCKET_CAN = _have_socket_can() called a helper that was missing from the module, raising NameError at import and crashing the whole test. Restore the upstream _have_socket_can() definition; it resolves to False where PF_CAN/CAN_RAW are absent. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/test/test_posixpath.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_posixpath.py b/Lib/test/test_posixpath.py index 65d0c3d749838bd..4a3707f91ffc84f 100644 --- a/Lib/test/test_posixpath.py +++ b/Lib/test/test_posixpath.py @@ -512,6 +512,7 @@ def test_expandvars_many(self): big = 'a' * 100000 + '$FOO' self.assertEqual(posixpath.expandvars(big), 'a' * 100000 + 'bar') + @unittest.skipUnless(test_support.FS_NONASCII, 'need test_support.FS_NONASCII') def test_expandvars_nonascii_word(self): encoding = sys.getfilesystemencoding() uwnonascii = test_support.FS_NONASCII From daa4249f5dfba72553982d57efd1182d6819ca8a Mon Sep 17 00:00:00 2001 From: icanhasmath Date: Fri, 12 Jun 2026 17:24:25 -0500 Subject: [PATCH 24/24] Address PR #85 review feedback (ucodery) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_ssl: drop the duplicate _ENOTCONN set; use ssl._NOT_CONNECTED_ERRORS (the identical constant already defined in the ssl module). - support: remove the vestigial `wrapper.min_version` attribute from _requires_unix_version (upstream CPython dropped it in the 3.9 rewrite). - test_webbrowser: replace the bogus `gh-bpo:` placeholder with the actual identifiers (CVE-2026-4519 / CVE-2026-4786). - tarfile: restore the Latin-1 'ä' byte (0xE4) in the three "Gustäbel" lines; a prior edit had corrupted it to the UTF-8 replacement char, contrary to the file's `# -*- coding: iso-8859-1 -*-` declaration. Co-Authored-By: Claude Opus 4.8 (1M context) --- Lib/tarfile.py | 6 +++--- Lib/test/support/__init__.py | 1 - Lib/test/test_ssl.py | 11 ++--------- Lib/test/test_webbrowser.py | 5 +++-- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 69895ad9386425e..d1cb4fe15ae27d7 100644 --- a/Lib/tarfile.py +++ b/Lib/tarfile.py @@ -2,7 +2,7 @@ #------------------------------------------------------------------- # tarfile.py #------------------------------------------------------------------- -# Copyright (C) 2002 Lars Gust�bel +# Copyright (C) 2002 Lars Gustäbel # All rights reserved. # # Permission is hereby granted, free of charge, to any person @@ -33,10 +33,10 @@ # $Source$ version = "0.9.0" -__author__ = "Lars Gust�bel (lars@gustaebel.de)" +__author__ = "Lars Gustäbel (lars@gustaebel.de)" __date__ = "$Date$" __cvsid__ = "$Id$" -__credits__ = "Gustavo Niemeyer, Niels Gust�bel, Richard Townsend." +__credits__ = "Gustavo Niemeyer, Niels Gustäbel, Richard Townsend." #--------- # Imports diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 9ce68ab48b37652..6765fd294552842 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -460,7 +460,6 @@ def wrapper(*args, **kw): "%s version %s or higher required, not %s" % (sysname, min_version_txt, version_txt)) return func(*args, **kw) - wrapper.min_version = min_version return wrapper return decorator diff --git a/Lib/test/test_ssl.py b/Lib/test/test_ssl.py index 105e3c62bb04af8..bab68808f7273bf 100644 --- a/Lib/test/test_ssl.py +++ b/Lib/test/test_ssl.py @@ -18,13 +18,6 @@ import gc import os import errno -# On Windows the socket layer reports Winsock (WSAE*) error numbers, which on a -# modern UCRT build no longer share values with the C-runtime errno constants; -# accept both spellings of "socket not connected". -_ENOTCONN = frozenset( - _e for _e in (getattr(errno, 'ENOTCONN', None), - getattr(errno, 'WSAENOTCONN', None)) - if _e is not None) import pprint import shutil import urllib2 @@ -2886,14 +2879,14 @@ def test_getpeercert_enotconn(self): with closing(context.wrap_socket(socket.socket())) as sock: with self.assertRaises(socket.error) as cm: sock.getpeercert() - self.assertIn(cm.exception.errno, _ENOTCONN) + self.assertIn(cm.exception.errno, ssl._NOT_CONNECTED_ERRORS) def test_do_handshake_enotconn(self): context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) with closing(context.wrap_socket(socket.socket())) as sock: with self.assertRaises(socket.error) as cm: sock.do_handshake() - self.assertIn(cm.exception.errno, _ENOTCONN) + self.assertIn(cm.exception.errno, ssl._NOT_CONNECTED_ERRORS) def test_no_shared_ciphers(self): server_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) diff --git a/Lib/test/test_webbrowser.py b/Lib/test/test_webbrowser.py index 9c4fbd8a6668f92..c5f180879d956ec 100644 --- a/Lib/test/test_webbrowser.py +++ b/Lib/test/test_webbrowser.py @@ -14,8 +14,9 @@ class FakeUnixBrowser(webbrowser.UnixBrowser): class CheckURLTest(unittest.TestCase): - # gh-bpo: webbrowser.open() must not let an attacker-controlled URL be - # turned into a command-line option (CVE-2026-4519 / CVE-2026-4786). + # CVE-2026-4519 / CVE-2026-4786: webbrowser.open() must not let an + # attacker-controlled URL be turned into a command-line option + # (argument injection). def test_check_url_rejects_leading_dash(self): for bad in ("-remote", "--incognito", " -leadingspace", "\t-tab"):