From ef01008e47a7741808ab223087a458f33e4dd922 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Fri, 16 Jan 2026 10:54:09 -0600 Subject: [PATCH 1/4] Add 'test.support' fixture for C0 control characters --- Lib/test/support/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 3a639497fa1272..9c113a6c137e52 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -3303,3 +3303,10 @@ def linked_to_musl(): return _linked_to_musl _linked_to_musl = tuple(map(int, version.split('.'))) return _linked_to_musl + + +def control_characters_c0() -> list[str]: + """Returns a list of C0 control characters as strings. + C0 control characters defined as the byte range 0x00-0x1F, and 0x7F. + """ + return [chr(c) for c in range(0x00, 0x20)] + ["\x7F"] From fdbf0c4af076c9804cc66ced3973f059e44caeee Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Thu, 8 Jan 2026 13:23:04 -0600 Subject: [PATCH 2/4] gh-143919: Reject control characters in http.cookies.Morsel --- Lib/http/cookies.py | 25 +++++++-- Lib/test/test_http_cookies.py | 53 +++++++++++++++++-- ...-01-16-11-13-15.gh-issue-143919.kchwZV.rst | 1 + 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py index 74349bb63d66e2..4e797c6c0c50ae 100644 --- a/Lib/http/cookies.py +++ b/Lib/http/cookies.py @@ -87,9 +87,9 @@ such trickeries do not confuse it. >>> C = cookies.SimpleCookie() - >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=\\012;";') + >>> C.load('keebler="E=everybody; L=\\"Loves\\"; fudge=;";') >>> print(C) - Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=\012;" + Set-Cookie: keebler="E=everybody; L=\"Loves\"; fudge=;" Each element of the Cookie also supports all of the RFC 2109 Cookie attributes. Here's an example which sets the Path @@ -170,6 +170,15 @@ class CookieError(Exception): }) _is_legal_key = re.compile('[%s]+' % re.escape(_LegalChars)).fullmatch +_control_character_re = re.compile(r'[\x00-\x1F\x7F]') + + +def _has_control_character(*val): + """Detects control characters within a value. + Supports any type, as header values can be any type. + """ + return any(_control_character_re.search(str(v)) for v in val) + def _quote(str): r"""Quote a string for use in a cookie header. @@ -294,12 +303,16 @@ def __setitem__(self, K, V): K = K.lower() if not K in self._reserved: raise CookieError("Invalid attribute %r" % (K,)) + if _has_control_character(K, V): + raise CookieError("Control characters are not allowed in cookies %r %r" % (K, V,)) dict.__setitem__(self, K, V) def setdefault(self, key, val=None): key = key.lower() if key not in self._reserved: raise CookieError("Invalid attribute %r" % (key,)) + if _has_control_character(key, val): + raise CookieError("Control characters are not allowed in cookies %r %r" % (key, val,)) return dict.setdefault(self, key, val) def __eq__(self, morsel): @@ -335,6 +348,9 @@ def set(self, key, val, coded_val): raise CookieError('Attempt to set a reserved key %r' % (key,)) if not _is_legal_key(key): raise CookieError('Illegal key %r' % (key,)) + if _has_control_character(key, val, coded_val): + raise CookieError( + "Control characters are not allowed in cookies %r %r %r" % (key, val, coded_val,)) # It's a good key, so save it. self._key = key @@ -488,7 +504,10 @@ def output(self, attrs=None, header="Set-Cookie:", sep="\015\012"): result = [] items = sorted(self.items()) for key, value in items: - result.append(value.output(attrs, header)) + value_output = value.output(attrs, header) + if _has_control_character(value_output): + raise CookieError("Control characters are not allowed in cookies") + result.append(value_output) return sep.join(result) __str__ = output diff --git a/Lib/test/test_http_cookies.py b/Lib/test/test_http_cookies.py index c2ed30831b2e0e..ca4434dd7ecc9d 100644 --- a/Lib/test/test_http_cookies.py +++ b/Lib/test/test_http_cookies.py @@ -17,10 +17,10 @@ def test_basic(self): 'repr': "", 'output': 'Set-Cookie: chips=ahoy\nSet-Cookie: vienna=finger'}, - {'data': 'keebler="E=mc2; L=\\"Loves\\"; fudge=\\012;"', - 'dict': {'keebler' : 'E=mc2; L="Loves"; fudge=\012;'}, - 'repr': '''''', - 'output': 'Set-Cookie: keebler="E=mc2; L=\\"Loves\\"; fudge=\\012;"'}, + {'data': 'keebler="E=mc2; L=\\"Loves\\"; fudge=;"', + 'dict': {'keebler' : 'E=mc2; L="Loves"; fudge=;'}, + 'repr': '''''', + 'output': 'Set-Cookie: keebler="E=mc2; L=\\"Loves\\"; fudge=;"'}, # Check illegal cookies that have an '=' char in an unquoted value {'data': 'keebler=E=mc2', @@ -594,6 +594,51 @@ def test_repr(self): r'Set-Cookie: key=coded_val; ' r'expires=\w+, \d+ \w+ \d+ \d+:\d+:\d+ \w+') + def test_control_characters(self): + for c0 in support.control_characters_c0(): + morsel = cookies.Morsel() + + # .__setitem__() + with self.assertRaises(cookies.CookieError): + morsel[c0] = "val" + with self.assertRaises(cookies.CookieError): + morsel["path"] = c0 + + # .setdefault() + with self.assertRaises(cookies.CookieError): + morsel.setdefault("path", c0) + with self.assertRaises(cookies.CookieError): + morsel.setdefault(c0, "val") + + # .set() + with self.assertRaises(cookies.CookieError): + morsel.set(c0, "val", "coded-value") + with self.assertRaises(cookies.CookieError): + morsel.set("path", c0, "coded-value") + with self.assertRaises(cookies.CookieError): + morsel.set("path", "val", c0) + + def test_control_characters_output(self): + # Tests that even if the internals of Morsel are modified + # that a call to .output() has control character safeguards. + for c0 in support.control_characters_c0(): + morsel = cookies.Morsel() + morsel.set("key", "value", "coded-value") + morsel._key = c0 # Override private variable. + cookie = cookies.SimpleCookie() + cookie["cookie"] = morsel + with self.assertRaises(cookies.CookieError): + cookie.output() + + morsel = cookies.Morsel() + morsel.set("key", "value", "coded-value") + morsel._coded_value = c0 # Override private variable. + cookie = cookies.SimpleCookie() + cookie["cookie"] = morsel + with self.assertRaises(cookies.CookieError): + cookie.output() + + def load_tests(loader, tests, pattern): tests.addTest(doctest.DocTestSuite(cookies)) diff --git a/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst b/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst new file mode 100644 index 00000000000000..788c3e4ac2ebf7 --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-01-16-11-13-15.gh-issue-143919.kchwZV.rst @@ -0,0 +1 @@ +Reject control characters in :class:`http.cookies.Morsel` fields and values. From 03f222d8808552582b69bfd2da7266b2a5573df6 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Fri, 16 Jan 2026 19:31:42 +0000 Subject: [PATCH 3/4] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartosz Sławecki --- Lib/test/test_http_cookies.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/test/test_http_cookies.py b/Lib/test/test_http_cookies.py index ca4434dd7ecc9d..7d072d5fd67ca7 100644 --- a/Lib/test/test_http_cookies.py +++ b/Lib/test/test_http_cookies.py @@ -639,7 +639,6 @@ def test_control_characters_output(self): cookie.output() - def load_tests(loader, tests, pattern): tests.addTest(doctest.DocTestSuite(cookies)) return tests From 6b999b5883936964ed6d519a2cd3a8206cd9e3b8 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson Date: Fri, 16 Jan 2026 19:57:35 +0000 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: sobolevn --- Lib/http/cookies.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/cookies.py b/Lib/http/cookies.py index 4e797c6c0c50ae..917280037d4dbb 100644 --- a/Lib/http/cookies.py +++ b/Lib/http/cookies.py @@ -304,7 +304,7 @@ def __setitem__(self, K, V): if not K in self._reserved: raise CookieError("Invalid attribute %r" % (K,)) if _has_control_character(K, V): - raise CookieError("Control characters are not allowed in cookies %r %r" % (K, V,)) + raise CookieError(f"Control characters are not allowed in cookies {K!r} {V!r}") dict.__setitem__(self, K, V) def setdefault(self, key, val=None):