diff --git a/Lib/http/client.py b/Lib/http/client.py --- a/Lib/http/client.py +++ b/Lib/http/client.py @@ -744,26 +744,37 @@ self.__response = None self.__state = _CS_IDLE self._method = None - self._tunnel_host = None - self._tunnel_port = None - self._tunnel_headers = {} + self._tunnel_host = [] + self._tunnel_port = [] + self._tunnel_headers = [] - self._set_hostport(host, port) + (self.host, self.port) = self._get_hostport(host, port) + # This is stored as an instance variable to allow unit + # tests to replace it with a suitable mockup + self._create_connection = socket.create_connection + def set_tunnel(self, host, port=None, headers=None): """ Sets up the host and the port for the HTTP CONNECT Tunnelling. + This method sends a CONNECT request to remote side. If this succeeds, + all further data is exchanged with *host*, with the original host acting + as an invisible tunnel only. + The headers argument should be a mapping of extra HTTP headers to send with the CONNECT request. + + This method may be called repeatedly to set up a chain of tunnels. """ - self._tunnel_host = host - self._tunnel_port = port + + self._tunnel_host.append(host) + self._tunnel_port.append(port) if headers: - self._tunnel_headers = headers + self._tunnel_headers.append(headers) else: - self._tunnel_headers.clear() + self._tunnel_headers.append({}) - def _set_hostport(self, host, port): + def _get_hostport(self, host, port): if port is None: i = host.rfind(':') j = host.rfind(']') # ipv6 addresses have [...] @@ -780,18 +791,18 @@ port = self.default_port if host and host[0] == '[' and host[-1] == ']': host = host[1:-1] - self.host = host - self.port = port + + return (host, port) def set_debuglevel(self, level): self.debuglevel = level - def _tunnel(self): - self._set_hostport(self._tunnel_host, self._tunnel_port) - connect_str = "CONNECT %s:%d HTTP/1.0\r\n" % (self.host, self.port) + def _tunnel(self, host, port, headers): + (host, port) = self._get_hostport(host, port) + connect_str = "CONNECT %s:%d HTTP/1.0\r\n" % (host, port) connect_bytes = connect_str.encode("ascii") self.send(connect_bytes) - for header, value in self._tunnel_headers.items(): + for header, value in headers.items(): header_str = "%s: %s\r\n" % (header, value) header_bytes = header_str.encode("latin-1") self.send(header_bytes) @@ -816,10 +827,13 @@ def connect(self): """Connect to the host and port specified in __init__.""" - self.sock = socket.create_connection((self.host,self.port), - self.timeout, self.source_address) - if self._tunnel_host: - self._tunnel() + self.sock = self._create_connection((self.host,self.port), + self.timeout, self.source_address) + + for (host, port, headers) in zip(self._tunnel_host, + self._tunnel_port, + self._tunnel_headers): + self._tunnel(host, port, headers) def close(self): """Close the connection to the HTTP server.""" @@ -986,22 +1000,29 @@ netloc_enc = netloc.encode("idna") self.putheader('Host', netloc_enc) else: + if self._tunnel_host: + host = self._tunnel_host[-1] + port = self._tunnel_port[-1] + else: + host = self.host + port = self.port + try: - host_enc = self.host.encode("ascii") + host_enc = host.encode("ascii") except UnicodeEncodeError: - host_enc = self.host.encode("idna") + host_enc = host.encode("idna") # As per RFC 273, IPv6 address should be wrapped with [] # when used as Host header - if self.host.find(':') >= 0: + if host.find(':') >= 0: host_enc = b'[' + host_enc + b']' - if self.port == self.default_port: + if port == self.default_port: self.putheader('Host', host_enc) else: host_enc = host_enc.decode("ascii") - self.putheader('Host', "%s:%s" % (host_enc, self.port)) + self.putheader('Host', "%s:%s" % (host_enc, port)) # note: we are assuming that clients will not attempt to set these # headers since *this* library must deal with the @@ -1194,19 +1215,16 @@ def connect(self): "Connect to a host on a given (SSL) port." - sock = socket.create_connection((self.host, self.port), - self.timeout, self.source_address) + super().connect() - if self._tunnel_host: - self.sock = sock - self._tunnel() + server_hostname = self._tunnel_host[-1] if self._tunnel_host else self.host + sni_hostname = server_hostname if ssl.HAS_SNI else None - server_hostname = self.host if ssl.HAS_SNI else None - self.sock = self._context.wrap_socket(sock, - server_hostname=server_hostname) + self.sock = self._context.wrap_socket(self.sock, + server_hostname=sni_hostname) if not self._context.check_hostname and self._check_hostname: try: - ssl.match_hostname(self.sock.getpeercert(), self.host) + ssl.match_hostname(self.sock.getpeercert(), server_hostname) except Exception: self.sock.shutdown(socket.SHUT_RDWR) self.sock.close() diff --git a/Lib/test/test_httplib.py b/Lib/test/test_httplib.py --- a/Lib/test/test_httplib.py +++ b/Lib/test/test_httplib.py @@ -21,14 +21,16 @@ HOST = support.HOST class FakeSocket: - def __init__(self, text, fileclass=io.BytesIO): + def __init__(self, text, fileclass=io.BytesIO, host=None, port=None): if isinstance(text, str): text = text.encode("ascii") self.text = text self.fileclass = fileclass self.data = b'' self.sendall_calls = 0 - + self.host = host + self.port = port + def sendall(self, data): self.sendall_calls += 1 self.data += data @@ -38,6 +40,9 @@ raise client.UnimplementedFileMode() return self.fileclass(self.text) + def close(self): + pass + class EPipeSocket(FakeSocket): def __init__(self, text, pipe_trigger): @@ -970,10 +975,45 @@ header = self.resp.getheader('No-Such-Header',default=42) self.assertEqual(header, 42) +class TunnelTests(TestCase): + + def test_connect(self): + response_text = ( + 'HTTP/1.0 200 OK\r\n\r\n' # Reply to CONNECT + 'HTTP/1.1 200 OK\r\n' # Reply to HEAD + 'Content-Length: 42\r\n\r\n' + ) + + def create_connection(address, timeout=None, source_address=None): + return FakeSocket(response_text, host=address[0], + port=address[1]) + + conn = client.HTTPConnection('proxy.com') + conn._create_connection = create_connection + conn.set_tunnel('destination.com') + conn.request('HEAD', '/', '') + + self.assertEqual(conn.sock.host, 'proxy.com') + self.assertEqual(conn.sock.port, 80) + self.assertTrue(b'CONNECT destination.com' in conn.sock.data) + self.assertTrue(b'Host: destination.com' in conn.sock.data) + + # This test should be removed if CONNECT ever gets + # HTTP/1.1 blessing + self.assertTrue(b'Host: proxy.com' not in conn.sock.data) + + conn.close() + conn.request('PUT', '/', '') + self.assertEqual(conn.sock.host, 'proxy.com') + self.assertEqual(conn.sock.port, 80) + self.assertTrue(b'CONNECT destination.com' in conn.sock.data) + self.assertTrue(b'Host: destination.com' in conn.sock.data) + + def test_main(verbose=None): support.run_unittest(HeaderTests, OfflineTest, BasicTest, TimeoutTest, HTTPSTest, RequestBodyTest, SourceAddressTest, - HTTPResponseTest) + HTTPResponseTest, TunnelTests) if __name__ == '__main__': test_main() diff --git a/Misc/NEWS b/Misc/NEWS --- a/Misc/NEWS +++ b/Misc/NEWS @@ -74,6 +74,9 @@ - Issue #14455: Fix some problems with the new binary plist support in plistlib. +- Issue #7776: Fix ``Host:'' header and reconnection when using + http.client.HTTPConnection.set_tunnel(). Patch by Nikolaus Rath. + IDLE ----