Skip to content

Commit 7945932

Browse files
committed
feat(ssl): support custom SSLContext
Allow sync and async Redis clients to use a pre-configured SSLContext for standalone and cluster connections. Preserve the existing SSL option path when no custom context is supplied. Refs #3599
1 parent 88d16d0 commit 7945932

12 files changed

Lines changed: 149 additions & 31 deletions

redis/asyncio/client.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,9 @@
9797
)
9898

9999
if TYPE_CHECKING and SSL_AVAILABLE:
100-
from ssl import TLSVersion, VerifyFlags, VerifyMode
100+
from ssl import SSLContext, TLSVersion, VerifyFlags, VerifyMode
101101
else:
102+
SSLContext = None
102103
TLSVersion = None
103104
VerifyMode = None
104105
VerifyFlags = None
@@ -293,6 +294,7 @@ def __init__(
293294
ssl_min_version: "TLSVersion | None" = None,
294295
ssl_ciphers: str | None = None,
295296
ssl_password: str | None = None,
297+
ssl_context: "SSLContext | None" = None,
296298
max_connections: int | None = None,
297299
single_connection_client: bool = False,
298300
health_check_interval: int = 0,
@@ -449,6 +451,7 @@ def __init__(
449451
"ssl_min_version": ssl_min_version,
450452
"ssl_ciphers": ssl_ciphers,
451453
"ssl_password": ssl_password,
454+
"ssl_context": ssl_context,
452455
}
453456
)
454457
maint_notifications_enabled = (

redis/asyncio/cluster.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,9 @@
133133
)
134134

135135
if SSL_AVAILABLE:
136-
from ssl import TLSVersion, VerifyFlags, VerifyMode
136+
from ssl import SSLContext, TLSVersion, VerifyFlags, VerifyMode
137137
else:
138+
SSLContext = None
138139
TLSVersion = None
139140
VerifyMode = None
140141
VerifyFlags = None
@@ -443,6 +444,7 @@ def __init__(
443444
ssl_keyfile: str | None = None,
444445
ssl_min_version: "TLSVersion | None" = None,
445446
ssl_ciphers: str | None = None,
447+
ssl_context: "SSLContext | None" = None,
446448
protocol: int | None = None,
447449
legacy_responses: bool = True,
448450
address_remap: Callable[[Tuple[str, int]], Tuple[str, int]] | None = None,
@@ -510,6 +512,7 @@ def __init__(
510512
"ssl_keyfile": ssl_keyfile,
511513
"ssl_min_version": ssl_min_version,
512514
"ssl_ciphers": ssl_ciphers,
515+
"ssl_context": ssl_context,
513516
}
514517
)
515518

redis/asyncio/connection.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1507,6 +1507,7 @@ def __init__(
15071507
ssl_min_version: Optional[TLSVersion] = None,
15081508
ssl_ciphers: Optional[str] = None,
15091509
ssl_password: Optional[str] = None,
1510+
ssl_context: Optional[SSLContext] = None,
15101511
**kwargs,
15111512
):
15121513
if not SSL_AVAILABLE:
@@ -1525,6 +1526,7 @@ def __init__(
15251526
min_version=ssl_min_version,
15261527
ciphers=ssl_ciphers,
15271528
password=ssl_password,
1529+
context=ssl_context,
15281530
)
15291531
super().__init__(**kwargs)
15301532

@@ -1601,6 +1603,7 @@ def __init__(
16011603
min_version: Optional[TLSVersion] = None,
16021604
ciphers: Optional[str] = None,
16031605
password: Optional[str] = None,
1606+
context: Optional[SSLContext] = None,
16041607
):
16051608
if not SSL_AVAILABLE:
16061609
raise RedisError("Python wasn't built with SSL support")
@@ -1632,10 +1635,10 @@ def __init__(
16321635
self.min_version = min_version
16331636
self.ciphers = ciphers
16341637
self.password = password
1635-
self.context: Optional[SSLContext] = None
1638+
self.context: Optional[SSLContext] = context
16361639

16371640
def get(self) -> SSLContext:
1638-
if not self.context:
1641+
if self.context is None:
16391642
context = ssl.create_default_context()
16401643
context.check_hostname = self.check_hostname
16411644
context.verify_mode = self.cert_reqs

redis/client.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ def __init__(
315315
maint_notifications_config: MaintNotificationsConfig | None = None,
316316
oss_cluster_maint_notifications_handler: OSSMaintNotificationsHandler
317317
| None = None,
318+
ssl_context: "ssl.SSLContext | None" = None,
318319
) -> None:
319320
"""
320321
Initialize a new Redis client.
@@ -464,6 +465,7 @@ def __init__(
464465
"ssl_ocsp_expected_cert": ssl_ocsp_expected_cert,
465466
"ssl_min_version": ssl_min_version,
466467
"ssl_ciphers": ssl_ciphers,
468+
"ssl_context": ssl_context,
467469
}
468470
)
469471
if (cache_config or cache) and check_protocol_version(protocol, 3):

redis/cluster.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ def parse_cluster_myshardid(resp, **options):
302302
"ssl_exclude_verify_flags",
303303
"ssl_keyfile",
304304
"ssl_password",
305+
"ssl_context",
305306
"ssl_check_hostname",
306307
"unix_socket_path",
307308
"username",

redis/connection.py

Lines changed: 35 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2035,6 +2035,7 @@ def __init__(
20352035
ssl_ocsp_expected_cert=None,
20362036
ssl_min_version=None,
20372037
ssl_ciphers=None,
2038+
ssl_context=None,
20382039
**kwargs,
20392040
):
20402041
"""Constructor
@@ -2058,6 +2059,9 @@ def __init__(
20582059
ssl_ocsp_expected_cert: A PEM armoured string containing the expected certificate to be returned from the ocsp verification service.
20592060
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.
20602061
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.
2062+
ssl_context: A pre-configured ``ssl.SSLContext`` to use for the
2063+
connection. If provided, it takes precedence over the other
2064+
SSL configuration options.
20612065
20622066
Raises:
20632067
RedisError
@@ -2096,6 +2100,7 @@ def __init__(
20962100
self.ssl_ocsp_expected_cert = ssl_ocsp_expected_cert
20972101
self.ssl_min_version = ssl_min_version
20982102
self.ssl_ciphers = ssl_ciphers
2103+
self.ssl_context = ssl_context
20992104
super().__init__(**kwargs)
21002105

21012106
def _connect(self):
@@ -2119,33 +2124,36 @@ def _wrap_socket_with_ssl(self, sock):
21192124
Returns:
21202125
An SSL wrapped socket.
21212126
"""
2122-
context = ssl.create_default_context()
2123-
context.check_hostname = self.check_hostname
2124-
context.verify_mode = self.cert_reqs
2125-
if self.ssl_include_verify_flags:
2126-
for flag in self.ssl_include_verify_flags:
2127-
context.verify_flags |= flag
2128-
if self.ssl_exclude_verify_flags:
2129-
for flag in self.ssl_exclude_verify_flags:
2130-
context.verify_flags &= ~flag
2131-
if self.certfile or self.keyfile:
2132-
context.load_cert_chain(
2133-
certfile=self.certfile,
2134-
keyfile=self.keyfile,
2135-
password=self.certificate_password,
2136-
)
2137-
if (
2138-
self.ca_certs is not None
2139-
or self.ca_path is not None
2140-
or self.ca_data is not None
2141-
):
2142-
context.load_verify_locations(
2143-
cafile=self.ca_certs, capath=self.ca_path, cadata=self.ca_data
2144-
)
2145-
if self.ssl_min_version is not None:
2146-
context.minimum_version = self.ssl_min_version
2147-
if self.ssl_ciphers:
2148-
context.set_ciphers(self.ssl_ciphers)
2127+
if self.ssl_context is None:
2128+
context = ssl.create_default_context()
2129+
context.check_hostname = self.check_hostname
2130+
context.verify_mode = self.cert_reqs
2131+
if self.ssl_include_verify_flags:
2132+
for flag in self.ssl_include_verify_flags:
2133+
context.verify_flags |= flag
2134+
if self.ssl_exclude_verify_flags:
2135+
for flag in self.ssl_exclude_verify_flags:
2136+
context.verify_flags &= ~flag
2137+
if self.certfile or self.keyfile:
2138+
context.load_cert_chain(
2139+
certfile=self.certfile,
2140+
keyfile=self.keyfile,
2141+
password=self.certificate_password,
2142+
)
2143+
if (
2144+
self.ca_certs is not None
2145+
or self.ca_path is not None
2146+
or self.ca_data is not None
2147+
):
2148+
context.load_verify_locations(
2149+
cafile=self.ca_certs, capath=self.ca_path, cadata=self.ca_data
2150+
)
2151+
if self.ssl_min_version is not None:
2152+
context.minimum_version = self.ssl_min_version
2153+
if self.ssl_ciphers:
2154+
context.set_ciphers(self.ssl_ciphers)
2155+
else:
2156+
context = self.ssl_context
21492157
if self.ssl_validate_ocsp is True and CRYPTOGRAPHY_AVAILABLE is False:
21502158
raise RedisError("cryptography is not installed.")
21512159

tests/test_asyncio/test_cluster.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,17 @@
7777
]
7878

7979

80+
def test_cluster_preserves_custom_ssl_context():
81+
context = ssl.create_default_context()
82+
cluster = RedisCluster(
83+
startup_nodes=[ClusterNode("localhost", 6379)],
84+
ssl=True,
85+
ssl_context=context,
86+
)
87+
88+
assert cluster.connection_kwargs["ssl_context"] is context
89+
90+
8091
class NodeProxy:
8192
"""A class to proxy a node connection to a different port"""
8293

tests/test_asyncio/test_connect.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import ssl
55

66
import pytest
7+
import redis.asyncio as redis
78
from redis.asyncio.connection import (
89
Connection,
910
SSLConnection,
@@ -53,6 +54,26 @@ async def test_uds_connect(uds_address):
5354
await _assert_connect(conn, path)
5455

5556

57+
@pytest.mark.ssl
58+
async def test_tcp_ssl_uses_custom_context(tcp_address):
59+
context = ssl.create_default_context()
60+
conn = SSLConnection(host="localhost", port=tcp_address[1], ssl_context=context)
61+
62+
assert conn.ssl_context.get() is context
63+
64+
65+
@pytest.mark.ssl
66+
async def test_redis_passes_custom_context_to_ssl_connection():
67+
context = ssl.create_default_context()
68+
client = redis.Redis(ssl=True, ssl_context=context)
69+
70+
try:
71+
connection = client.connection_pool.make_connection()
72+
assert connection.ssl_context.get() is context
73+
finally:
74+
await client.aclose()
75+
76+
5677
@pytest.mark.ssl
5778
@pytest.mark.parametrize(
5879
"ssl_ciphers",

tests/test_asyncio/test_connection_pool.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,6 +753,19 @@ def test_host(self):
753753
assert pool.connection_class == redis.SSLConnection
754754
assert_kwargs_subset(pool.connection_kwargs, {"host": "my.host"})
755755

756+
def test_custom_ssl_context(self):
757+
import ssl
758+
759+
context = ssl.create_default_context()
760+
761+
class DummyConnectionPool(redis.ConnectionPool):
762+
def get_connection(self):
763+
return self.make_connection()
764+
765+
pool = DummyConnectionPool.from_url("rediss://my.host", ssl_context=context)
766+
767+
assert pool.get_connection().ssl_context.get() is context
768+
756769
def test_cert_reqs_options(self):
757770
import ssl
758771

tests/test_cluster.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import select
44
import socket
55
import socketserver
6+
import ssl
67
import threading
78
from typing import List
89
import warnings
@@ -75,6 +76,22 @@
7576
]
7677

7778

79+
def test_cluster_preserves_custom_ssl_context():
80+
context = ssl.create_default_context()
81+
82+
with (
83+
patch.object(NodesManager, "initialize"),
84+
patch.object(CommandsParser, "initialize"),
85+
):
86+
cluster = RedisCluster(
87+
startup_nodes=[ClusterNode("localhost", 6379)],
88+
ssl=True,
89+
ssl_context=context,
90+
)
91+
92+
assert cluster.get_connection_kwargs()["ssl_context"] is context
93+
94+
7895
class ProxyRequestHandler(socketserver.BaseRequestHandler):
7996
def recv(self, sock):
8097
"""A recv with a timeout"""

0 commit comments

Comments
 (0)