Repository: dask/distributed

Issue:
dask security: The TLS protocol version can be selected.
<!-- Please do a quick search of existing issues to make sure that this has not been asked before. -->
TLS 1.0 and TLS 1.1 have proven to be insecure and are gradually being discarded. When users have higher security requirements, TLS1.2 and later versions are preferred. However, the TLS version cannot be specified currently. As a result, earlier versions are supported, causing security risks.
According to my practice, you only need to modify the following code to specify the version:
change the file   of `security.py`:
change 
```python
 ctx = ssl.create_default_context(purpose=purpose, cafile=ca)
```
to 
```python
ctx = ssl.SSLContext(ssl.PROTOCOL_TLSv1_2)
ctx.verify_mode = ssl.CERT_REQUIRED
ctx.load_verify_locations(ca)
```
`ssl.PROTOCOL_TLSv1_2` can be an input parameter， so the user can select another version.  Since `dask-scheduler` and `dask -worker` already have the `--protocol` parameter, I think it's convenient to extend this parameter directly.

However, we cannot directly modify the dask source code, so we hope dask provides official support.


Discussion:

Resolving patch:
diff --git a/distributed/security.py b/distributed/security.py
--- a/distributed/security.py
+++ b/distributed/security.py
@@ -28,6 +28,11 @@ class Security:
     tls_ciphers : str, optional
         An OpenSSL cipher string of allowed ciphers. If not provided, the
         system defaults will be used.
+    tls_min_version : ssl.TLSVersion, optional
+        The minimum TLS version to support. Defaults to TLS 1.2.
+    tls_max_version : ssl.TLSVersion, optional
+        The maximum TLS version to support. Defaults to the maximum version
+        supported.
     tls_client_cert : str, optional
         Path to a certificate file for the client, encoded in PEM format.
     tls_client_key : str, optional
@@ -54,6 +59,8 @@ class Security:
         "require_encryption",
         "tls_ca_file",
         "tls_ciphers",
+        "tls_min_version",
+        "tls_max_version",
         "tls_client_key",
         "tls_client_cert",
         "tls_scheduler_key",
@@ -74,6 +81,17 @@ def __init__(self, require_encryption=None, **kwargs):
             require_encryption = bool(kwargs)
         self.require_encryption = require_encryption
         self._set_field(kwargs, "tls_ciphers", "distributed.comm.tls.ciphers")
+        self._set_tls_version_field(
+            kwargs,
+            "tls_min_version",
+            "distributed.comm.tls.min-version",
+            ssl.TLSVersion.TLSv1_2,
+        )
+        self._set_tls_version_field(
+            kwargs,
+            "tls_max_version",
+            "distributed.comm.tls.max-version",
+        )
         self._set_field(kwargs, "tls_ca_file", "distributed.comm.tls.ca-file")
         self._set_field(kwargs, "tls_client_key", "distributed.comm.tls.client.key")
         self._set_field(kwargs, "tls_client_cert", "distributed.comm.tls.client.cert")
@@ -149,10 +167,36 @@ def temporary(cls, **kwargs):
 
     def _set_field(self, kwargs, field, config_name):
         if field in kwargs:
-            out = kwargs[field]
+            val = kwargs[field]
+        else:
+            val = dask.config.get(config_name)
+        setattr(self, field, val)
+
+    def _set_tls_version_field(self, kwargs, field, config_name, default=None):
+        if field in kwargs:
+            val = kwargs[field]
+            valid = {None, ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3}
+            if val not in valid:
+                raise ValueError(
+                    f"{field}={val!r} is not supported, expected one of {list(valid)}"
+                )
+            if val is None:
+                val = default
         else:
-            out = dask.config.get(config_name)
-        setattr(self, field, out)
+            valid = {
+                None: default,
+                1.2: ssl.TLSVersion.TLSv1_2,
+                1.3: ssl.TLSVersion.TLSv1_3,
+            }
+            val = dask.config.get(config_name)
+            if val in valid:
+                val = valid[val]
+            else:
+                raise ValueError(
+                    f"{config_name}={val!r} is not supported, expected one of {list(valid)}"
+                )
+
+        setattr(self, field, val)
 
     def _attr_to_dict(self):
         keys = sorted(self.__slots__)
@@ -207,6 +251,11 @@ def _get_tls_context(self, tls, purpose):
             else:
                 ctx = ssl.create_default_context(purpose=purpose, cafile=ca)
 
+            if self.tls_min_version is not None:
+                ctx.minimum_version = self.tls_min_version
+            if self.tls_max_version is not None:
+                ctx.maximum_version = self.tls_max_version
+
             cert_in_memory = "\n" in cert
             key_in_memory = key is not None and "\n" in key
             if cert_in_memory or key_in_memory:


Test patch:
diff --git a/distributed/tests/test_security.py b/distributed/tests/test_security.py
--- a/distributed/tests/test_security.py
+++ b/distributed/tests/test_security.py
@@ -42,6 +42,8 @@ def test_defaults():
     assert sec.tls_scheduler_cert is None
     assert sec.tls_worker_key is None
     assert sec.tls_worker_cert is None
+    assert sec.tls_min_version is ssl.TLSVersion.TLSv1_2
+    assert sec.tls_max_version is None
     assert sec.extra_conn_args == {}
 
 
@@ -85,6 +87,29 @@ def test_from_config():
     assert sec.tls_worker_cert == "wcert.pem"
 
 
+@pytest.mark.parametrize("min_ver", [None, 1.2, 1.3])
+@pytest.mark.parametrize("max_ver", [None, 1.2, 1.3])
+def test_min_max_version_from_config(min_ver, max_ver):
+    versions = {1.2: ssl.TLSVersion.TLSv1_2, 1.3: ssl.TLSVersion.TLSv1_3}
+    min_ver_val = versions.get(min_ver, ssl.TLSVersion.TLSv1_2)
+    max_ver_val = versions.get(max_ver)
+    c = {
+        "distributed.comm.tls.min-version": min_ver,
+        "distributed.comm.tls.max-version": max_ver,
+    }
+    with dask.config.set(c):
+        sec = Security()
+    assert sec.tls_min_version == min_ver_val
+    assert sec.tls_max_version == max_ver_val
+
+
+@pytest.mark.parametrize("field", ["min-version", "max-version"])
+def test_min_max_version_config_errors(field):
+    with dask.config.set({f"distributed.comm.tls.{field}": "bad"}):
+        with pytest.raises(ValueError, match="'bad' is not supported, expected one of"):
+            sec = Security()
+
+
 def test_kwargs():
     c = {
         "distributed.comm.tls.ca-file": "ca.pem",
@@ -96,11 +121,15 @@ def test_kwargs():
             tls_scheduler_cert="newcert.pem",
             require_encryption=True,
             tls_ca_file=None,
+            tls_min_version=None,
+            tls_max_version=ssl.TLSVersion.TLSv1_3,
             extra_conn_args={"headers": {"Auth": "Token abc"}},
         )
     assert sec.require_encryption is True
     assert sec.tls_ca_file is None
     assert sec.tls_ciphers is None
+    assert sec.tls_min_version is ssl.TLSVersion.TLSv1_2
+    assert sec.tls_max_version is ssl.TLSVersion.TLSv1_3
     assert sec.tls_client_key is None
     assert sec.tls_client_cert is None
     assert sec.tls_scheduler_key == "skey.pem"
@@ -110,6 +139,12 @@ def test_kwargs():
     assert sec.extra_conn_args == {"headers": {"Auth": "Token abc"}}
 
 
+@pytest.mark.parametrize("key", ["tls_min_version", "tls_max_version"])
+def test_min_max_version_kwarg_errors(key):
+    with pytest.raises(ValueError, match="'bad' is not supported, expected one of"):
+        sec = Security(**{key: "bad"})
+
+
 def test_repr_temp_keys():
     sec = Security.temporary()
     representation = repr(sec)
@@ -166,12 +201,16 @@ def test_connection_args():
     def basic_checks(ctx):
         assert ctx.verify_mode == ssl.CERT_REQUIRED
         assert ctx.check_hostname is False
+        assert ctx.minimum_version is ssl.TLSVersion.TLSv1_2
+        assert ctx.maximum_version is ssl.TLSVersion.TLSv1_3
 
     c = {
         "distributed.comm.tls.ca-file": ca_file,
         "distributed.comm.tls.scheduler.key": key1,
         "distributed.comm.tls.scheduler.cert": cert1,
         "distributed.comm.tls.worker.cert": keycert1,
+        "distributed.comm.tls.min-version": None,
+        "distributed.comm.tls.max-version": 1.3,
     }
     with dask.config.set(c):
         sec = Security()
@@ -238,12 +277,16 @@ def test_listen_args():
     def basic_checks(ctx):
         assert ctx.verify_mode == ssl.CERT_REQUIRED
         assert ctx.check_hostname is False
+        assert ctx.minimum_version is ssl.TLSVersion.TLSv1_2
+        assert ctx.maximum_version is ssl.TLSVersion.TLSv1_3
 
     c = {
         "distributed.comm.tls.ca-file": ca_file,
         "distributed.comm.tls.scheduler.key": key1,
         "distributed.comm.tls.scheduler.cert": cert1,
         "distributed.comm.tls.worker.cert": keycert1,
+        "distributed.comm.tls.min-version": None,
+        "distributed.comm.tls.max-version": 1.3,
     }
     with dask.config.set(c):
         sec = Security()