Repository: getsentry/sentry-python

Issue:
Support for Proxy Authentication
Hi there! Thanks for all you do with Sentry -- it adds incredible value to our team.

I have a question about support for HTTP proxy authentication in `sentry_sdk`.

**TL;DR:** Is proxy authentication supported? If not, can it be? Without support for proxy auth, we can't use Sentry from a significant portion of our services.

**More details:**

I see that we can pass a `https_proxy` kwarg to `sentry_sdk.init()`, such as:

```
sentry_sdk.init("https://...@foo.ingest.sentry.io/...", https_proxy="http://user:pass@proxy")
```

However, it looks like that proxy string eventually gets passed to `urllib3.ProxyManager()` here: https://github.com/getsentry/sentry-python/blob/0da369f839ee2c383659c91ea8858abcac04b869/sentry_sdk/transport.py#L293

Unfortunately, `urllib3.ProxyManager` does not appear to respect credentials passed in this string. The [documentation](https://urllib3.readthedocs.io/en/latest/reference/#urllib3.poolmanager.ProxyManager) isn't super clear on this, but it does allude to it in the description of the `proxy_headers` kwarg: "Could be used for proxy authentication." We verified with packet captures that `urllib3.ProxyManager` is not adding a `Proxy-Authorization` HTTP header to requests when the proxy string includes credentials.

Thanks so much for your consideration and help with this!


Discussion:
You can solve it by subclassing the default `HttpTransport`. This is not a pretty solution since it overrides a method prefixed by `_` and it requires us to parse the proxy url twice. The best thing would be if the maintainers gave some pointers on how they want to solve the problem. Still, this way seems to work.
```
"""
Transport subclass which enables proxy authentication
"""
import urllib3

from sentry_sdk.transport import getproxies
from sentry_sdk.transport import HttpTransport


def add_proxy_auth_header(opts: dict, proxy: str):
    """Adds Proxy-Authorization to opts"""
    parsed_proxy = urllib3.util.url.parse_url(proxy)
    if parsed_proxy.auth:
        opts.setdefault("proxy_headers", {})
        auth_header = urllib3.util.make_headers(proxy_basic_auth=parsed_proxy.auth)
        opts["proxy_headers"].update(auth_header)


class ProxyAuthHttpTransport(HttpTransport):
    def _make_pool(
        self,
        parsed_dsn,  # type: Dsn
        http_proxy,  # type: Optional[str]
        https_proxy,  # type: Optional[str]
        ca_certs,  # type: Optional[Any]
    ):  # type: (...) -> Union[PoolManager, ProxyManager]
        proxy = None
        no_proxy = self._in_no_proxy(parsed_dsn)

        # try HTTPS first
        if parsed_dsn.scheme == "https" and (https_proxy != ""):
            proxy = https_proxy or (not no_proxy and getproxies().get("https"))

        # maybe fallback to HTTP proxy
        if not proxy and (http_proxy != ""):
            proxy = http_proxy or (not no_proxy and getproxies().get("http"))

        opts = self._get_pool_options(ca_certs)

        if proxy:
            add_proxy_auth_header(opts, proxy)

        if proxy:
            return urllib3.ProxyManager(proxy, **opts)
        else:
            return urllib3.PoolManager(**opts)
```
This issue has gone three weeks without activity. In another week, I will close it.

But! If you comment or otherwise update it, I will reset the clock, and if you label it `Status: Backlog` or `Status: In Progress`, I will leave it alone ... forever!

----

"A weed is but an unloved flower." ― _Ella Wheeler Wilcox_ 🥀
I will put this in our internal low priority list. We will do some proper road map planning in the next couple of weeks and look at all the enhancements (also this one) and decide if and when we will do them. 
picking this up,
we will just add another top level config `proxy_headers` that will be passthroughed to `ProxyManager`

Resolving patch:
diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py
--- a/sentry_sdk/consts.py
+++ b/sentry_sdk/consts.py
@@ -81,6 +81,7 @@ def __init__(
         auto_session_tracking=True,  # type: bool
         send_client_reports=True,  # type: bool
         _experiments={},  # type: Experiments  # noqa: B006
+        proxy_headers=None,  # type: Optional[Dict[str, str]]
     ):
         # type: (...) -> None
         pass
diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py
--- a/sentry_sdk/transport.py
+++ b/sentry_sdk/transport.py
@@ -156,6 +156,7 @@ def __init__(
             http_proxy=options["http_proxy"],
             https_proxy=options["https_proxy"],
             ca_certs=options["ca_certs"],
+            proxy_headers=options["proxy_headers"],
         )
 
         from sentry_sdk import Hub
@@ -420,6 +421,7 @@ def _make_pool(
         http_proxy,  # type: Optional[str]
         https_proxy,  # type: Optional[str]
         ca_certs,  # type: Optional[Any]
+        proxy_headers,  # type: Optional[Dict[str, str]]
     ):
         # type: (...) -> Union[PoolManager, ProxyManager]
         proxy = None
@@ -436,6 +438,9 @@ def _make_pool(
         opts = self._get_pool_options(ca_certs)
 
         if proxy:
+            if proxy_headers:
+                opts["proxy_headers"] = proxy_headers
+
             return urllib3.ProxyManager(proxy, **opts)
         else:
             return urllib3.PoolManager(**opts)


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -227,6 +227,16 @@ def test_transport_option(monkeypatch):
             "arg_https_proxy": "https://localhost/123",
             "expected_proxy_scheme": "https",
         },
+        {
+            "dsn": "https://foo@sentry.io/123",
+            "env_http_proxy": None,
+            "env_https_proxy": None,
+            "env_no_proxy": "sentry.io,example.com",
+            "arg_http_proxy": None,
+            "arg_https_proxy": "https://localhost/123",
+            "expected_proxy_scheme": "https",
+            "arg_proxy_headers": {"Test-Header": "foo-bar"},
+        },
     ],
 )
 def test_proxy(monkeypatch, testcase):
@@ -241,12 +251,17 @@ def test_proxy(monkeypatch, testcase):
         kwargs["http_proxy"] = testcase["arg_http_proxy"]
     if testcase["arg_https_proxy"] is not None:
         kwargs["https_proxy"] = testcase["arg_https_proxy"]
+    if testcase.get("arg_proxy_headers") is not None:
+        kwargs["proxy_headers"] = testcase["arg_proxy_headers"]
     client = Client(testcase["dsn"], **kwargs)
     if testcase["expected_proxy_scheme"] is None:
         assert client.transport._pool.proxy is None
     else:
         assert client.transport._pool.proxy.scheme == testcase["expected_proxy_scheme"]
 
+        if testcase.get("arg_proxy_headers") is not None:
+            assert client.transport._pool.proxy_headers == testcase["arg_proxy_headers"]
+
 
 def test_simple_transport(sentry_init):
     events = []