Skip to content

Commit 87feb69

Browse files
committed
Apply connect timeout to SOCKS5 handshakes
1 parent 10a6582 commit 87feb69

4 files changed

Lines changed: 162 additions & 12 deletions

File tree

httpcore/_async/socks_proxy.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ async def _init_socks5_connection(
4545
host: bytes,
4646
port: int,
4747
auth: tuple[bytes, bytes] | None = None,
48+
timeout: float | None = None,
4849
) -> None:
4950
conn = socksio.socks5.SOCKS5Connection()
5051

@@ -56,10 +57,10 @@ async def _init_socks5_connection(
5657
)
5758
conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method]))
5859
outgoing_bytes = conn.data_to_send()
59-
await stream.write(outgoing_bytes)
60+
await stream.write(outgoing_bytes, timeout=timeout)
6061

6162
# Auth method response
62-
incoming_bytes = await stream.read(max_bytes=4096)
63+
incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout)
6364
response = conn.receive_data(incoming_bytes)
6465
assert isinstance(response, socksio.socks5.SOCKS5AuthReply)
6566
if response.method != auth_method:
@@ -75,10 +76,10 @@ async def _init_socks5_connection(
7576
username, password = auth
7677
conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password))
7778
outgoing_bytes = conn.data_to_send()
78-
await stream.write(outgoing_bytes)
79+
await stream.write(outgoing_bytes, timeout=timeout)
7980

8081
# Username/password response
81-
incoming_bytes = await stream.read(max_bytes=4096)
82+
incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout)
8283
response = conn.receive_data(incoming_bytes)
8384
assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply)
8485
if not response.success:
@@ -91,10 +92,10 @@ async def _init_socks5_connection(
9192
)
9293
)
9394
outgoing_bytes = conn.data_to_send()
94-
await stream.write(outgoing_bytes)
95+
await stream.write(outgoing_bytes, timeout=timeout)
9596

9697
# Connect response
97-
incoming_bytes = await stream.read(max_bytes=4096)
98+
incoming_bytes = await stream.read(max_bytes=4096, timeout=timeout)
9899
response = conn.receive_data(incoming_bytes)
99100
assert isinstance(response, socksio.socks5.SOCKS5Reply)
100101
if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED:
@@ -237,6 +238,7 @@ async def handle_async_request(self, request: Request) -> Response:
237238
"host": self._remote_origin.host.decode("ascii"),
238239
"port": self._remote_origin.port,
239240
"auth": self._proxy_auth,
241+
"timeout": timeout,
240242
}
241243
async with Trace(
242244
"setup_socks5_connection", logger, request, kwargs

httpcore/_sync/socks_proxy.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def _init_socks5_connection(
4545
host: bytes,
4646
port: int,
4747
auth: tuple[bytes, bytes] | None = None,
48+
timeout: float | None = None,
4849
) -> None:
4950
conn = socksio.socks5.SOCKS5Connection()
5051

@@ -56,10 +57,10 @@ def _init_socks5_connection(
5657
)
5758
conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method]))
5859
outgoing_bytes = conn.data_to_send()
59-
stream.write(outgoing_bytes)
60+
stream.write(outgoing_bytes, timeout=timeout)
6061

6162
# Auth method response
62-
incoming_bytes = stream.read(max_bytes=4096)
63+
incoming_bytes = stream.read(max_bytes=4096, timeout=timeout)
6364
response = conn.receive_data(incoming_bytes)
6465
assert isinstance(response, socksio.socks5.SOCKS5AuthReply)
6566
if response.method != auth_method:
@@ -75,10 +76,10 @@ def _init_socks5_connection(
7576
username, password = auth
7677
conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password))
7778
outgoing_bytes = conn.data_to_send()
78-
stream.write(outgoing_bytes)
79+
stream.write(outgoing_bytes, timeout=timeout)
7980

8081
# Username/password response
81-
incoming_bytes = stream.read(max_bytes=4096)
82+
incoming_bytes = stream.read(max_bytes=4096, timeout=timeout)
8283
response = conn.receive_data(incoming_bytes)
8384
assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply)
8485
if not response.success:
@@ -91,10 +92,10 @@ def _init_socks5_connection(
9192
)
9293
)
9394
outgoing_bytes = conn.data_to_send()
94-
stream.write(outgoing_bytes)
95+
stream.write(outgoing_bytes, timeout=timeout)
9596

9697
# Connect response
97-
incoming_bytes = stream.read(max_bytes=4096)
98+
incoming_bytes = stream.read(max_bytes=4096, timeout=timeout)
9899
response = conn.receive_data(incoming_bytes)
99100
assert isinstance(response, socksio.socks5.SOCKS5Reply)
100101
if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED:
@@ -237,6 +238,7 @@ def handle_request(self, request: Request) -> Response:
237238
"host": self._remote_origin.host.decode("ascii"),
238239
"port": self._remote_origin.port,
239240
"auth": self._proxy_auth,
241+
"timeout": timeout,
240242
}
241243
with Trace(
242244
"setup_socks5_connection", logger, request, kwargs

tests/_async/test_socks_proxy.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
1+
import typing
2+
13
import pytest
24

35
import httpcore
46

57

8+
class AsyncRecordingTimeoutStream(httpcore.AsyncMockStream):
9+
def __init__(self, buffer: typing.List[bytes]) -> None:
10+
super().__init__(buffer)
11+
self.read_timeouts: typing.List[typing.Optional[float]] = []
12+
self.write_timeouts: typing.List[typing.Optional[float]] = []
13+
14+
async def read(
15+
self, max_bytes: int, timeout: typing.Optional[float] = None
16+
) -> bytes:
17+
self.read_timeouts.append(timeout)
18+
return await super().read(max_bytes, timeout)
19+
20+
async def write(
21+
self, buffer: bytes, timeout: typing.Optional[float] = None
22+
) -> None:
23+
self.write_timeouts.append(timeout)
24+
return await super().write(buffer, timeout)
25+
26+
27+
class AsyncRecordingTimeoutBackend(httpcore.AsyncMockBackend):
28+
def __init__(self, buffer: typing.List[bytes]) -> None:
29+
super().__init__(buffer)
30+
self.stream = AsyncRecordingTimeoutStream(buffer)
31+
32+
async def connect_tcp(
33+
self, *args: typing.Any, **kwargs: typing.Any
34+
) -> httpcore.AsyncNetworkStream:
35+
return self.stream
36+
37+
638
@pytest.mark.anyio
739
async def test_socks5_request():
840
"""
@@ -61,6 +93,47 @@ async def test_socks5_request():
6193
)
6294

6395

96+
@pytest.mark.anyio
97+
async def test_socks5_request_uses_connect_timeout_for_handshake():
98+
"""
99+
Apply the connect timeout to all SOCKS handshake I/O.
100+
"""
101+
network_backend = AsyncRecordingTimeoutBackend(
102+
[
103+
# The initial socks CONNECT
104+
# v5 USERNAME/PASSWORD
105+
b"\x05\x02",
106+
# v1 VALID USERNAME/PASSWORD
107+
b"\x01\x00",
108+
# v5 SUC RSV IP4 127 .0 .0 .1 :80
109+
b"\x05\x00\x00\x01\xff\x00\x00\x01\x00\x50",
110+
# The actual response from the remote server
111+
b"HTTP/1.1 200 OK\r\n",
112+
b"Content-Type: plain/text\r\n",
113+
b"Content-Length: 13\r\n",
114+
b"\r\n",
115+
b"Hello, world!",
116+
]
117+
)
118+
119+
async with httpcore.AsyncConnectionPool(
120+
proxy=httpcore.Proxy(
121+
url="socks5://localhost:8080/",
122+
auth=(b"username", b"password"),
123+
),
124+
network_backend=network_backend,
125+
) as proxy:
126+
response = await proxy.request(
127+
"GET",
128+
"https://example.com/",
129+
extensions={"timeout": {"connect": 5.0}},
130+
)
131+
132+
assert response.status == 200
133+
assert network_backend.stream.write_timeouts[:3] == [5.0, 5.0, 5.0]
134+
assert network_backend.stream.read_timeouts[:3] == [5.0, 5.0, 5.0]
135+
136+
64137
@pytest.mark.anyio
65138
async def test_authenticated_socks5_request():
66139
"""

tests/_sync/test_socks_proxy.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
1+
import typing
2+
13
import pytest
24

35
import httpcore
46

57

8+
class RecordingTimeoutStream(httpcore.MockStream):
9+
def __init__(self, buffer: typing.List[bytes]) -> None:
10+
super().__init__(buffer)
11+
self.read_timeouts: typing.List[typing.Optional[float]] = []
12+
self.write_timeouts: typing.List[typing.Optional[float]] = []
13+
14+
def read(
15+
self, max_bytes: int, timeout: typing.Optional[float] = None
16+
) -> bytes:
17+
self.read_timeouts.append(timeout)
18+
return super().read(max_bytes, timeout)
19+
20+
def write(
21+
self, buffer: bytes, timeout: typing.Optional[float] = None
22+
) -> None:
23+
self.write_timeouts.append(timeout)
24+
return super().write(buffer, timeout)
25+
26+
27+
class RecordingTimeoutBackend(httpcore.MockBackend):
28+
def __init__(self, buffer: typing.List[bytes]) -> None:
29+
super().__init__(buffer)
30+
self.stream = RecordingTimeoutStream(buffer)
31+
32+
def connect_tcp(
33+
self, *args: typing.Any, **kwargs: typing.Any
34+
) -> httpcore.NetworkStream:
35+
return self.stream
36+
37+
638

739
def test_socks5_request():
840
"""
@@ -62,6 +94,47 @@ def test_socks5_request():
6294

6395

6496

97+
def test_socks5_request_uses_connect_timeout_for_handshake():
98+
"""
99+
Apply the connect timeout to all SOCKS handshake I/O.
100+
"""
101+
network_backend = RecordingTimeoutBackend(
102+
[
103+
# The initial socks CONNECT
104+
# v5 USERNAME/PASSWORD
105+
b"\x05\x02",
106+
# v1 VALID USERNAME/PASSWORD
107+
b"\x01\x00",
108+
# v5 SUC RSV IP4 127 .0 .0 .1 :80
109+
b"\x05\x00\x00\x01\xff\x00\x00\x01\x00\x50",
110+
# The actual response from the remote server
111+
b"HTTP/1.1 200 OK\r\n",
112+
b"Content-Type: plain/text\r\n",
113+
b"Content-Length: 13\r\n",
114+
b"\r\n",
115+
b"Hello, world!",
116+
]
117+
)
118+
119+
with httpcore.ConnectionPool(
120+
proxy=httpcore.Proxy(
121+
url="socks5://localhost:8080/",
122+
auth=(b"username", b"password"),
123+
),
124+
network_backend=network_backend,
125+
) as proxy:
126+
response = proxy.request(
127+
"GET",
128+
"https://example.com/",
129+
extensions={"timeout": {"connect": 5.0}},
130+
)
131+
132+
assert response.status == 200
133+
assert network_backend.stream.write_timeouts[:3] == [5.0, 5.0, 5.0]
134+
assert network_backend.stream.read_timeouts[:3] == [5.0, 5.0, 5.0]
135+
136+
137+
65138
def test_authenticated_socks5_request():
66139
"""
67140
Send an HTTP request via a SOCKS proxy.

0 commit comments

Comments
 (0)