Skip to content

Commit 0e326b0

Browse files
committed
Apply connect timeout to SOCKS5 handshakes
1 parent 10a6582 commit 0e326b0

4 files changed

Lines changed: 155 additions & 15 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: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,35 @@
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(self, max_bytes: int, timeout: typing.Optional[float] = None) -> bytes:
15+
self.read_timeouts.append(timeout)
16+
return super().read(max_bytes, timeout)
17+
18+
def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None:
19+
self.write_timeouts.append(timeout)
20+
return super().write(buffer, timeout)
21+
22+
23+
class RecordingTimeoutBackend(httpcore.MockBackend):
24+
def __init__(self, buffer: typing.List[bytes]) -> None:
25+
super().__init__(buffer)
26+
self.stream = RecordingTimeoutStream(buffer)
27+
28+
def connect_tcp(
29+
self, *args: typing.Any, **kwargs: typing.Any
30+
) -> httpcore.NetworkStream:
31+
return self.stream
32+
633

734
def test_socks5_request():
835
"""
@@ -61,6 +88,45 @@ def test_socks5_request():
6188
)
6289

6390

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

65131
def test_authenticated_socks5_request():
66132
"""
@@ -110,7 +176,6 @@ def test_authenticated_socks5_request():
110176
assert not proxy.connections[0].is_closed()
111177

112178

113-
114179
def test_socks5_request_connect_failed():
115180
"""
116181
Attempt to send an HTTP request via a SOCKS proxy, resulting in a connect failure.
@@ -139,7 +204,6 @@ def test_socks5_request_connect_failed():
139204
assert not proxy.connections
140205

141206

142-
143207
def test_socks5_request_failed_to_provide_auth():
144208
"""
145209
Attempt to send an HTTP request via an authenticated SOCKS proxy,
@@ -167,7 +231,6 @@ def test_socks5_request_failed_to_provide_auth():
167231
assert not proxy.connections
168232

169233

170-
171234
def test_socks5_request_incorrect_auth():
172235
"""
173236
Attempt to send an HTTP request via an authenticated SOCKS proxy,

0 commit comments

Comments
 (0)