Submitted By: Joe Locash Date: 2026-03-26 Initial Package Version: 3.14.3 Upstream Status: Applied Origin: Upstream https://github.com/python/cpython/commit/eb0e8be3a7e11b87d198a2c3af1ed0eccf532768 https://github.com/python/cpython/commit/57e88c1cf95e1481b94ae57abe1010469d47a6b4 https://github.com/python/cpython/pull/143931 Description: When an Expat parser with a registered ElementDeclHandler parses an inline document type definition containing a deeply nested content model a C stack overflow occurs. The fix for CVE-2026-0672, which rejected control characters in http.cookies.Morsel, was incomplete. The Morsel.update(), |= operator, and unpickling paths were not patched, allowing control characters to bypass input validation. Additionally, BaseCookie.js_output() lacked the output validation applied to BaseCookie.output(). The webbrowser.open() API would accept leading dashes in the URL which could be handled as command line options for certain web browsers. New behavior rejects leading dashes. Users are recommended to sanitize URLs prior to passing to webbrowser.open(). Fixes backported to version 3.14.3 by Joe Locash. diff -Nuarp Python-3.14.3.orig/Lib/http/cookies.py Python-3.14.3/Lib/http/cookies.py --- Python-3.14.3.orig/Lib/http/cookies.py 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Lib/http/cookies.py 2026-03-21 16:49:52.712312381 -0400 @@ -337,9 +337,16 @@ class Morsel(dict): key = key.lower() if key not in self._reserved: raise CookieError("Invalid attribute %r" % (key,)) + if _has_control_character(key, val): + raise CookieError("Control characters are not allowed in " + f"cookies {key!r} {val!r}") data[key] = val dict.update(self, data) + def __ior__(self, values): + self.update(values) + return self + def isReservedKey(self, K): return K.lower() in self._reserved @@ -365,9 +372,15 @@ class Morsel(dict): } def __setstate__(self, state): - self._key = state['key'] - self._value = state['value'] - self._coded_value = state['coded_value'] + key = state['key'] + value = state['value'] + coded_value = state['coded_value'] + if _has_control_character(key, value, coded_value): + raise CookieError("Control characters are not allowed in cookies " + f"{key!r} {value!r} {coded_value!r}") + self._key = key + self._value = value + self._coded_value = coded_value def output(self, attrs=None, header="Set-Cookie:"): return "%s %s" % (header, self.OutputString(attrs)) @@ -379,13 +392,16 @@ class Morsel(dict): def js_output(self, attrs=None): # Print javascript + output_string = self.OutputString(attrs) + if _has_control_character(output_string): + raise CookieError("Control characters are not allowed in cookies") return """ - """ % (self.OutputString(attrs).replace('"', r'\"')) + """ % (output_string.replace('"', r'\"')) def OutputString(self, attrs=None): # Build up our result diff -Nuarp Python-3.14.3.orig/Lib/test/test_http_cookies.py Python-3.14.3/Lib/test/test_http_cookies.py --- Python-3.14.3.orig/Lib/test/test_http_cookies.py 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Lib/test/test_http_cookies.py 2026-03-21 16:49:52.712674042 -0400 @@ -581,6 +581,14 @@ class MorselTests(unittest.TestCase): with self.assertRaises(cookies.CookieError): morsel["path"] = c0 + # .__setstate__() + with self.assertRaises(cookies.CookieError): + morsel.__setstate__({'key': c0, 'value': 'val', 'coded_value': 'coded'}) + with self.assertRaises(cookies.CookieError): + morsel.__setstate__({'key': 'key', 'value': c0, 'coded_value': 'coded'}) + with self.assertRaises(cookies.CookieError): + morsel.__setstate__({'key': 'key', 'value': 'val', 'coded_value': c0}) + # .setdefault() with self.assertRaises(cookies.CookieError): morsel.setdefault("path", c0) @@ -595,6 +603,18 @@ class MorselTests(unittest.TestCase): with self.assertRaises(cookies.CookieError): morsel.set("path", "val", c0) + # .update() + with self.assertRaises(cookies.CookieError): + morsel.update({"path": c0}) + with self.assertRaises(cookies.CookieError): + morsel.update({c0: "val"}) + + # .__ior__() + with self.assertRaises(cookies.CookieError): + morsel |= {"path": c0} + with self.assertRaises(cookies.CookieError): + morsel |= {c0: "val"} + def test_control_characters_output(self): # Tests that even if the internals of Morsel are modified # that a call to .output() has control character safeguards. @@ -615,6 +635,24 @@ class MorselTests(unittest.TestCase): with self.assertRaises(cookies.CookieError): cookie.output() + # Tests that .js_output() also has control character safeguards. + for c0 in support.control_characters_c0(): + morsel = cookies.Morsel() + morsel.set("key", "value", "coded-value") + morsel._key = c0 # Override private variable. + cookie = cookies.SimpleCookie() + cookie["cookie"] = morsel + with self.assertRaises(cookies.CookieError): + cookie.js_output() + + morsel = cookies.Morsel() + morsel.set("key", "value", "coded-value") + morsel._coded_value = c0 # Override private variable. + cookie = cookies.SimpleCookie() + cookie["cookie"] = morsel + with self.assertRaises(cookies.CookieError): + cookie.js_output() + def load_tests(loader, tests, pattern): tests.addTest(doctest.DocTestSuite(cookies)) diff -Nuarp Python-3.14.3.orig/Lib/test/test_pyexpat.py Python-3.14.3/Lib/test/test_pyexpat.py --- Python-3.14.3.orig/Lib/test/test_pyexpat.py 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Lib/test/test_pyexpat.py 2026-03-21 16:49:39.677966250 -0400 @@ -689,6 +689,25 @@ class ElementDeclHandlerTest(unittest.Te parser.ElementDeclHandler = lambda _1, _2: None self.assertRaises(TypeError, parser.Parse, data, True) + @support.skip_if_unlimited_stack_size + @support.skip_emscripten_stack_overflow() + @support.skip_wasi_stack_overflow() + def test_deeply_nested_content_model(self): + # This should raise a RecursionError and not crash. + # See https://github.com/python/cpython/issues/145986. + N = 500_000 + data = ( + b'\n]>\n\n' + ) + + parser = expat.ParserCreate() + parser.ElementDeclHandler = lambda _1, _2: None + with support.infinite_recursion(): + with self.assertRaises(RecursionError): + parser.Parse(data) + class MalformedInputTest(unittest.TestCase): def test1(self): xml = b"\0\r\n" diff -Nuarp Python-3.14.3.orig/Lib/test/test_webbrowser.py Python-3.14.3/Lib/test/test_webbrowser.py --- Python-3.14.3.orig/Lib/test/test_webbrowser.py 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Lib/test/test_webbrowser.py 2026-03-21 16:49:45.577085677 -0400 @@ -67,6 +67,11 @@ class GenericBrowserCommandTest(CommandT options=[], arguments=[URL]) + def test_reject_dash_prefixes(self): + browser = self.browser_class(name=CMD_NAME) + with self.assertRaises(ValueError): + browser.open(f"--key=val {URL}") + class BackgroundBrowserCommandTest(CommandTestMixin, unittest.TestCase): diff -Nuarp Python-3.14.3.orig/Lib/webbrowser.py Python-3.14.3/Lib/webbrowser.py --- Python-3.14.3.orig/Lib/webbrowser.py 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Lib/webbrowser.py 2026-03-21 16:49:45.577396563 -0400 @@ -163,6 +163,12 @@ class BaseBrowser: def open_new_tab(self, url): return self.open(url, 2) + @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(f"Invalid URL: {url}") + class GenericBrowser(BaseBrowser): """Class for all browsers started with a command @@ -180,6 +186,7 @@ class GenericBrowser(BaseBrowser): def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] try: @@ -200,6 +207,7 @@ class BackgroundBrowser(GenericBrowser): cmdline = [self.name] + [arg.replace("%s", url) for arg in self.args] sys.audit("webbrowser.open", url) + self._check_url(url) try: if sys.platform[:3] == 'win': p = subprocess.Popen(cmdline) @@ -266,6 +274,7 @@ class UnixBrowser(BaseBrowser): def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) if new == 0: action = self.remote_action elif new == 1: @@ -357,6 +366,7 @@ class Konqueror(BaseBrowser): def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) # XXX Currently I know no way to prevent KFM from opening a new win. if new == 2: action = "newTab" @@ -588,6 +598,7 @@ if sys.platform[:3] == "win": class WindowsDefault(BaseBrowser): def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) try: os.startfile(url) except OSError: @@ -608,6 +619,7 @@ if sys.platform == 'darwin': def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) url = url.replace('"', '%22') if self.name == 'default': proto, _sep, _rest = url.partition(":") @@ -664,6 +676,7 @@ if sys.platform == "ios": class IOSBrowser(BaseBrowser): def open(self, url, new=0, autoraise=True): sys.audit("webbrowser.open", url) + self._check_url(url) # If ctypes isn't available, we can't open a browser if objc is None: return False diff -Nuarp Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-01-16-12-04-49.gh-issue-143930.zYC5x3.rst Python-3.14.3/Misc/NEWS.d/next/Security/2026-01-16-12-04-49.gh-issue-143930.zYC5x3.rst --- Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-01-16-12-04-49.gh-issue-143930.zYC5x3.rst 1969-12-31 19:00:00.000000000 -0500 +++ Python-3.14.3/Misc/NEWS.d/next/Security/2026-01-16-12-04-49.gh-issue-143930.zYC5x3.rst 2026-03-21 16:49:45.577550844 -0400 @@ -0,0 +1 @@ +Reject leading dashes in URLs passed to :func:`webbrowser.open` diff -Nuarp Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-03-06-17-03-38.gh-issue-145599.kchwZV.rst Python-3.14.3/Misc/NEWS.d/next/Security/2026-03-06-17-03-38.gh-issue-145599.kchwZV.rst --- Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-03-06-17-03-38.gh-issue-145599.kchwZV.rst 1969-12-31 19:00:00.000000000 -0500 +++ Python-3.14.3/Misc/NEWS.d/next/Security/2026-03-06-17-03-38.gh-issue-145599.kchwZV.rst 2026-03-21 16:49:52.713006379 -0400 @@ -0,0 +1,4 @@ +Reject control characters in :class:`http.cookies.Morsel` +:meth:`~http.cookies.Morsel.update` and +:meth:`~http.cookies.BaseCookie.js_output`. +This addresses :cve:`2026-3644`. diff -Nuarp Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-03-14-17-31-39.gh-issue-145986.ifSSr8.rst Python-3.14.3/Misc/NEWS.d/next/Security/2026-03-14-17-31-39.gh-issue-145986.ifSSr8.rst --- Python-3.14.3.orig/Misc/NEWS.d/next/Security/2026-03-14-17-31-39.gh-issue-145986.ifSSr8.rst 1969-12-31 19:00:00.000000000 -0500 +++ Python-3.14.3/Misc/NEWS.d/next/Security/2026-03-14-17-31-39.gh-issue-145986.ifSSr8.rst 2026-03-21 16:49:39.678194310 -0400 @@ -0,0 +1,4 @@ +:mod:`xml.parsers.expat`: Fixed a crash caused by unbounded C recursion when +converting deeply nested XML content models with +:meth:`~xml.parsers.expat.xmlparser.ElementDeclHandler`. +This addresses :cve:`2026-4224`. diff -Nuarp Python-3.14.3.orig/Modules/pyexpat.c Python-3.14.3/Modules/pyexpat.c --- Python-3.14.3.orig/Modules/pyexpat.c 2026-02-03 10:32:20.000000000 -0500 +++ Python-3.14.3/Modules/pyexpat.c 2026-03-21 16:49:39.678449501 -0400 @@ -3,6 +3,7 @@ #endif #include "Python.h" +#include "pycore_ceval.h" // _Py_EnterRecursiveCall() #include "pycore_import.h" // _PyImport_SetModule() #include "pycore_pyhash.h" // _Py_HashSecret #include "pycore_traceback.h" // _PyTraceback_Add() @@ -603,6 +604,10 @@ static PyObject * conv_content_model(XML_Content * const model, PyObject *(*conv_string)(void *)) { + if (_Py_EnterRecursiveCall(" in conv_content_model")) { + return NULL; + } + PyObject *result = NULL; PyObject *children = PyTuple_New(model->numchildren); int i; @@ -614,7 +619,7 @@ conv_content_model(XML_Content * const m conv_string); if (child == NULL) { Py_XDECREF(children); - return NULL; + goto done; } PyTuple_SET_ITEM(children, i, child); } @@ -622,6 +627,8 @@ conv_content_model(XML_Content * const m model->type, model->quant, conv_string, model->name, children); } +done: + _Py_LeaveRecursiveCall(); return result; }