Skip to content

Commit ce66166

Browse files
Add proxy_auth argument to HTTPProxy (#481)
* Add proxy_auth argument to HTTPProxy * Add proxy_auth argument to HTTPProxy
1 parent 2e45d07 commit ce66166

4 files changed

Lines changed: 118 additions & 8 deletions

File tree

httpcore/_async/http_proxy.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import ssl
2+
from base64 import b64encode
23
from typing import List, Mapping, Optional, Sequence, Tuple, Union
34

45
from .._exceptions import ProxyError
5-
from .._models import URL, Origin, Request, Response, enforce_headers, enforce_url
6+
from .._models import (
7+
URL,
8+
Origin,
9+
Request,
10+
Response,
11+
enforce_bytes,
12+
enforce_headers,
13+
enforce_url,
14+
)
615
from .._ssl import default_ssl_context
716
from .._synchronization import AsyncLock
817
from .._trace import Trace
@@ -35,6 +44,11 @@ def merge_headers(
3544
return default_headers + override_headers
3645

3746

47+
def build_auth_header(username: bytes, password: bytes) -> bytes:
48+
userpass = username + b":" + password
49+
return b"Basic " + b64encode(userpass)
50+
51+
3852
class AsyncHTTPProxy(AsyncConnectionPool):
3953
"""
4054
A connection pool that sends requests via an HTTP proxy.
@@ -43,6 +57,7 @@ class AsyncHTTPProxy(AsyncConnectionPool):
4357
def __init__(
4458
self,
4559
proxy_url: Union[URL, bytes, str],
60+
proxy_auth: Tuple[Union[bytes, str], Union[bytes, str]] = None,
4661
proxy_headers: Union[HeadersAsMapping, HeadersAsSequence] = None,
4762
ssl_context: ssl.SSLContext = None,
4863
max_connections: Optional[int] = 10,
@@ -61,6 +76,8 @@ def __init__(
6176
Parameters:
6277
proxy_url: The URL to use when connecting to the proxy server.
6378
For example `"http://127.0.0.1:8080/"`.
79+
proxy_auth: Any proxy authentication as a two-tuple of
80+
(username, password). May be either bytes or ascii-only str.
6481
proxy_headers: Any HTTP headers to use for the proxy requests.
6582
For example `{"Proxy-Authorization": "Basic <username>:<password>"}`.
6683
ssl_context: An SSL context to use for verifying connections.
@@ -102,6 +119,13 @@ def __init__(
102119
self._ssl_context = ssl_context
103120
self._proxy_url = enforce_url(proxy_url, name="proxy_url")
104121
self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers")
122+
if proxy_auth is not None:
123+
username = enforce_bytes(proxy_auth[0], name="proxy_auth")
124+
password = enforce_bytes(proxy_auth[1], name="proxy_auth")
125+
authorization = build_auth_header(username, password)
126+
self._proxy_headers = [
127+
(b"Proxy-Authorization", authorization)
128+
] + self._proxy_headers
105129

106130
def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
107131
if origin.scheme == b"http":

httpcore/_sync/http_proxy.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
11
import ssl
2+
from base64 import b64encode
23
from typing import List, Mapping, Optional, Sequence, Tuple, Union
34

45
from .._exceptions import ProxyError
5-
from .._models import URL, Origin, Request, Response, enforce_headers, enforce_url
6+
from .._models import (
7+
URL,
8+
Origin,
9+
Request,
10+
Response,
11+
enforce_bytes,
12+
enforce_headers,
13+
enforce_url,
14+
)
615
from .._ssl import default_ssl_context
716
from .._synchronization import Lock
817
from .._trace import Trace
@@ -35,6 +44,11 @@ def merge_headers(
3544
return default_headers + override_headers
3645

3746

47+
def build_auth_header(username: bytes, password: bytes) -> bytes:
48+
userpass = username + b":" + password
49+
return b"Basic " + b64encode(userpass)
50+
51+
3852
class HTTPProxy(ConnectionPool):
3953
"""
4054
A connection pool that sends requests via an HTTP proxy.
@@ -43,6 +57,7 @@ class HTTPProxy(ConnectionPool):
4357
def __init__(
4458
self,
4559
proxy_url: Union[URL, bytes, str],
60+
proxy_auth: Tuple[Union[bytes, str], Union[bytes, str]] = None,
4661
proxy_headers: Union[HeadersAsMapping, HeadersAsSequence] = None,
4762
ssl_context: ssl.SSLContext = None,
4863
max_connections: Optional[int] = 10,
@@ -61,6 +76,8 @@ def __init__(
6176
Parameters:
6277
proxy_url: The URL to use when connecting to the proxy server.
6378
For example `"http://127.0.0.1:8080/"`.
79+
proxy_auth: Any proxy authentication as a two-tuple of
80+
(username, password). May be either bytes or ascii-only str.
6481
proxy_headers: Any HTTP headers to use for the proxy requests.
6582
For example `{"Proxy-Authorization": "Basic <username>:<password>"}`.
6683
ssl_context: An SSL context to use for verifying connections.
@@ -102,6 +119,13 @@ def __init__(
102119
self._ssl_context = ssl_context
103120
self._proxy_url = enforce_url(proxy_url, name="proxy_url")
104121
self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers")
122+
if proxy_auth is not None:
123+
username = enforce_bytes(proxy_auth[0], name="proxy_auth")
124+
password = enforce_bytes(proxy_auth[1], name="proxy_auth")
125+
authorization = build_auth_header(username, password)
126+
self._proxy_headers = [
127+
(b"Proxy-Authorization", authorization)
128+
] + self._proxy_headers
105129

106130
def create_connection(self, origin: Origin) -> ConnectionInterface:
107131
if origin.scheme == b"http":

tests/_async/test_http_proxy.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ async def test_proxy_tunneling():
8282

8383
async with AsyncHTTPProxy(
8484
proxy_url="http://localhost:8080/",
85-
max_connections=10,
8685
network_backend=network_backend,
8786
) as proxy:
8887
# Sending an intial request, which once complete will return to the pool, IDLE.
@@ -169,7 +168,6 @@ async def test_proxy_tunneling_http2():
169168

170169
async with AsyncHTTPProxy(
171170
proxy_url="http://localhost:8080/",
172-
max_connections=10,
173171
network_backend=network_backend,
174172
http2=True,
175173
) as proxy:
@@ -219,10 +217,43 @@ async def test_proxy_tunneling_with_403():
219217

220218
async with AsyncHTTPProxy(
221219
proxy_url="http://localhost:8080/",
222-
max_connections=10,
223220
network_backend=network_backend,
224221
) as proxy:
225222
with pytest.raises(ProxyError) as exc_info:
226223
await proxy.request("GET", "https://example.com/")
227224
assert str(exc_info.value) == "403 Permission Denied"
228225
assert not proxy.connections
226+
227+
228+
@pytest.mark.anyio
229+
async def test_proxy_tunneling_with_auth():
230+
"""
231+
Send an authenticated HTTPS request via a proxy.
232+
"""
233+
network_backend = AsyncMockBackend(
234+
[
235+
# The initial response to the proxy CONNECT
236+
b"HTTP/1.1 200 OK\r\n\r\n",
237+
# The actual response from the remote server
238+
b"HTTP/1.1 200 OK\r\n",
239+
b"Content-Type: plain/text\r\n",
240+
b"Content-Length: 13\r\n",
241+
b"\r\n",
242+
b"Hello, world!",
243+
]
244+
)
245+
246+
async with AsyncHTTPProxy(
247+
proxy_url="http://localhost:8080/",
248+
proxy_auth=("username", "password"),
249+
network_backend=network_backend,
250+
) as proxy:
251+
response = await proxy.request("GET", "https://example.com/")
252+
assert response.status == 200
253+
assert response.content == b"Hello, world!"
254+
255+
# Dig into this private property as a cheap lazy way of
256+
# checking that the proxy header is set correctly.
257+
assert proxy._proxy_headers == [ # type: ignore
258+
(b"Proxy-Authorization", b"Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
259+
]

tests/_sync/test_http_proxy.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ def test_proxy_tunneling():
8282

8383
with HTTPProxy(
8484
proxy_url="http://localhost:8080/",
85-
max_connections=10,
8685
network_backend=network_backend,
8786
) as proxy:
8887
# Sending an intial request, which once complete will return to the pool, IDLE.
@@ -169,7 +168,6 @@ def test_proxy_tunneling_http2():
169168

170169
with HTTPProxy(
171170
proxy_url="http://localhost:8080/",
172-
max_connections=10,
173171
network_backend=network_backend,
174172
http2=True,
175173
) as proxy:
@@ -219,10 +217,43 @@ def test_proxy_tunneling_with_403():
219217

220218
with HTTPProxy(
221219
proxy_url="http://localhost:8080/",
222-
max_connections=10,
223220
network_backend=network_backend,
224221
) as proxy:
225222
with pytest.raises(ProxyError) as exc_info:
226223
proxy.request("GET", "https://example.com/")
227224
assert str(exc_info.value) == "403 Permission Denied"
228225
assert not proxy.connections
226+
227+
228+
229+
def test_proxy_tunneling_with_auth():
230+
"""
231+
Send an authenticated HTTPS request via a proxy.
232+
"""
233+
network_backend = MockBackend(
234+
[
235+
# The initial response to the proxy CONNECT
236+
b"HTTP/1.1 200 OK\r\n\r\n",
237+
# The actual response from the remote server
238+
b"HTTP/1.1 200 OK\r\n",
239+
b"Content-Type: plain/text\r\n",
240+
b"Content-Length: 13\r\n",
241+
b"\r\n",
242+
b"Hello, world!",
243+
]
244+
)
245+
246+
with HTTPProxy(
247+
proxy_url="http://localhost:8080/",
248+
proxy_auth=("username", "password"),
249+
network_backend=network_backend,
250+
) as proxy:
251+
response = proxy.request("GET", "https://example.com/")
252+
assert response.status == 200
253+
assert response.content == b"Hello, world!"
254+
255+
# Dig into this private property as a cheap lazy way of
256+
# checking that the proxy header is set correctly.
257+
assert proxy._proxy_headers == [ # type: ignore
258+
(b"Proxy-Authorization", b"Basic dXNlcm5hbWU6cGFzc3dvcmQ=")
259+
]

0 commit comments

Comments
 (0)