Skip to content

Commit e71119d

Browse files
authored
Make it possible to customize SSL ciphers (#3212)
Given that Python 3.10 changed the default list of TLS ciphers, it is a good idea to allow customization of the list of ciphers when using Redis with TLS. In some situations the client is unusable right now with older servers and Python >= 3.10. Also whitelist a dev dependency vulnerability, and bump version to 5.0.4. --------- Co-authored-by: Gabriel Erzse <gabriel.erzse@redis.com>
1 parent 1784b37 commit e71119d

File tree

11 files changed

+187
-1
lines changed

11 files changed

+187
-1
lines changed

.github/workflows/integration.yaml

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ jobs:
3535
inputs: requirements.txt dev_requirements.txt
3636
ignore-vulns: |
3737
GHSA-w596-4wvx-j9j6 # subversion related git pull, dependency for pytest. There is no impact here.
38+
PYSEC-2024-48 # black vulnerability in 22.3.0, can't upgrade due to python 3.7 support, no impact
3839
3940
lint:
4041
name: Code linters

redis/asyncio/client.py

+2
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ def __init__(
221221
ssl_ca_data: Optional[str] = None,
222222
ssl_check_hostname: bool = False,
223223
ssl_min_version: Optional[ssl.TLSVersion] = None,
224+
ssl_ciphers: Optional[str] = None,
224225
max_connections: Optional[int] = None,
225226
single_connection_client: bool = False,
226227
health_check_interval: int = 0,
@@ -314,6 +315,7 @@ def __init__(
314315
"ssl_ca_data": ssl_ca_data,
315316
"ssl_check_hostname": ssl_check_hostname,
316317
"ssl_min_version": ssl_min_version,
318+
"ssl_ciphers": ssl_ciphers,
317319
}
318320
)
319321
# This arg only used if no pool is passed in

redis/asyncio/cluster.py

+2
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ def __init__(
267267
ssl_check_hostname: bool = False,
268268
ssl_keyfile: Optional[str] = None,
269269
ssl_min_version: Optional[ssl.TLSVersion] = None,
270+
ssl_ciphers: Optional[str] = None,
270271
protocol: Optional[int] = 2,
271272
address_remap: Optional[Callable[[str, int], Tuple[str, int]]] = None,
272273
) -> None:
@@ -326,6 +327,7 @@ def __init__(
326327
"ssl_check_hostname": ssl_check_hostname,
327328
"ssl_keyfile": ssl_keyfile,
328329
"ssl_min_version": ssl_min_version,
330+
"ssl_ciphers": ssl_ciphers,
329331
}
330332
)
331333

redis/asyncio/connection.py

+7
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@ def __init__(
739739
ssl_ca_data: Optional[str] = None,
740740
ssl_check_hostname: bool = False,
741741
ssl_min_version: Optional[ssl.TLSVersion] = None,
742+
ssl_ciphers: Optional[str] = None,
742743
**kwargs,
743744
):
744745
self.ssl_context: RedisSSLContext = RedisSSLContext(
@@ -749,6 +750,7 @@ def __init__(
749750
ca_data=ssl_ca_data,
750751
check_hostname=ssl_check_hostname,
751752
min_version=ssl_min_version,
753+
ciphers=ssl_ciphers,
752754
)
753755
super().__init__(**kwargs)
754756

@@ -796,6 +798,7 @@ class RedisSSLContext:
796798
"context",
797799
"check_hostname",
798800
"min_version",
801+
"ciphers",
799802
)
800803

801804
def __init__(
@@ -807,6 +810,7 @@ def __init__(
807810
ca_data: Optional[str] = None,
808811
check_hostname: bool = False,
809812
min_version: Optional[ssl.TLSVersion] = None,
813+
ciphers: Optional[str] = None,
810814
):
811815
self.keyfile = keyfile
812816
self.certfile = certfile
@@ -827,6 +831,7 @@ def __init__(
827831
self.ca_data = ca_data
828832
self.check_hostname = check_hostname
829833
self.min_version = min_version
834+
self.ciphers = ciphers
830835
self.context: Optional[ssl.SSLContext] = None
831836

832837
def get(self) -> ssl.SSLContext:
@@ -840,6 +845,8 @@ def get(self) -> ssl.SSLContext:
840845
context.load_verify_locations(cafile=self.ca_certs, cadata=self.ca_data)
841846
if self.min_version is not None:
842847
context.minimum_version = self.min_version
848+
if self.ciphers is not None:
849+
context.set_ciphers(self.ciphers)
843850
self.context = context
844851
return self.context
845852

redis/client.py

+2
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ def __init__(
198198
ssl_ocsp_context=None,
199199
ssl_ocsp_expected_cert=None,
200200
ssl_min_version=None,
201+
ssl_ciphers=None,
201202
max_connections=None,
202203
single_connection_client=False,
203204
health_check_interval=0,
@@ -298,6 +299,7 @@ def __init__(
298299
"ssl_ocsp_context": ssl_ocsp_context,
299300
"ssl_ocsp_expected_cert": ssl_ocsp_expected_cert,
300301
"ssl_min_version": ssl_min_version,
302+
"ssl_ciphers": ssl_ciphers,
301303
}
302304
)
303305
connection_pool = ConnectionPool(**kwargs)

redis/connection.py

+5
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ def __init__(
685685
ssl_ocsp_context=None,
686686
ssl_ocsp_expected_cert=None,
687687
ssl_min_version=None,
688+
ssl_ciphers=None,
688689
**kwargs,
689690
):
690691
"""Constructor
@@ -704,6 +705,7 @@ def __init__(
704705
ssl_ocsp_context: A fully initialized OpenSSL.SSL.Context object to be used in verifying the ssl_ocsp_expected_cert
705706
ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service.
706707
ssl_min_version: The lowest supported SSL version. It affects the supported SSL versions of the SSLContext. None leaves the default provided by ssl module.
708+
ssl_ciphers: A string listing the ciphers that are allowed to be used. Defaults to None, which means that the default ciphers are used. See https://docs.python.org/3/library/ssl.html#ssl.SSLContext.set_ciphers for more information.
707709
708710
Raises:
709711
RedisError
@@ -737,6 +739,7 @@ def __init__(
737739
self.ssl_ocsp_context = ssl_ocsp_context
738740
self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert
739741
self.ssl_min_version = ssl_min_version
742+
self.ssl_ciphers = ssl_ciphers
740743
super().__init__(**kwargs)
741744

742745
def _connect(self):
@@ -761,6 +764,8 @@ def _connect(self):
761764
)
762765
if self.ssl_min_version is not None:
763766
context.minimum_version = self.ssl_min_version
767+
if self.ssl_ciphers:
768+
context.set_ciphers(self.ssl_ciphers)
764769
sslsock = context.wrap_socket(sock, server_hostname=self.host)
765770
if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:
766771
raise RedisError("cryptography is not installed.")

setup.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
long_description_content_type="text/markdown",
99
keywords=["Redis", "key-value store", "database"],
1010
license="MIT",
11-
version="5.0.3",
11+
version="5.0.4",
1212
packages=find_packages(
1313
include=[
1414
"redis",

tests/test_asyncio/test_cluster.py

+54
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import asyncio
22
import binascii
33
import datetime
4+
import ssl
45
import warnings
56
from typing import Any, Awaitable, Callable, Dict, List, Optional, Type, Union
67
from urllib.parse import urlparse
@@ -2951,6 +2952,59 @@ async def test_ssl_connection(
29512952
async with await create_client(ssl=True, ssl_cert_reqs="none") as rc:
29522953
assert await rc.ping()
29532954

2955+
@pytest.mark.parametrize(
2956+
"ssl_ciphers",
2957+
[
2958+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
2959+
"ECDHE-ECDSA-AES256-GCM-SHA384",
2960+
"ECDHE-RSA-AES128-GCM-SHA256",
2961+
],
2962+
)
2963+
async def test_ssl_connection_tls12_custom_ciphers(
2964+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
2965+
) -> None:
2966+
async with await create_client(
2967+
ssl=True,
2968+
ssl_cert_reqs="none",
2969+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
2970+
ssl_ciphers=ssl_ciphers,
2971+
) as rc:
2972+
assert await rc.ping()
2973+
2974+
async def test_ssl_connection_tls12_custom_ciphers_invalid(
2975+
self, create_client: Callable[..., Awaitable[RedisCluster]]
2976+
) -> None:
2977+
async with await create_client(
2978+
ssl=True,
2979+
ssl_cert_reqs="none",
2980+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
2981+
ssl_ciphers="foo:bar",
2982+
) as rc:
2983+
with pytest.raises(RedisClusterException) as e:
2984+
assert await rc.ping()
2985+
assert "Redis Cluster cannot be connected" in str(e.value)
2986+
2987+
@pytest.mark.parametrize(
2988+
"ssl_ciphers",
2989+
[
2990+
"TLS_CHACHA20_POLY1305_SHA256",
2991+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
2992+
],
2993+
)
2994+
async def test_ssl_connection_tls13_custom_ciphers(
2995+
self, ssl_ciphers, create_client: Callable[..., Awaitable[RedisCluster]]
2996+
) -> None:
2997+
# TLSv1.3 does not support changing the ciphers
2998+
async with await create_client(
2999+
ssl=True,
3000+
ssl_cert_reqs="none",
3001+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
3002+
ssl_ciphers=ssl_ciphers,
3003+
) as rc:
3004+
with pytest.raises(RedisClusterException) as e:
3005+
assert await rc.ping()
3006+
assert "Redis Cluster cannot be connected" in str(e.value)
3007+
29543008
async def test_validating_self_signed_certificate(
29553009
self, create_client: Callable[..., Awaitable[RedisCluster]]
29563010
) -> None:

tests/test_asyncio/test_connect.py

+26
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,32 @@ async def test_uds_connect(uds_address):
5050
await _assert_connect(conn, path)
5151

5252

53+
@pytest.mark.ssl
54+
@pytest.mark.parametrize(
55+
"ssl_ciphers",
56+
[
57+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
58+
"ECDHE-ECDSA-AES256-GCM-SHA384",
59+
"ECDHE-RSA-AES128-GCM-SHA256",
60+
],
61+
)
62+
async def test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):
63+
host, port = tcp_address
64+
certfile = get_ssl_filename("server-cert.pem")
65+
keyfile = get_ssl_filename("server-key.pem")
66+
conn = SSLConnection(
67+
host=host,
68+
port=port,
69+
client_name=_CLIENT_NAME,
70+
ssl_ca_certs=certfile,
71+
socket_timeout=10,
72+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
73+
ssl_ciphers=ssl_ciphers,
74+
)
75+
await _assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
76+
await conn.disconnect()
77+
78+
5379
@pytest.mark.ssl
5480
@pytest.mark.parametrize(
5581
"ssl_min_version",

tests/test_connect.py

+25
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,31 @@ def test_tcp_ssl_connect(tcp_address, ssl_min_version):
7171
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
7272

7373

74+
@pytest.mark.ssl
75+
@pytest.mark.parametrize(
76+
"ssl_ciphers",
77+
[
78+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
79+
"ECDHE-ECDSA-AES256-GCM-SHA384",
80+
"ECDHE-RSA-AES128-GCM-SHA256",
81+
],
82+
)
83+
def test_tcp_ssl_tls12_custom_ciphers(tcp_address, ssl_ciphers):
84+
host, port = tcp_address
85+
certfile = get_ssl_filename("server-cert.pem")
86+
keyfile = get_ssl_filename("server-key.pem")
87+
conn = SSLConnection(
88+
host=host,
89+
port=port,
90+
client_name=_CLIENT_NAME,
91+
ssl_ca_certs=certfile,
92+
socket_timeout=10,
93+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
94+
ssl_ciphers=ssl_ciphers,
95+
)
96+
_assert_connect(conn, tcp_address, certfile=certfile, keyfile=keyfile)
97+
98+
7499
@pytest.mark.ssl
75100
@pytest.mark.skipif(not ssl.HAS_TLSv1_3, reason="requires TLSv1.3")
76101
def test_tcp_ssl_version_mismatch(tcp_address):

tests/test_ssl.py

+62
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,68 @@ def test_validating_self_signed_string_certificate(self, request):
7373
)
7474
assert r.ping()
7575

76+
@pytest.mark.parametrize(
77+
"ssl_ciphers",
78+
[
79+
"AES256-SHA:DHE-RSA-AES256-SHA:AES128-SHA:DHE-RSA-AES128-SHA",
80+
"DHE-RSA-AES256-GCM-SHA384",
81+
"ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305",
82+
],
83+
)
84+
def test_ssl_connection_tls12_custom_ciphers(self, request, ssl_ciphers):
85+
ssl_url = request.config.option.redis_ssl_url
86+
p = urlparse(ssl_url)[1].split(":")
87+
r = redis.Redis(
88+
host=p[0],
89+
port=p[1],
90+
ssl=True,
91+
ssl_cert_reqs="none",
92+
ssl_min_version=ssl.TLSVersion.TLSv1_3,
93+
ssl_ciphers=ssl_ciphers,
94+
)
95+
assert r.ping()
96+
r.close()
97+
98+
def test_ssl_connection_tls12_custom_ciphers_invalid(self, request):
99+
ssl_url = request.config.option.redis_ssl_url
100+
p = urlparse(ssl_url)[1].split(":")
101+
r = redis.Redis(
102+
host=p[0],
103+
port=p[1],
104+
ssl=True,
105+
ssl_cert_reqs="none",
106+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
107+
ssl_ciphers="foo:bar",
108+
)
109+
with pytest.raises(RedisError) as e:
110+
r.ping()
111+
assert "No cipher can be selected" in str(e)
112+
r.close()
113+
114+
@pytest.mark.parametrize(
115+
"ssl_ciphers",
116+
[
117+
"TLS_CHACHA20_POLY1305_SHA256",
118+
"TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256",
119+
],
120+
)
121+
def test_ssl_connection_tls13_custom_ciphers(self, request, ssl_ciphers):
122+
# TLSv1.3 does not support changing the ciphers
123+
ssl_url = request.config.option.redis_ssl_url
124+
p = urlparse(ssl_url)[1].split(":")
125+
r = redis.Redis(
126+
host=p[0],
127+
port=p[1],
128+
ssl=True,
129+
ssl_cert_reqs="none",
130+
ssl_min_version=ssl.TLSVersion.TLSv1_2,
131+
ssl_ciphers=ssl_ciphers,
132+
)
133+
with pytest.raises(RedisError) as e:
134+
r.ping()
135+
assert "No cipher can be selected" in str(e)
136+
r.close()
137+
76138
def _create_oscp_conn(self, request):
77139
ssl_url = request.config.option.redis_ssl_url
78140
p = urlparse(ssl_url)[1].split(":")

0 commit comments

Comments
 (0)