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/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/HTMLParser.py b/Lib/HTMLParser.py index fb9380e128bfc76..85b980750e32bab 100644 --- a/Lib/HTMLParser.py +++ b/Lib/HTMLParser.py @@ -20,6 +20,7 @@ charref = re.compile('&#(?:[0-9]+|[xX][0-9a-fA-F]+)[^0-9a-fA-F]') starttagopen = re.compile('<[a-zA-Z]') +endtagopen = re.compile('') commentclose = re.compile(r'--\s*>') @@ -167,7 +168,7 @@ def goahead(self, end): k = self.parse_pi(i) elif startswith("', i + 1) - if k < 0: - k = rawdata.find('<', i + 1) - if k < 0: - k = i + 1 + # End of input with an unterminated construct. Close it + # per HTML5 instead of rescanning, which made repeated + # incomplete constructs quadratic (CVE-2025-6069). + if starttagopen.match(rawdata, i): # < + letter + pass + elif startswith(" 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/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") 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/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 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') 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/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/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/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/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'' diff --git a/Lib/tarfile.py b/Lib/tarfile.py index 4aec4ea14548ddc..d1cb4fe15ae27d7 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") @@ -1475,6 +1490,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/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/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) diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 00b066d3ff1ca7f..6765fd294552842 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -441,6 +441,39 @@ 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) + 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" 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") 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_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) diff --git a/Lib/test/test_htmlparser.py b/Lib/test/test_htmlparser.py index cde2bd23b74f9b3..65c73b0eee251c0 100644 --- a/Lib/test/test_htmlparser.py +++ b/Lib/test/test_htmlparser.py @@ -205,21 +205,57 @@ def test_starttag_junk_chars(self): self._run_check("", []) self._run_check("", [('comment', '$')]) self._run_check("", [('starttag', 'a", [('endtag', 'a'", [('data', "'", []) + self._run_check("", [('starttag', 'a$b', [])]) self._run_check("", [('startendtag', 'a$b', [])]) self._run_check("", [('starttag', 'a$b', [])]) self._run_check("", [('startendtag', 'a$b', [])]) + def test_eof_in_comments(self): + # CVE-2025-6069: an unterminated comment at EOF is closed rather than + # rescanned, which previously made repeated incomplete constructs + # quadratic. + data = [ + ('', [('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 = [ + (':///?# @@ -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: 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 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))) 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 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 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..5ae82f719da882f --- /dev/null +++ b/Misc/NEWS.d/2.7.18.14.rst @@ -0,0 +1,319 @@ +.. 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+ +.. 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. 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;