Skip to content

Commit d03fe32

Browse files
authored
Fix HTTP tunneling with IPv6 in older Python versions
1 parent 11661e9 commit d03fe32

File tree

4 files changed

+157
-39
lines changed

4 files changed

+157
-39
lines changed

.github/workflows/ci.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ jobs:
5858
os: ubuntu-24.04
5959
experimental: false
6060
nox-session: test_integration
61+
# Test with 3.12.2 for https://github.com/urllib3/urllib3/pull/3620 patch
62+
- python-version: "3.12.2"
63+
os: ubuntu-24.04
64+
experimental: false
65+
nox-session: test-3.12
6166
# pypy
6267
- python-version: "pypy-3.10"
6368
os: ubuntu-24.04

changelog/3615.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed incorrect `CONNECT` statement when using an IPv6 proxy with `connection_from_host`. Previously would not be wrapped in `[]`.

src/urllib3/connection.py

Lines changed: 86 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -232,45 +232,94 @@ def set_tunnel(
232232
super().set_tunnel(host, port=port, headers=headers)
233233
self._tunnel_scheme = scheme
234234

235-
if sys.version_info < (3, 11, 4):
236-
237-
def _tunnel(self) -> None:
238-
_MAXLINE = http.client._MAXLINE # type: ignore[attr-defined]
239-
connect = b"CONNECT %s:%d HTTP/1.0\r\n" % ( # type: ignore[str-format]
240-
self._tunnel_host.encode("ascii"), # type: ignore[union-attr]
241-
self._tunnel_port,
242-
)
243-
headers = [connect]
244-
for header, value in self._tunnel_headers.items(): # type: ignore[attr-defined]
245-
headers.append(f"{header}: {value}\r\n".encode("latin-1"))
246-
headers.append(b"\r\n")
247-
# Making a single send() call instead of one per line encourages
248-
# the host OS to use a more optimal packet size instead of
249-
# potentially emitting a series of small packets.
250-
self.send(b"".join(headers))
251-
del headers
252-
253-
response = self.response_class(self.sock, method=self._method) # type: ignore[attr-defined]
254-
try:
255-
(version, code, message) = response._read_status() # type: ignore[attr-defined]
256-
257-
if code != http.HTTPStatus.OK:
258-
self.close()
259-
raise OSError(f"Tunnel connection failed: {code} {message.strip()}")
260-
while True:
261-
line = response.fp.readline(_MAXLINE + 1)
262-
if len(line) > _MAXLINE:
263-
raise http.client.LineTooLong("header line")
264-
if not line:
265-
# for sites which EOF without sending a trailer
266-
break
267-
if line in (b"\r\n", b"\n", b""):
268-
break
235+
if sys.version_info < (3, 11, 9) or ((3, 12) <= sys.version_info < (3, 12, 3)):
236+
# Taken from python/cpython#100986 which was backported in 3.11.9 and 3.12.3.
237+
# When using connection_from_host, host will come without brackets.
238+
def _wrap_ipv6(self, ip: bytes) -> bytes:
239+
if b":" in ip and ip[0] != b"["[0]:
240+
return b"[" + ip + b"]"
241+
return ip
242+
243+
if sys.version_info < (3, 11, 9):
244+
# `_tunnel` copied from 3.11.13 backporting
245+
# https://github.com/python/cpython/commit/0d4026432591d43185568dd31cef6a034c4b9261
246+
# and https://github.com/python/cpython/commit/6fbc61070fda2ffb8889e77e3b24bca4249ab4d1
247+
def _tunnel(self) -> None:
248+
_MAXLINE = http.client._MAXLINE # type: ignore[attr-defined]
249+
connect = b"CONNECT %s:%d HTTP/1.0\r\n" % ( # type: ignore[str-format]
250+
self._wrap_ipv6(self._tunnel_host.encode("ascii")), # type: ignore[union-attr]
251+
self._tunnel_port,
252+
)
253+
headers = [connect]
254+
for header, value in self._tunnel_headers.items(): # type: ignore[attr-defined]
255+
headers.append(f"{header}: {value}\r\n".encode("latin-1"))
256+
headers.append(b"\r\n")
257+
# Making a single send() call instead of one per line encourages
258+
# the host OS to use a more optimal packet size instead of
259+
# potentially emitting a series of small packets.
260+
self.send(b"".join(headers))
261+
del headers
262+
263+
response = self.response_class(self.sock, method=self._method) # type: ignore[attr-defined]
264+
try:
265+
(version, code, message) = response._read_status() # type: ignore[attr-defined]
266+
267+
if code != http.HTTPStatus.OK:
268+
self.close()
269+
raise OSError(
270+
f"Tunnel connection failed: {code} {message.strip()}"
271+
)
272+
while True:
273+
line = response.fp.readline(_MAXLINE + 1)
274+
if len(line) > _MAXLINE:
275+
raise http.client.LineTooLong("header line")
276+
if not line:
277+
# for sites which EOF without sending a trailer
278+
break
279+
if line in (b"\r\n", b"\n", b""):
280+
break
281+
282+
if self.debuglevel > 0:
283+
print("header:", line.decode())
284+
finally:
285+
response.close()
286+
287+
elif (3, 12) <= sys.version_info < (3, 12, 3):
288+
# `_tunnel` copied from 3.12.11 backporting
289+
# https://github.com/python/cpython/commit/23aef575c7629abcd4aaf028ebd226fb41a4b3c8
290+
def _tunnel(self) -> None: # noqa: F811
291+
connect = b"CONNECT %s:%d HTTP/1.1\r\n" % ( # type: ignore[str-format]
292+
self._wrap_ipv6(self._tunnel_host.encode("idna")), # type: ignore[union-attr]
293+
self._tunnel_port,
294+
)
295+
headers = [connect]
296+
for header, value in self._tunnel_headers.items(): # type: ignore[attr-defined]
297+
headers.append(f"{header}: {value}\r\n".encode("latin-1"))
298+
headers.append(b"\r\n")
299+
# Making a single send() call instead of one per line encourages
300+
# the host OS to use a more optimal packet size instead of
301+
# potentially emitting a series of small packets.
302+
self.send(b"".join(headers))
303+
del headers
304+
305+
response = self.response_class(self.sock, method=self._method) # type: ignore[attr-defined]
306+
try:
307+
(version, code, message) = response._read_status() # type: ignore[attr-defined]
308+
309+
self._raw_proxy_headers = http.client._read_headers(response.fp) # type: ignore[attr-defined]
269310

270311
if self.debuglevel > 0:
271-
print("header:", line.decode())
272-
finally:
273-
response.close()
312+
for header in self._raw_proxy_headers:
313+
print("header:", header.decode())
314+
315+
if code != http.HTTPStatus.OK:
316+
self.close()
317+
raise OSError(
318+
f"Tunnel connection failed: {code} {message.strip()}"
319+
)
320+
321+
finally:
322+
response.close()
274323

275324
def connect(self) -> None:
276325
self.sock = self._new_conn()

test/with_dummyserver/test_socketlevel.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from test import LONG_TIMEOUT, SHORT_TIMEOUT, notWindows, resolvesLocalhostFQDN
2323
from threading import Event
2424
from unittest import mock
25+
from urllib.parse import urlparse
2526

2627
import pytest
2728
import trustme
@@ -1289,7 +1290,7 @@ def echo_socket_handler(listener: socket.socket) -> None:
12891290
r = conn.urlopen("GET", url, retries=0)
12901291
assert r.status == 200
12911292

1292-
def test_connect_ipv6_addr(self) -> None:
1293+
def test_connect_ipv6_addr_from_host(self) -> None:
12931294
ipv6_addr = "2001:4998:c:a06::2:4008"
12941295

12951296
def echo_socket_handler(listener: socket.socket) -> None:
@@ -1329,13 +1330,75 @@ def echo_socket_handler(listener: socket.socket) -> None:
13291330

13301331
with proxy_from_url(base_url, cert_reqs="NONE") as proxy:
13311332
url = f"https://[{ipv6_addr}]"
1333+
1334+
# Try with connection_from_host
1335+
parsed_request_url = urlparse(url)
1336+
1337+
conn = proxy.connection_from_host(
1338+
scheme=parsed_request_url.scheme.lower(),
1339+
host=parsed_request_url.hostname,
1340+
port=parsed_request_url.port,
1341+
)
1342+
try:
1343+
with pytest.warns(InsecureRequestWarning):
1344+
r = conn.urlopen("GET", url, retries=0)
1345+
assert r.status == 200
1346+
except MaxRetryError:
1347+
pytest.fail(
1348+
"Invalid IPv6 format in HTTP CONNECT request when using connection_from_host"
1349+
)
1350+
1351+
def test_connect_ipv6_addr_from_url(self) -> None:
1352+
ipv6_addr = "2001:4998:c:a06::2:4008"
1353+
1354+
def echo_socket_handler(listener: socket.socket) -> None:
1355+
sock = listener.accept()[0]
1356+
1357+
buf = b""
1358+
while not buf.endswith(b"\r\n\r\n"):
1359+
buf += sock.recv(65536)
1360+
s = buf.decode("utf-8")
1361+
1362+
if s.startswith(f"CONNECT [{ipv6_addr}]:443"):
1363+
sock.send(b"HTTP/1.1 200 Connection Established\r\n\r\n")
1364+
ssl_sock = original_ssl_wrap_socket(
1365+
sock,
1366+
server_side=True,
1367+
keyfile=DEFAULT_CERTS["keyfile"],
1368+
certfile=DEFAULT_CERTS["certfile"],
1369+
)
1370+
buf = b""
1371+
while not buf.endswith(b"\r\n\r\n"):
1372+
buf += ssl_sock.recv(65536)
1373+
1374+
ssl_sock.send(
1375+
b"HTTP/1.1 200 OK\r\n"
1376+
b"Content-Type: text/plain\r\n"
1377+
b"Content-Length: 2\r\n"
1378+
b"Connection: close\r\n"
1379+
b"\r\n"
1380+
b"Hi"
1381+
)
1382+
ssl_sock.close()
1383+
else:
1384+
sock.close()
1385+
1386+
self._start_server(echo_socket_handler)
1387+
base_url = f"http://{self.host}:{self.port}"
1388+
1389+
with proxy_from_url(base_url, cert_reqs="NONE") as proxy:
1390+
url = f"https://[{ipv6_addr}]"
1391+
1392+
# Try with connection_from_url
13321393
conn = proxy.connection_from_url(url)
13331394
try:
13341395
with pytest.warns(InsecureRequestWarning):
13351396
r = conn.urlopen("GET", url, retries=0)
13361397
assert r.status == 200
13371398
except MaxRetryError:
1338-
pytest.fail("Invalid IPv6 format in HTTP CONNECT request")
1399+
pytest.fail(
1400+
"Invalid IPv6 format in HTTP CONNECT request when using connection_from_url"
1401+
)
13391402

13401403
@pytest.mark.parametrize("target_scheme", ["http", "https"])
13411404
def test_https_proxymanager_connected_to_http_proxy(

0 commit comments

Comments
 (0)