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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy