From d62d6a77d1c00dfa4052adc85ebcb109e84c2e2c Mon Sep 17 00:00:00 2001 From: Jakub Roztocil Date: Sat, 23 May 2020 13:26:06 +0200 Subject: [PATCH] Add support for --ciphers (#870) --- CHANGELOG.rst | 2 ++ README.rst | 32 +++++++++++++++++++++++++++----- httpie/cli/constants.py | 14 -------------- httpie/cli/definition.py | 16 ++++++++++++++-- httpie/client.py | 20 ++++++-------------- httpie/ssl.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_ssl.py | 35 ++++++++++++++++++++++++++++------- 7 files changed, 115 insertions(+), 42 deletions(-) create mode 100644 httpie/ssl.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 013cadce..d2023b4f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -9,6 +9,7 @@ This project adheres to `Semantic Versioning `_. `2.2.0-dev`_ (unreleased) ------------------------- +* Added support for ``--ciphers`` (`#870`_). * Added support for ``$XDG_CONFIG_HOME`` (`#920`_). @@ -433,5 +434,6 @@ This project adheres to `Semantic Versioning `_. .. _#488: https://github.com/jakubroztocil/httpie/issues/488 .. _#840: https://github.com/jakubroztocil/httpie/issues/840 +.. _#870: https://github.com/jakubroztocil/httpie/issues/870 .. _#895: https://github.com/jakubroztocil/httpie/issues/895 .. _#920: https://github.com/jakubroztocil/httpie/issues/920 diff --git a/README.rst b/README.rst index e0c1f6c3..da7d6570 100644 --- a/README.rst +++ b/README.rst @@ -1092,11 +1092,13 @@ path of the key file with ``--cert-key``: SSL version ----------- -Use the ``--ssl=`` to specify the desired protocol version to use. -This will default to SSL v2.3 which will negotiate the highest protocol that both -the server and your installation of OpenSSL support. The available protocols -are ``ssl2.3``, ``ssl3``, ``tls1``, ``tls1.1``, ``tls1.2``, ``tls1.3``. (The actually -available set of protocols may vary depending on your OpenSSL installation.) +Use the ``--ssl=`` option to specify the desired protocol version to +use. This will default to SSL v2.3 which will negotiate the highest protocol +that both the server and your installation of OpenSSL support. The available +protocols are +``ssl2.3``, ``ssl3``, ``tls1``, ``tls1.1``, ``tls1.2``, ``tls1.3``. +(The actually available set of protocols may vary depending on your OpenSSL +installation.) .. code-block:: bash @@ -1104,6 +1106,26 @@ available set of protocols may vary depending on your OpenSSL installation.) $ http --ssl=ssl3 https://vulnerable.example.org + +SSL ciphers +----------- + +You can specify the available ciphers with ``--ciphers``. +It should be a string in the +`OpenSSL cipher list format `_. + +.. code-block:: bash + + $ http --ciphers=ECDHE-RSA-AES128-GCM-SHA256 https://httpbin.org/get + +Note: these cipher strings do not change the negotiated version of SSL or TLS, +they only affect the list of available cipher suites. + +To see the default cipher string, run ``http --help`` and see +the ``--ciphers`` section under SSL. + + + Output options ============== diff --git a/httpie/cli/constants.py b/httpie/cli/constants.py index ba34dcc9..5865d5c5 100644 --- a/httpie/cli/constants.py +++ b/httpie/cli/constants.py @@ -87,17 +87,3 @@ PRETTY_STDOUT_TTY_ONLY = object() OUTPUT_OPTIONS_DEFAULT = OUT_RESP_HEAD + OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_STDOUT_REDIRECTED = OUT_RESP_BODY OUTPUT_OPTIONS_DEFAULT_OFFLINE = OUT_REQ_HEAD + OUT_REQ_BODY - -SSL_VERSION_ARG_MAPPING = { - 'ssl2.3': 'PROTOCOL_SSLv23', - 'ssl3': 'PROTOCOL_SSLv3', - 'tls1': 'PROTOCOL_TLSv1', - 'tls1.1': 'PROTOCOL_TLSv1_1', - 'tls1.2': 'PROTOCOL_TLSv1_2', - 'tls1.3': 'PROTOCOL_TLSv1_3', -} -SSL_VERSION_ARG_MAPPING = { - cli_arg: getattr(ssl, ssl_constant) - for cli_arg, ssl_constant in SSL_VERSION_ARG_MAPPING.items() - if hasattr(ssl, ssl_constant) -} diff --git a/httpie/cli/definition.py b/httpie/cli/definition.py index 43fb5348..bcd8dbc5 100644 --- a/httpie/cli/definition.py +++ b/httpie/cli/definition.py @@ -13,7 +13,7 @@ from httpie.cli.argtypes import ( from httpie.cli.constants import ( OUTPUT_OPTIONS, OUTPUT_OPTIONS_DEFAULT, OUT_REQ_BODY, OUT_REQ_HEAD, OUT_RESP_BODY, OUT_RESP_HEAD, PRETTY_MAP, PRETTY_STDOUT_TTY_ONLY, - SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, SSL_VERSION_ARG_MAPPING, + SEPARATOR_GROUP_ALL_ITEMS, SEPARATOR_PROXY, ) from httpie.output.formatters.colors import ( AUTO_STYLE, AVAILABLE_STYLES, DEFAULT_STYLE, @@ -21,6 +21,7 @@ from httpie.output.formatters.colors import ( from httpie.plugins import plugin_manager from httpie.plugins.builtin import BuiltinAuthPlugin from httpie.sessions import DEFAULT_SESSIONS_DIR +from httpie.ssl import DEFAULT_SSL_CIPHERS, AVAILABLE_SSL_VERSION_ARG_MAPPING parser = HTTPieArgumentParser( @@ -580,7 +581,7 @@ ssl.add_argument( ssl.add_argument( '--ssl', # TODO: Maybe something more general, such as --secure-protocol? dest='ssl_version', - choices=list(sorted(SSL_VERSION_ARG_MAPPING.keys())), + choices=list(sorted(AVAILABLE_SSL_VERSION_ARG_MAPPING.keys())), help=""" The desired protocol version to use. This will default to SSL v2.3 which will negotiate the highest protocol that both @@ -590,6 +591,17 @@ ssl.add_argument( """ ) +ssl.add_argument( + '--ciphers', + help=f""" + + A string in the OpenSSL cipher list format. By default, the following + is used: + + {DEFAULT_SSL_CIPHERS} + + """ +) ssl.add_argument( '--cert', default=None, diff --git a/httpie/client.py b/httpie/client.py index e3c6fa39..991d5755 100644 --- a/httpie/client.py +++ b/httpie/client.py @@ -9,13 +9,12 @@ from typing import Iterable, Union from urllib.parse import urlparse, urlunparse import requests -from requests.adapters import HTTPAdapter from httpie import __version__ -from httpie.cli.constants import SSL_VERSION_ARG_MAPPING from httpie.cli.dicts import RequestHeadersDict from httpie.plugins import plugin_manager from httpie.sessions import get_httpie_session +from httpie.ssl import HTTPieHTTPSAdapter, AVAILABLE_SSL_VERSION_ARG_MAPPING from httpie.utils import repr_dict @@ -57,6 +56,7 @@ def collect_messages( send_kwargs_mergeable_from_env = make_send_kwargs_mergeable_from_env(args) requests_session = build_requests_session( ssl_version=args.ssl_version, + ciphers=args.ciphers, ) if httpie_session: @@ -121,6 +121,7 @@ def collect_messages( @contextmanager def max_headers(limit): # + # noinspection PyUnresolvedReferences orig = http.client._MAXHEADERS http.client._MAXHEADERS = limit or float('Inf') try: @@ -145,26 +146,17 @@ def compress_body(request: requests.PreparedRequest, always: bool): request.headers['Content-Length'] = str(len(deflated_data)) -class HTTPieHTTPSAdapter(HTTPAdapter): - - def __init__(self, ssl_version=None, **kwargs): - self._ssl_version = ssl_version - super().__init__(**kwargs) - - def init_poolmanager(self, *args, **kwargs): - kwargs['ssl_version'] = self._ssl_version - super().init_poolmanager(*args, **kwargs) - - def build_requests_session( ssl_version: str = None, + ciphers: str = None, ) -> requests.Session: requests_session = requests.Session() # Install our adapter. requests_session.mount('https://', HTTPieHTTPSAdapter( + ciphers=ciphers, ssl_version=( - SSL_VERSION_ARG_MAPPING[ssl_version] + AVAILABLE_SSL_VERSION_ARG_MAPPING[ssl_version] if ssl_version else None ) )) diff --git a/httpie/ssl.py b/httpie/ssl.py new file mode 100644 index 00000000..9f8e58ec --- /dev/null +++ b/httpie/ssl.py @@ -0,0 +1,38 @@ +import ssl + +from requests.adapters import HTTPAdapter +# noinspection PyPackageRequirements +from urllib3.util.ssl_ import DEFAULT_CIPHERS, create_urllib3_context + + +DEFAULT_SSL_CIPHERS = DEFAULT_CIPHERS +SSL_VERSION_ARG_MAPPING = { + 'ssl2.3': 'PROTOCOL_SSLv23', + 'ssl3': 'PROTOCOL_SSLv3', + 'tls1': 'PROTOCOL_TLSv1', + 'tls1.1': 'PROTOCOL_TLSv1_1', + 'tls1.2': 'PROTOCOL_TLSv1_2', + 'tls1.3': 'PROTOCOL_TLSv1_3', +} +AVAILABLE_SSL_VERSION_ARG_MAPPING = { + arg: getattr(ssl, constant_name) + for arg, constant_name in SSL_VERSION_ARG_MAPPING.items() + if hasattr(ssl, constant_name) +} + + +class HTTPieHTTPSAdapter(HTTPAdapter): + def __init__(self, ssl_version: str = None, ciphers: str = None, **kwargs): + self._ssl_context: ssl.SSLContext = create_urllib3_context( + ciphers=ciphers, + ssl_version=ssl_version + ) + super().__init__(**kwargs) + + def init_poolmanager(self, *args, **kwargs): + kwargs['ssl_context'] = self._ssl_context + return super().init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs['ssl_context'] = self._ssl_context + return super().proxy_manager_for(*args, **kwargs) diff --git a/tests/test_ssl.py b/tests/test_ssl.py index 051532f8..31e0d534 100644 --- a/tests/test_ssl.py +++ b/tests/test_ssl.py @@ -1,11 +1,9 @@ -import os - import pytest import pytest_httpbin.certs import requests.exceptions +from httpie.ssl import AVAILABLE_SSL_VERSION_ARG_MAPPING, DEFAULT_SSL_CIPHERS from httpie.status import ExitStatus -from httpie.cli.constants import SSL_VERSION_ARG_MAPPING from utils import HTTP_OK, TESTS_ROOT, http @@ -23,10 +21,12 @@ except ImportError: requests.exceptions.SSLError, ) +CERTS_ROOT = TESTS_ROOT / 'client_certs' +CLIENT_CERT = str(CERTS_ROOT / 'client.crt') +CLIENT_KEY = str(CERTS_ROOT / 'client.key') +CLIENT_PEM = str(CERTS_ROOT / 'client.pem') + -CLIENT_CERT = os.path.join(TESTS_ROOT, 'client_certs', 'client.crt') -CLIENT_KEY = os.path.join(TESTS_ROOT, 'client_certs', 'client.key') -CLIENT_PEM = os.path.join(TESTS_ROOT, 'client_certs', 'client.pem') # FIXME: # We test against a local httpbin instance which uses a self-signed cert. # Requests without --verify= will fail with a verification error. @@ -34,7 +34,8 @@ CLIENT_PEM = os.path.join(TESTS_ROOT, 'client_certs', 'client.pem') CA_BUNDLE = pytest_httpbin.certs.where() -@pytest.mark.parametrize('ssl_version', SSL_VERSION_ARG_MAPPING.keys()) +@pytest.mark.parametrize('ssl_version', + AVAILABLE_SSL_VERSION_ARG_MAPPING.keys()) def test_ssl_version(httpbin_secure, ssl_version): try: r = http( @@ -113,3 +114,23 @@ class TestServerCert: def test_verify_custom_ca_bundle_invalid_bundle(self, httpbin_secure): with pytest.raises(ssl_errors): http(httpbin_secure.url + '/get', '--verify', __file__) + + +def test_ciphers(httpbin_secure): + r = http( + httpbin_secure.url + '/get', + '--ciphers', + DEFAULT_SSL_CIPHERS, + ) + assert HTTP_OK in r + + +def test_ciphers_none_can_be_selected(httpbin_secure): + r = http( + httpbin_secure.url + '/get', + '--ciphers', + '__FOO__', + tolerate_error_exit_status=True, + ) + assert r.exit_status == ExitStatus.ERROR + assert 'No cipher can be selected.' in r.stderr