From 1703bdda0ffc08f7035a8db5368d9eb448a910d2 Mon Sep 17 00:00:00 2001 From: Seth Michael Larson 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 | 10 ++++- Lib/test/test_httplib.py | 45 +++++++++++++++++++ ...-03-20-09-29-42.gh-issue-146211.PQVbs7.rst | 2 + 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst diff --git a/Lib/http/client.py b/Lib/http/client.py index 73c3256734a64f..1e0b004330f8e0 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -976,13 +976,21 @@ def _wrap_ipv6(self, ip): return ip def _tunnel(self): + if _contains_disallowed_url_pchar_re.search(self._tunnel_host): + raise ValueError('Invalid header value %r' % (self._tunnel_host,)) connect = b"CONNECT %s:%d %s\r\n" % ( self._wrap_ipv6(self._tunnel_host.encode("idna")), self._tunnel_port, self._http_vsn_str.encode("ascii")) headers = [connect] for header, value in self._tunnel_headers.items(): - headers.append(f"{header}: {value}\r\n".encode("latin-1")) + header_bytes = header.encode("latin-1") + value_bytes = value.encode("latin-1") + if not _is_legal_header_name(header_bytes): + raise ValueError('Invalid header name %r' % (header_bytes,)) + if _is_illegal_header_value(value_bytes): + raise ValueError('Invalid header value %r' % (value_bytes,)) + headers.append(b"%s: %s\r\n" % (header_bytes, value_bytes)) headers.append(b"\r\n") # Making a single send() call instead of one per line encourages # the host OS to use a more optimal packet size instead of diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index 44044d0385c72e..e72bbfb261552d 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -369,6 +369,51 @@ def test_invalid_headers(self): 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, 'Invalid header'): + conn._tunnel() # Called in .connect() + def test_headers_debuglevel(self): body = ( b'HTTP/1.1 200 OK\r\n' diff --git a/Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst b/Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst new file mode 100644 index 00000000000000..4993633b8ebebb --- /dev/null +++ b/Misc/NEWS.d/next/Security/2026-03-20-09-29-42.gh-issue-146211.PQVbs7.rst @@ -0,0 +1,2 @@ +Reject CR/LF characters in tunnel request headers for the +HTTPConnection.set_tunnel() method. From 277163d3cee1ef360eda1dac94294fba56417fef Mon Sep 17 00:00:00 2001 From: Seth Larson Date: Fri, 10 Apr 2026 13:54:57 +0000 Subject: [PATCH 2/4] Update Lib/http/client.py Co-authored-by: Illia Volochii --- Lib/http/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Lib/http/client.py b/Lib/http/client.py index 1e0b004330f8e0..ebf0ed218257c2 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -977,7 +977,8 @@ def _wrap_ipv6(self, ip): def _tunnel(self): if _contains_disallowed_url_pchar_re.search(self._tunnel_host): - raise ValueError('Invalid header value %r' % (self._tunnel_host,)) + raise ValueError('Tunnel host can't contain control characters %r' + % (self._tunnel_host,)) connect = b"CONNECT %s:%d %s\r\n" % ( self._wrap_ipv6(self._tunnel_host.encode("idna")), self._tunnel_port, From b7ea6ae66a1aed6fe1f60e305523786b14f0d09d Mon Sep 17 00:00:00 2001 From: Seth Larson Date: Fri, 10 Apr 2026 13:59:28 +0000 Subject: [PATCH 3/4] Escape single quote --- Lib/http/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/http/client.py b/Lib/http/client.py index ebf0ed218257c2..1e1a535c4c4eb1 100644 --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -977,7 +977,7 @@ def _wrap_ipv6(self, ip): def _tunnel(self): if _contains_disallowed_url_pchar_re.search(self._tunnel_host): - raise ValueError('Tunnel host can't contain control characters %r' + raise ValueError('Tunnel host can\'t contain control characters %r' % (self._tunnel_host,)) connect = b"CONNECT %s:%d %s\r\n" % ( self._wrap_ipv6(self._tunnel_host.encode("idna")), From 0176ee72696bf08f1f6e5738f2824fa2f72d58b9 Mon Sep 17 00:00:00 2001 From: Seth Larson Date: Fri, 10 Apr 2026 14:42:23 +0000 Subject: [PATCH 4/4] Update test_httplib.py --- Lib/test/test_httplib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py index e72bbfb261552d..f771fc48dada36 100644 --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -411,7 +411,7 @@ def test_invalid_tunnel_host(self): conn = client.HTTPConnection('example.com') conn.set_tunnel(tunnel_host) conn.sock = FakeSocket('') - with self.assertRaisesRegex(ValueError, 'Invalid header'): + with self.assertRaisesRegex(ValueError, 'Tunnel host can\'t contain control characters'): conn._tunnel() # Called in .connect() def test_headers_debuglevel(self):