Skip to content

Commit

Permalink
Wrap HTTPS/HTTP proxy mismatch error into ProxyError
Browse files Browse the repository at this point in the history
  • Loading branch information
sethmlarson committed Jan 6, 2022
1 parent 25d0454 commit 0435b0c
Show file tree
Hide file tree
Showing 4 changed files with 290 additions and 11 deletions.
72 changes: 72 additions & 0 deletions docs/advanced-usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,78 @@ an `absolute URI <https://tools.ietf.org/html/rfc7230#section-5.3.2>`_ if the
**only use this option with trusted or corporate proxies** as the proxy will have
full visibility of your requests.

.. _https_proxy_error_http_proxy:

Your proxy appears to only use HTTP and not HTTPS
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

If you're receiving the :class:`~urllib3.exceptions.ProxyError` and it mentions
your proxy only speaks HTTP and not HTTPS here's what to do to solve your issue:

If you're using ``urllib3`` directly, make sure the URL you're passing into :class:`urllib3.ProxyManager`
starts with ``http://`` instead of ``https://``:

.. code-block:: python
# Do this:
http = urllib3.ProxyManager("http://...")
# Not this:
http = urllib3.ProxyManager("https://...")
If instead you're using ``urllib3`` through another library like Requests
there are multiple ways your proxy could be mis-configured. You need to figure out
where the configuration isn't correct and make the fix there. Some common places
to look are environment variables like ``HTTP_PROXY``, ``HTTPS_PROXY``, and ``ALL_PROXY``.

Ensure that the values for all of these environment variables starts with ``http://``
and not ``https://``:

.. code-block:: bash
# Check your existing environment variables in bash
$ env | grep "_PROXY"
HTTP_PROXY=http://127.0.0.1:8888
HTTPS_PROXY=https://127.0.0.1:8888 # <--- This setting is the problem!
# Make the fix in your current session and test your script
$ export HTTPS_PROXY="http://127.0.0.1:8888"
$ python test-proxy.py # This should now pass.
# Persist your change in your shell 'profile' (~/.bashrc, ~/.profile, ~/.bash_profile, etc)
# You may need to logout and log back in to ensure this works across all programs.
$ vim ~/.bashrc
If you're on Windows or macOS your proxy may be getting set at a system level.
To check this first ensure that the above environment variables aren't set
then run the following:

.. code-block:: bash
$ python -c 'import urllib.request; print(urllib.request.getproxies())'
If the output of the above command isn't empty and looks like this:

.. code-block:: python
{
"http": "http://127.0.0.1:8888",
"https": "https://127.0.0.1:8888" # <--- This setting is the problem!
}
Search how to configure proxies on your operating system and change the ``https://...`` URL into ``http://``.
After you make the change the return value of ``urllib.request.getproxies()`` should be:

.. code-block:: python
{ # Everything is good here! :)
"http": "http://127.0.0.1:8888",
"https": "http://127.0.0.1:8888"
}
If you still can't figure out how to configure your proxy after all these steps
please `join our community Discord <https://discord.gg/urllib3>`_ and we'll try to help you with your issue.

SOCKS Proxies
~~~~~~~~~~~~~

Expand Down
29 changes: 28 additions & 1 deletion src/urllib3/connectionpool.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import errno
import logging
import re
import socket
import sys
import warnings
Expand Down Expand Up @@ -748,7 +749,33 @@ def urlopen(
# Discard the connection for these exceptions. It will be
# replaced during the next _get_conn() call.
clean_exit = False
if isinstance(e, (BaseSSLError, CertificateError)):

def _is_ssl_error_message_from_http_proxy(ssl_error):
# We're trying to detect the message 'WRONG_VERSION_NUMBER' but
# SSLErrors are kinda all over the place when it comes to the message,
# so we try to cover our bases here!
message = " ".join(re.split("[^a-z]", str(ssl_error).lower()))
return (
"wrong version number" in message or "unknown protocol" in message
)

# Try to detect a common user error with proxies which is to
# set an HTTP proxy to be HTTPS when it should be 'http://'
# (ie {'http': 'http://proxy', 'https': 'https://proxy'})
# Instead we add a nice error message and point to a URL.
if (
isinstance(e, BaseSSLError)
and self.proxy
and _is_ssl_error_message_from_http_proxy(e)
):
e = ProxyError(
"Your proxy appears to only use HTTP and not HTTPS, "
"try changing your proxy URL to be HTTP. See: "
"https://urllib3.readthedocs.io/en/1.26.x/advanced-usage.html"
"#https-proxy-error-http-proxy",
SSLError(e),
)
elif isinstance(e, (BaseSSLError, CertificateError)):
e = SSLError(e)
elif isinstance(e, (SocketError, NewConnectionError)) and self.proxy:
e = ProxyError("Cannot connect to proxy.", e)
Expand Down
170 changes: 161 additions & 9 deletions test/with_dummyserver/test_proxy_poolmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
import os.path
import shutil
import socket
import ssl
import sys
import tempfile
import warnings
from test import (
Expand All @@ -27,10 +29,12 @@
ProxyError,
ProxySchemeUnknown,
ProxySchemeUnsupported,
ReadTimeoutError,
SSLError,
SubjectAltNameWarning,
)
from urllib3.poolmanager import ProxyManager, proxy_from_url
from urllib3.util import Timeout
from urllib3.util.ssl_ import create_urllib3_context

from .. import TARPIT_HOST, requires_network
Expand Down Expand Up @@ -497,24 +501,160 @@ def test_proxy_pooling_ext(self):
assert sc2 != sc3
assert sc3 == sc4

@pytest.mark.timeout(0.5)
@requires_network
def test_https_proxy_timeout(self):
with proxy_from_url("https://{host}".format(host=TARPIT_HOST)) as https:
@pytest.mark.parametrize(
["proxy_scheme", "target_scheme", "use_forwarding_for_https"],
[
("http", "http", False),
("https", "http", False),
# 'use_forwarding_for_https' is only valid for HTTPS+HTTPS.
("https", "https", True),
],
)
def test_forwarding_proxy_request_timeout(
self, proxy_scheme, target_scheme, use_forwarding_for_https
):
_should_skip_https_in_https(
proxy_scheme, target_scheme, use_forwarding_for_https
)

proxy_url = self.https_proxy_url if proxy_scheme == "https" else self.proxy_url
target_url = "%s://%s" % (target_scheme, TARPIT_HOST)

with proxy_from_url(
proxy_url,
ca_certs=DEFAULT_CA,
use_forwarding_for_https=use_forwarding_for_https,
) as proxy:
with pytest.raises(MaxRetryError) as e:
timeout = Timeout(connect=LONG_TIMEOUT, read=SHORT_TIMEOUT)
proxy.request("GET", target_url, timeout=timeout)

# We sent the request to the proxy but didn't get any response
# so we're not sure if that's being caused by the proxy or the
# target so we put the blame on the target.
assert type(e.value.reason) == ReadTimeoutError

@requires_network
@pytest.mark.parametrize(
["proxy_scheme", "target_scheme"], [("http", "https"), ("https", "https")]
)
def test_tunneling_proxy_request_timeout(self, proxy_scheme, target_scheme):
_should_skip_https_in_https(proxy_scheme, target_scheme)

proxy_url = self.https_proxy_url if proxy_scheme == "https" else self.proxy_url
target_url = "%s://%s" % (target_scheme, TARPIT_HOST)

with proxy_from_url(
proxy_url,
ca_certs=DEFAULT_CA,
) as proxy:
with pytest.raises(MaxRetryError) as e:
timeout = Timeout(connect=LONG_TIMEOUT, read=SHORT_TIMEOUT)
proxy.request("GET", target_url, timeout=timeout)

assert type(e.value.reason) == ProxyError
assert type(e.value.reason.original_error) == socket.timeout

@requires_network
@pytest.mark.parametrize(
["proxy_scheme", "target_scheme", "use_forwarding_for_https"],
[
("http", "http", False),
("https", "http", False),
# 'use_forwarding_for_https' is only valid for HTTPS+HTTPS.
("https", "https", True),
],
)
def test_forwarding_proxy_connect_timeout(
self, proxy_scheme, target_scheme, use_forwarding_for_https
):
_should_skip_https_in_https(
proxy_scheme, target_scheme, use_forwarding_for_https
)

proxy_url = "%s://%s" % (proxy_scheme, TARPIT_HOST)
target_url = self.https_url if target_scheme == "https" else self.http_url

with proxy_from_url(
proxy_url,
ca_certs=DEFAULT_CA,
timeout=SHORT_TIMEOUT,
use_forwarding_for_https=use_forwarding_for_https,
) as proxy:
with pytest.raises(MaxRetryError) as e:
https.request("GET", self.http_url, timeout=SHORT_TIMEOUT)
proxy.request("GET", target_url)

assert type(e.value.reason) == ConnectTimeoutError

@pytest.mark.timeout(0.5)
@requires_network
def test_https_proxy_pool_timeout(self):
@pytest.mark.parametrize(
["proxy_scheme", "target_scheme"], [("http", "https"), ("https", "https")]
)
def test_tunneling_proxy_connect_timeout(self, proxy_scheme, target_scheme):
_should_skip_https_in_https(proxy_scheme, target_scheme)

proxy_url = "%s://%s" % (proxy_scheme, TARPIT_HOST)
target_url = self.https_url if target_scheme == "https" else self.http_url

with proxy_from_url(
"https://{host}".format(host=TARPIT_HOST), timeout=SHORT_TIMEOUT
) as https:
proxy_url, ca_certs=DEFAULT_CA, timeout=SHORT_TIMEOUT
) as proxy:
with pytest.raises(MaxRetryError) as e:
https.request("GET", self.http_url)
proxy.request("GET", target_url)

assert type(e.value.reason) == ConnectTimeoutError

@requires_network
@pytest.mark.parametrize(
["target_scheme", "use_forwarding_for_https"],
[
("http", False),
("https", False),
("https", True),
],
)
def test_https_proxy_tls_error(self, target_scheme, use_forwarding_for_https):
_should_skip_https_in_https("https", target_scheme, use_forwarding_for_https)

target_url = self.https_url if target_scheme == "https" else self.http_url
proxy_ctx = ssl.create_default_context()
with proxy_from_url(
self.https_proxy_url,
proxy_ssl_context=proxy_ctx,
use_forwarding_for_https=use_forwarding_for_https,
) as proxy:
with pytest.raises(MaxRetryError) as e:
proxy.request("GET", target_url)
assert type(e.value.reason) == SSLError

@requires_network
@pytest.mark.parametrize(
["proxy_scheme", "use_forwarding_for_https"],
[
("http", False),
("https", False),
("https", True),
],
)
def test_proxy_https_target_tls_error(self, proxy_scheme, use_forwarding_for_https):
_should_skip_https_in_https(proxy_scheme, "https")

proxy_url = self.https_proxy_url if proxy_scheme == "https" else self.proxy_url
proxy_ctx = ssl.create_default_context()
proxy_ctx.load_verify_locations(DEFAULT_CA)
ctx = ssl.create_default_context()

with proxy_from_url(
proxy_url,
proxy_ssl_context=proxy_ctx,
ssl_context=ctx,
use_forwarding_for_https=use_forwarding_for_https,
) as proxy:
with pytest.raises(MaxRetryError) as e:
proxy.request("GET", self.https_url)
assert type(e.value.reason) == SSLError

def test_scheme_host_case_insensitive(self):
"""Assert that upper-case schemes and hosts are normalized."""
with proxy_from_url(self.proxy_url.upper(), ca_certs=DEFAULT_CA) as http:
Expand Down Expand Up @@ -618,3 +758,15 @@ def test_https_proxy_common_name_warning(self, no_san_proxy):

assert len(w) == 1
assert w[0].category == SubjectAltNameWarning


def _should_skip_https_in_https(
proxy_scheme, target_scheme, use_forwarding_for_https=False
):
if (
sys.version_info[0] == 2
and proxy_scheme == "https"
and target_scheme == "https"
and use_forwarding_for_https is False
):
pytest.skip("HTTPS-in-HTTPS isn't supported on Python 2")
30 changes: 29 additions & 1 deletion test/with_dummyserver/test_socketlevel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
get_unreachable_address,
)
from dummyserver.testcase import SocketDummyServerTestCase, consume_socket
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, util
from urllib3 import HTTPConnectionPool, HTTPSConnectionPool, ProxyManager, util
from urllib3._collections import HTTPHeaderDict
from urllib3.connection import HTTPConnection, _get_default_user_agent
from urllib3.exceptions import (
Expand Down Expand Up @@ -39,6 +39,7 @@ class MimeToolMessage(object):
import shutil
import socket
import ssl
import sys
import tempfile
from collections import OrderedDict
from test import (
Expand Down Expand Up @@ -1160,6 +1161,33 @@ def echo_socket_handler(listener):
except MaxRetryError:
self.fail("Invalid IPv6 format in HTTP CONNECT request")

@pytest.mark.parametrize("target_scheme", ["http", "https"])
def test_https_proxymanager_connected_to_http_proxy(self, target_scheme):
if target_scheme == "https" and sys.version_info[0] == 2:
pytest.skip("HTTPS-in-HTTPS isn't supported on Python 2")

errored = Event()

def http_socket_handler(listener):
sock = listener.accept()[0]
sock.send(b"HTTP/1.0 501 Not Implemented\r\nConnection: close\r\n\r\n")
errored.wait()
sock.close()

self._start_server(http_socket_handler)
base_url = "https://%s:%d" % (self.host, self.port)

with ProxyManager(base_url, cert_reqs="NONE") as proxy:
with pytest.raises(MaxRetryError) as e:
proxy.request("GET", "%s://example.com" % target_scheme, retries=0)

errored.set() # Avoid a ConnectionAbortedError on Windows.

assert type(e.value.reason) == ProxyError
assert "Your proxy appears to only use HTTP and not HTTPS" in str(
e.value.reason
)


class TestSSL(SocketDummyServerTestCase):
def test_ssl_failure_midway_through_conn(self):
Expand Down

0 comments on commit 0435b0c

Please sign in to comment.