Skip to content
This repository was archived by the owner on Apr 12, 2024. It is now read-only.

Commit 0060eb3

Browse files
Port "Allow providing credentials to HTTPS_PROXY (#9657)" from mainline (#95)
* Allow providing credentials to HTTPS_PROXY (#9657) Addresses #70 This PR causes `ProxyAgent` to attempt to extract credentials from an `HTTPS_PROXY` env var. If credentials are found, a `Proxy-Authorization` header ([details](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Proxy-Authorization)) is sent to the proxy server to authenticate against it. The headers are *not* passed to the remote server. Also added some type hints. * lint
1 parent 0da5273 commit 0060eb3

File tree

4 files changed

+183
-34
lines changed

4 files changed

+183
-34
lines changed

changelog.d/9657.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add support for credentials for proxy authentication in the `HTTPS_PROXY` environment variable.

synapse/http/connectproxyclient.py

Lines changed: 70 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919

2020
from twisted.internet import defer, protocol
2121
from twisted.internet.error import ConnectError
22-
from twisted.internet.interfaces import IStreamClientEndpoint
23-
from twisted.internet.protocol import connectionDone
22+
from twisted.internet.interfaces import IReactorCore, IStreamClientEndpoint
23+
from twisted.internet.protocol import ClientFactory, Protocol, connectionDone
2424
from twisted.web import http
25+
from twisted.web.http_headers import Headers
2526

2627
logger = logging.getLogger(__name__)
2728

@@ -43,23 +44,33 @@ class HTTPConnectProxyEndpoint:
4344
4445
Args:
4546
reactor: the Twisted reactor to use for the connection
46-
proxy_endpoint (IStreamClientEndpoint): the endpoint to use to connect to the
47-
proxy
48-
host (bytes): hostname that we want to CONNECT to
49-
port (int): port that we want to connect to
47+
proxy_endpoint: the endpoint to use to connect to the proxy
48+
host: hostname that we want to CONNECT to
49+
port: port that we want to connect to
50+
headers: Extra HTTP headers to include in the CONNECT request
5051
"""
5152

52-
def __init__(self, reactor, proxy_endpoint, host, port):
53+
def __init__(
54+
self,
55+
reactor: IReactorCore,
56+
proxy_endpoint: IStreamClientEndpoint,
57+
host: bytes,
58+
port: int,
59+
headers: Headers,
60+
):
5361
self._reactor = reactor
5462
self._proxy_endpoint = proxy_endpoint
5563
self._host = host
5664
self._port = port
65+
self._headers = headers
5766

5867
def __repr__(self):
5968
return "<HTTPConnectProxyEndpoint %s>" % (self._proxy_endpoint,)
6069

61-
def connect(self, protocolFactory):
62-
f = HTTPProxiedClientFactory(self._host, self._port, protocolFactory)
70+
def connect(self, protocolFactory: ClientFactory):
71+
f = HTTPProxiedClientFactory(
72+
self._host, self._port, protocolFactory, self._headers
73+
)
6374
d = self._proxy_endpoint.connect(f)
6475
# once the tcp socket connects successfully, we need to wait for the
6576
# CONNECT to complete.
@@ -74,15 +85,23 @@ class HTTPProxiedClientFactory(protocol.ClientFactory):
7485
HTTP Protocol object and run the rest of the connection.
7586
7687
Args:
77-
dst_host (bytes): hostname that we want to CONNECT to
78-
dst_port (int): port that we want to connect to
79-
wrapped_factory (protocol.ClientFactory): The original Factory
88+
dst_host: hostname that we want to CONNECT to
89+
dst_port: port that we want to connect to
90+
wrapped_factory: The original Factory
91+
headers: Extra HTTP headers to include in the CONNECT request
8092
"""
8193

82-
def __init__(self, dst_host, dst_port, wrapped_factory):
94+
def __init__(
95+
self,
96+
dst_host: bytes,
97+
dst_port: int,
98+
wrapped_factory: ClientFactory,
99+
headers: Headers,
100+
):
83101
self.dst_host = dst_host
84102
self.dst_port = dst_port
85103
self.wrapped_factory = wrapped_factory
104+
self.headers = headers
86105
self.on_connection = defer.Deferred()
87106

88107
def startedConnecting(self, connector):
@@ -92,7 +111,11 @@ def buildProtocol(self, addr):
92111
wrapped_protocol = self.wrapped_factory.buildProtocol(addr)
93112

94113
return HTTPConnectProtocol(
95-
self.dst_host, self.dst_port, wrapped_protocol, self.on_connection
114+
self.dst_host,
115+
self.dst_port,
116+
wrapped_protocol,
117+
self.on_connection,
118+
self.headers,
96119
)
97120

98121
def clientConnectionFailed(self, connector, reason):
@@ -112,24 +135,37 @@ class HTTPConnectProtocol(protocol.Protocol):
112135
"""Protocol that wraps an existing Protocol to do a CONNECT handshake at connect
113136
114137
Args:
115-
host (bytes): The original HTTP(s) hostname or IPv4 or IPv6 address literal
138+
host: The original HTTP(s) hostname or IPv4 or IPv6 address literal
116139
to put in the CONNECT request
117140
118-
port (int): The original HTTP(s) port to put in the CONNECT request
141+
port: The original HTTP(s) port to put in the CONNECT request
119142
120-
wrapped_protocol (interfaces.IProtocol): the original protocol (probably
121-
HTTPChannel or TLSMemoryBIOProtocol, but could be anything really)
143+
wrapped_protocol: the original protocol (probably HTTPChannel or
144+
TLSMemoryBIOProtocol, but could be anything really)
122145
123-
connected_deferred (Deferred): a Deferred which will be callbacked with
146+
connected_deferred: a Deferred which will be callbacked with
124147
wrapped_protocol when the CONNECT completes
148+
149+
headers: Extra HTTP headers to include in the CONNECT request
125150
"""
126151

127-
def __init__(self, host, port, wrapped_protocol, connected_deferred):
152+
def __init__(
153+
self,
154+
host: bytes,
155+
port: int,
156+
wrapped_protocol: Protocol,
157+
connected_deferred: defer.Deferred,
158+
headers: Headers,
159+
):
128160
self.host = host
129161
self.port = port
130162
self.wrapped_protocol = wrapped_protocol
131163
self.connected_deferred = connected_deferred
132-
self.http_setup_client = HTTPConnectSetupClient(self.host, self.port)
164+
self.headers = headers
165+
166+
self.http_setup_client = HTTPConnectSetupClient(
167+
self.host, self.port, self.headers
168+
)
133169
self.http_setup_client.on_connected.addCallback(self.proxyConnected)
134170

135171
def connectionMade(self):
@@ -154,7 +190,7 @@ def proxyConnected(self, _):
154190
if buf:
155191
self.wrapped_protocol.dataReceived(buf)
156192

157-
def dataReceived(self, data):
193+
def dataReceived(self, data: bytes):
158194
# if we've set up the HTTP protocol, we can send the data there
159195
if self.wrapped_protocol.connected:
160196
return self.wrapped_protocol.dataReceived(data)
@@ -168,21 +204,29 @@ class HTTPConnectSetupClient(http.HTTPClient):
168204
"""HTTPClient protocol to send a CONNECT message for proxies and read the response.
169205
170206
Args:
171-
host (bytes): The hostname to send in the CONNECT message
172-
port (int): The port to send in the CONNECT message
207+
host: The hostname to send in the CONNECT message
208+
port: The port to send in the CONNECT message
209+
headers: Extra headers to send with the CONNECT message
173210
"""
174211

175-
def __init__(self, host, port):
212+
def __init__(self, host: bytes, port: int, headers: Headers):
176213
self.host = host
177214
self.port = port
215+
self.headers = headers
178216
self.on_connected = defer.Deferred()
179217

180218
def connectionMade(self):
181219
logger.debug("Connected to proxy, sending CONNECT")
182220
self.sendCommand(b"CONNECT", b"%s:%d" % (self.host, self.port))
221+
222+
# Send any additional specified headers
223+
for name, values in self.headers.getAllRawHeaders():
224+
for value in values:
225+
self.sendHeader(name, value)
226+
183227
self.endHeaders()
184228

185-
def handleStatus(self, version, status, message):
229+
def handleStatus(self, version: bytes, status: bytes, message: bytes):
186230
logger.debug("Got Status: %s %s %s", status, message, version)
187231
if status != b"200":
188232
raise ProxyConnectError("Unexpected status on CONNECT: %s" % status)

synapse/http/proxyagent.py

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,21 @@
1212
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
15+
import base64
1516
import logging
1617
import re
18+
from typing import Optional, Tuple
1719
from urllib.request import getproxies_environment, proxy_bypass_environment
1820

21+
import attr
1922
from zope.interface import implementer
2023

2124
from twisted.internet import defer
2225
from twisted.internet.endpoints import HostnameEndpoint, wrapClientTLS
2326
from twisted.python.failure import Failure
2427
from twisted.web.client import URI, BrowserLikePolicyForHTTPS, _AgentBase
2528
from twisted.web.error import SchemeNotSupported
29+
from twisted.web.http_headers import Headers
2630
from twisted.web.iweb import IAgent
2731

2832
from synapse.http.connectproxyclient import HTTPConnectProxyEndpoint
@@ -32,6 +36,22 @@
3236
_VALID_URI = re.compile(br"\A[\x21-\x7e]+\Z")
3337

3438

39+
@attr.s
40+
class ProxyCredentials:
41+
username_password = attr.ib(type=bytes)
42+
43+
def as_proxy_authorization_value(self) -> bytes:
44+
"""
45+
Return the value for a Proxy-Authorization header (i.e. 'Basic abdef==').
46+
47+
Returns:
48+
A transformation of the authentication string the encoded value for
49+
a Proxy-Authorization header.
50+
"""
51+
# Encode as base64 and prepend the authorization type
52+
return b"Basic " + base64.encodebytes(self.username_password)
53+
54+
3555
@implementer(IAgent)
3656
class ProxyAgent(_AgentBase):
3757
"""An Agent implementation which will use an HTTP proxy if one was requested
@@ -96,6 +116,9 @@ def __init__(
96116
https_proxy = proxies["https"].encode() if "https" in proxies else None
97117
no_proxy = proxies["no"] if "no" in proxies else None
98118

119+
# Parse credentials from https proxy connection string if present
120+
self.https_proxy_creds, https_proxy = parse_username_password(https_proxy)
121+
99122
self.http_proxy_endpoint = _http_proxy_endpoint(
100123
http_proxy, self.proxy_reactor, **self._endpoint_kwargs
101124
)
@@ -174,11 +197,22 @@ def request(self, method, uri, headers=None, bodyProducer=None):
174197
and self.https_proxy_endpoint
175198
and not should_skip_proxy
176199
):
200+
connect_headers = Headers()
201+
202+
# Determine whether we need to set Proxy-Authorization headers
203+
if self.https_proxy_creds:
204+
# Set a Proxy-Authorization header
205+
connect_headers.addRawHeader(
206+
b"Proxy-Authorization",
207+
self.https_proxy_creds.as_proxy_authorization_value(),
208+
)
209+
177210
endpoint = HTTPConnectProxyEndpoint(
178211
self.proxy_reactor,
179212
self.https_proxy_endpoint,
180213
parsed_uri.host,
181214
parsed_uri.port,
215+
headers=connect_headers,
182216
)
183217
else:
184218
# not using a proxy
@@ -207,12 +241,16 @@ def request(self, method, uri, headers=None, bodyProducer=None):
207241
)
208242

209243

210-
def _http_proxy_endpoint(proxy, reactor, **kwargs):
244+
def _http_proxy_endpoint(proxy: Optional[bytes], reactor, **kwargs):
211245
"""Parses an http proxy setting and returns an endpoint for the proxy
212246
213247
Args:
214-
proxy (bytes|None): the proxy setting
248+
proxy: the proxy setting in the form: [<username>:<password>@]<host>[:<port>]
249+
Note that compared to other apps, this function currently lacks support
250+
for specifying a protocol schema (i.e. protocol://...).
251+
215252
reactor: reactor to be used to connect to the proxy
253+
216254
kwargs: other args to be passed to HostnameEndpoint
217255
218256
Returns:
@@ -222,16 +260,43 @@ def _http_proxy_endpoint(proxy, reactor, **kwargs):
222260
if proxy is None:
223261
return None
224262

225-
# currently we only support hostname:port. Some apps also support
226-
# protocol://<host>[:port], which allows a way of requiring a TLS connection to the
227-
# proxy.
228-
263+
# Parse the connection string
229264
host, port = parse_host_port(proxy, default_port=1080)
230265
return HostnameEndpoint(reactor, host, port, **kwargs)
231266

232267

233-
def parse_host_port(hostport, default_port=None):
234-
# could have sworn we had one of these somewhere else...
268+
def parse_username_password(proxy: bytes) -> Tuple[Optional[ProxyCredentials], bytes]:
269+
"""
270+
Parses the username and password from a proxy declaration e.g
271+
username:password@hostname:port.
272+
273+
Args:
274+
proxy: The proxy connection string.
275+
276+
Returns
277+
An instance of ProxyCredentials and the proxy connection string with any credentials
278+
stripped, i.e u:p@host:port -> host:port. If no credentials were found, the
279+
ProxyCredentials instance is replaced with None.
280+
"""
281+
if proxy and b"@" in proxy:
282+
# We use rsplit here as the password could contain an @ character
283+
credentials, proxy_without_credentials = proxy.rsplit(b"@", 1)
284+
return ProxyCredentials(credentials), proxy_without_credentials
285+
286+
return None, proxy
287+
288+
289+
def parse_host_port(hostport: bytes, default_port: int = None) -> Tuple[bytes, int]:
290+
"""
291+
Parse the hostname and port from a proxy connection byte string.
292+
293+
Args:
294+
hostport: The proxy connection string. Must be in the form 'host[:port]'.
295+
default_port: The default port to return if one is not found in `hostport`.
296+
297+
Returns:
298+
A tuple containing the hostname and port. Uses `default_port` if one was not found.
299+
"""
235300
if b":" in hostport:
236301
host, port = hostport.rsplit(b":", 1)
237302
try:

0 commit comments

Comments
 (0)