From 1703bdda0ffc08f7035a8db5368d9eb448a910d2 Mon Sep 17 00:00:00 2001
From: Seth Michael Larson <seth@python.org>
Date: Fri, 20 Mar 2026 09:30:02 -0500
Subject: [PATCH 1/4] gh-146211: Reject CR/LF in HTTP tunnel request headers

---
 Lib/http/client.py                                                       |   47 +++++++--
 Lib/test/test_httplib.py                                                 |   49 ++++++++++
 Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst |    2 
 3 files changed, 86 insertions(+), 12 deletions(-)
 create mode 100644 Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst

Index: Python-3.4.10/Lib/http/client.py
===================================================================
--- Python-3.4.10.orig/Lib/http/client.py	2026-04-26 00:44:39.366838416 +0200
+++ Python-3.4.10/Lib/http/client.py	2026-04-26 00:44:39.667007424 +0200
@@ -248,8 +248,22 @@
 
 # the patterns for both name and value are more leniant than RFC
 # definitions to allow for backwards compatibility
-_is_legal_header_name = re.compile(rb'[^:\s][^:\r\n]*').fullmatch
-_is_illegal_header_value = re.compile(rb'\n(?![ \t])|\r(?![ \t\n])').search
+_is_legal_header_name_re = re.compile(rb'[^:\s][^:\r\n]*').fullmatch
+_is_legal_header_name_str_re = re.compile(r'[^:\s][^:\r\n]*').fullmatch
+_is_illegal_header_value_re = re.compile(rb'\n(?![ \t])|\r(?![ \t\n])').search
+_is_illegal_header_value_str_re = re.compile(r'\n(?![ \t])|\r(?![ \t\n])').search
+
+
+def _is_legal_header_name(name):
+    if isinstance(name, bytes):
+        return _is_legal_header_name_re(name)
+    return _is_legal_header_name_str_re(name)
+
+
+def _is_illegal_header_value(value):
+    if isinstance(value, bytes):
+        return _is_illegal_header_value_re(value)
+    return _is_illegal_header_value_str_re(value)
 
 # These characters are not allowed within HTTP URL paths.
 #  See https://tools.ietf.org/html/rfc3986#section-3.3 and the
@@ -463,7 +477,7 @@
         self.length = None
         length = self.headers.get("content-length")
 
-         # are we using the chunked-style of transfer encoding?
+        # are we using the chunked-style of transfer encoding?
         tr_enc = self.headers.get("transfer-encoding")
         if length and not self.chunked:
             try:
@@ -479,7 +493,7 @@
         # does the body have a fixed length? (of zero)
         if (status == NO_CONTENT or status == NOT_MODIFIED or
             100 <= status < 200 or      # 1xx codes
-            self._method == "HEAD"):
+                self._method == "HEAD"):
             self.length = 0
 
         # if the connection remains open, and we aren't using chunked, and
@@ -487,7 +501,7 @@
         # WILL close.
         if (not self.will_close and
             not self.chunked and
-            self.length is None):
+                self.length is None):
             self.will_close = True
 
     def _check_close(self):
@@ -527,7 +541,7 @@
 
     def close(self):
         try:
-            super().close() # set "closed" flag
+            super().close()  # set "closed" flag
         finally:
             if self.fp:
                 self._close_conn()
@@ -625,7 +639,7 @@
             raise LineTooLong("chunk size")
         i = line.find(b";")
         if i >= 0:
-            line = line[:i] # strip chunk-extensions
+            line = line[:i]  # strip chunk-extensions
         try:
             return int(line, 16)
         except ValueError:
@@ -885,13 +899,23 @@
         self.debuglevel = level
 
     def _tunnel(self):
+        if _contains_disallowed_url_pchar_re.search(self._tunnel_host):
+            raise ValueError('Tunnel host can\'t contain control characters %r'
+                             % (self._tunnel_host,))
         connect_str = "CONNECT %s:%d HTTP/1.0\r\n" % (self._tunnel_host,
             self._tunnel_port)
         connect_bytes = connect_str.encode("ascii")
         self.send(connect_bytes)
         for header, value in self._tunnel_headers.items():
-            header_str = "%s: %s\r\n" % (header, value)
-            header_bytes = header_str.encode("latin-1")
+            if not _is_legal_header_name(header):
+                raise ValueError('Invalid header name %r' % (header,))
+            if _is_illegal_header_value(value):
+                raise ValueError('Invalid header value %r' % (value,))
+            if hasattr(header, 'encode'):
+                header = header.encode('latin-1')
+            if hasattr(value, 'encode'):
+                value = value.encode('latin-1')
+            header_bytes = header + b': ' + value + b'\r\n'
             self.send(header_bytes)
         self.send(b'\r\n')
 
@@ -1168,9 +1192,6 @@
         if self.__state != _CS_REQ_STARTED:
             raise CannotSendHeader()
 
-        if hasattr(header, 'encode'):
-            header = header.encode('ascii')
-
         if not _is_legal_header_name(header):
             raise ValueError('Invalid header name %r' % (header,))
 
@@ -1185,6 +1206,8 @@
                 raise ValueError('Invalid header value %r' % (values[i],))
 
         value = b'\r\n\t'.join(values)
+        if hasattr(header, 'encode'):
+            header = header.encode('latin-1')
         header = header + b': ' + value
         self._output(header)
 
Index: Python-3.4.10/Lib/test/test_httplib.py
===================================================================
--- Python-3.4.10.orig/Lib/test/test_httplib.py	2026-04-26 00:44:39.367754923 +0200
+++ Python-3.4.10/Lib/test/test_httplib.py	2026-04-26 00:58:32.904840214 +0200
@@ -274,6 +274,52 @@
                 with self.assertRaisesRegex(ValueError, 'Invalid header'):
                     conn.putheader(name, value)
 
+    def test_invalid_tunnel_headers(self):
+        cases = (
+            ('Invalid\r\nName', 'ValidValue'),
+            ('Invalid\rName', 'ValidValue'),
+            ('Invalid\nName', 'ValidValue'),
+            ('\r\nInvalidName', 'ValidValue'),
+            ('\rInvalidName', 'ValidValue'),
+            ('\nInvalidName', 'ValidValue'),
+            (' InvalidName', 'ValidValue'),
+            ('\tInvalidName', 'ValidValue'),
+            ('Invalid:Name', 'ValidValue'),
+            (':InvalidName', 'ValidValue'),
+            ('ValidName', 'Invalid\r\nValue'),
+            ('ValidName', 'Invalid\rValue'),
+            ('ValidName', 'Invalid\nValue'),
+            ('ValidName', 'InvalidValue\r\n'),
+            ('ValidName', 'InvalidValue\r'),
+            ('ValidName', 'InvalidValue\n'),
+        )
+        for name, value in cases:
+            with self.subTest((name, value)):
+                conn = client.HTTPConnection('example.com')
+                conn.set_tunnel('tunnel', headers={
+                    name: value
+                })
+                conn.sock = FakeSocket('')
+                with self.assertRaisesRegex(ValueError, 'Invalid header'):
+                    conn._tunnel()  # Called in .connect()
+
+    def test_invalid_tunnel_host(self):
+        cases = (
+            'invalid\r.host',
+            '\ninvalid.host',
+            'invalid.host\r\n',
+            'invalid.host\x00',
+            'invalid host',
+        )
+        for tunnel_host in cases:
+            with self.subTest(tunnel_host):
+                conn = client.HTTPConnection('example.com')
+                conn.set_tunnel(tunnel_host)
+                conn.sock = FakeSocket('')
+                with self.assertRaisesRegex(ValueError,
+                                            'Tunnel host can\'t contain control characters'):
+                    conn._tunnel()  # Called in .connect()
+
 
 class BasicTest(TestCase):
     def test_status_lines(self):
@@ -880,6 +926,9 @@
         # HTTPMessage, parse_headers(), and the HTTP status code constants are
         # intentionally omitted for simplicity
         blacklist = {"HTTPMessage", "parse_headers"}
+        # additional functions added by CVE-2026-1502-reject-CRLF-HTTP-tunnel.patch
+        blacklist.update(('_is_illegal_header_value',
+                          '_is_legal_header_name'))
         for name in dir(client):
             if name in blacklist:
                 continue
Index: Python-3.4.10/Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst
===================================================================
--- /dev/null	1970-01-01 00:00:00.000000000 +0000
+++ Python-3.4.10/Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst	2026-04-26 00:44:39.667973383 +0200
@@ -0,0 +1,2 @@
+Reject CR/LF characters in tunnel request headers for the
+HTTPConnection.set_tunnel() method.
