diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d2858fb..20d2ff32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Fix: expand does not take timezones into account * Fix: expand does not support overridden recurring events * Fix: expand does not honor start and end times +* Add: option [server] protocol + ciphersuite for optional restrictions on SSL socket ## 3.3.0 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 596a5125..d3de50d4 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -703,6 +703,22 @@ authentication plugin that extracts the username from the certificate. Default: +##### protocol + +Accepted SSL protocol (maybe not all supported by underlying OpenSSL version) +Example for secure configuration: ALL -SSLv3 -TLSv1 -TLSv1.1 +Format: Apache SSLProtocol list (from "mod_ssl") + +Default: (system default) + +##### ciphersuite + +Accepted SSL ciphersuite (maybe not all supported by underlying OpenSSL version) +Example for secure configuration: DHE:ECDHE:-NULL:-SHA +Format: OpenSSL cipher list (see also "man openssl-ciphers") + +Default: (system-default) + #### encoding ##### request diff --git a/config b/config index cb50ab72..8415807b 100644 --- a/config +++ b/config @@ -40,6 +40,12 @@ # TCP traffic between Radicale and a reverse proxy #certificate_authority = +# SSL protocol, secure configuration: ALL -SSLv3 -TLSv1 -TLSv1.1 +#protocol = (default) + +# SSL ciphersuite, secure configuration: DHE:ECDHE:-NULL:-SHA (see also "man openssl-ciphers") +#ciphersuite = (default) + [encoding] diff --git a/radicale/config.py b/radicale/config.py index 3e91a6fa..2a70a7e7 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -141,6 +141,14 @@ def json_str(value: Any) -> dict: "aliases": ("-s", "--ssl",), "opposite_aliases": ("-S", "--no-ssl",), "type": bool}), + ("protocol", { + "value": "", + "help": "SSL/TLS protocol (Apache SSLProtocol format)", + "type": str}), + ("ciphersuite", { + "value": "", + "help": "SSL/TLS Cipher Suite (OpenSSL cipher list format)", + "type": str}), ("certificate", { "value": "/etc/ssl/radicale.cert.pem", "help": "set certificate file", diff --git a/radicale/server.py b/radicale/server.py index 2f03837c..80e58fd3 100644 --- a/radicale/server.py +++ b/radicale/server.py @@ -34,7 +34,7 @@ Tuple, Union) from urllib.parse import unquote -from radicale import Application, config +from radicale import Application, config, utils from radicale.log import logger COMPAT_EAI_ADDRFAMILY: int @@ -167,6 +167,8 @@ def server_bind(self) -> None: certfile: str = self.configuration.get("server", "certificate") keyfile: str = self.configuration.get("server", "key") cafile: str = self.configuration.get("server", "certificate_authority") + protocol: str = self.configuration.get("server", "protocol") + ciphersuite: str = self.configuration.get("server", "ciphersuite") # Test if the files can be read for name, filename in [("certificate", certfile), ("key", keyfile), ("certificate_authority", cafile)]: @@ -183,8 +185,33 @@ def server_bind(self) -> None: "(%s)" % (type_name, name, "server", source, filename, e)) from e context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + logger.info("SSL load files certificate='%s' key='%s'", certfile, keyfile) context.load_cert_chain(certfile=certfile, keyfile=keyfile) + if protocol: + logger.info("SSL set explicit protocols (maybe not all supported by underlying OpenSSL): '%s'", protocol) + context.options = utils.ssl_context_options_by_protocol(protocol, context.options) + context.minimum_version = utils.ssl_context_minimum_version_by_options(context.options) + if (context.minimum_version == 0): + raise RuntimeError("No SSL minimum protocol active") + context.maximum_version = utils.ssl_context_maximum_version_by_options(context.options) + if (context.maximum_version == 0): + raise RuntimeError("No SSL maximum protocol active") + else: + logger.info("SSL active protocols: (system-default)") + logger.debug("SSL minimum acceptable protocol: %s", context.minimum_version) + logger.debug("SSL maximum acceptable protocol: %s", context.maximum_version) + logger.info("SSL accepted protocols: %s", ' '.join(utils.ssl_get_protocols(context))) + if ciphersuite: + logger.info("SSL set explicit ciphersuite (maybe not all supported by underlying OpenSSL): '%s'", ciphersuite) + context.set_ciphers(ciphersuite) + else: + logger.info("SSL active ciphersuite: (system-default)") + cipherlist = [] + for entry in context.get_ciphers(): + cipherlist.append(entry["name"]) + logger.info("SSL accepted ciphers: %s", ' '.join(cipherlist)) if cafile: + logger.info("SSL enable mandatory client certificate verification using CA file='%s'", cafile) context.load_verify_locations(cafile=cafile) context.verify_mode = ssl.CERT_REQUIRED self.socket = context.wrap_socket( diff --git a/radicale/utils.py b/radicale/utils.py index a6512646..50a8d822 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -2,6 +2,7 @@ # Copyright © 2014 Jean-Marc Martins # Copyright © 2012-2017 Guillaume Ayoub # Copyright © 2017-2018 Unrud +# Copyright © 2024-2024 Peter Bieringer # # This library is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -16,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with Radicale. If not, see . +import ssl from importlib import import_module, metadata from typing import Callable, Sequence, Type, TypeVar, Union @@ -47,3 +49,129 @@ def load_plugin(internal_types: Sequence[str], module_name: str, def package_version(name): return metadata.version(name) + + +def ssl_context_options_by_protocol(protocol: str, ssl_context_options): + logger.debug("SSL protocol string: '%s' and current SSL context options: '0x%x'", protocol, ssl_context_options) + # disable any protocol by default + logger.debug("SSL context options, disable ALL by default") + ssl_context_options |= ssl.OP_NO_SSLv2 + ssl_context_options |= ssl.OP_NO_SSLv3 + ssl_context_options |= ssl.OP_NO_TLSv1 + ssl_context_options |= ssl.OP_NO_TLSv1_1 + ssl_context_options |= ssl.OP_NO_TLSv1_2 + ssl_context_options |= ssl.OP_NO_TLSv1_3 + logger.debug("SSL cleared SSL context options: '0x%x'", ssl_context_options) + for entry in protocol.split(): + entry = entry.strip('+') # remove trailing '+' + if entry == "ALL": + logger.debug("SSL context options, enable ALL (some maybe not supported by underlying OpenSSL, SSLv2 not enabled at all)") + ssl_context_options &= ~ssl.OP_NO_SSLv3 + ssl_context_options &= ~ssl.OP_NO_TLSv1 + ssl_context_options &= ~ssl.OP_NO_TLSv1_1 + ssl_context_options &= ~ssl.OP_NO_TLSv1_2 + ssl_context_options &= ~ssl.OP_NO_TLSv1_3 + elif entry == "SSLv2": + logger.warning("SSL context options, ignore SSLv2 (totally insecure)") + elif entry == "SSLv3": + ssl_context_options &= ~ssl.OP_NO_SSLv3 + logger.debug("SSL context options, enable SSLv3 (maybe not supported by underlying OpenSSL)") + elif entry == "TLSv1": + ssl_context_options &= ~ssl.OP_NO_TLSv1 + logger.debug("SSL context options, enable TLSv1 (maybe not supported by underlying OpenSSL)") + elif entry == "TLSv1.1": + logger.debug("SSL context options, enable TLSv1.1 (maybe not supported by underlying OpenSSL)") + ssl_context_options &= ~ssl.OP_NO_TLSv1_1 + elif entry == "TLSv1.2": + logger.debug("SSL context options, enable TLSv1.2") + ssl_context_options &= ~ssl.OP_NO_TLSv1_2 + elif entry == "TLSv1.3": + logger.debug("SSL context options, enable TLSv1.3") + ssl_context_options &= ~ssl.OP_NO_TLSv1_3 + elif entry == "-ALL": + logger.debug("SSL context options, disable ALL") + ssl_context_options |= ssl.OP_NO_SSLv2 + ssl_context_options |= ssl.OP_NO_SSLv3 + ssl_context_options |= ssl.OP_NO_TLSv1 + ssl_context_options |= ssl.OP_NO_TLSv1_1 + ssl_context_options |= ssl.OP_NO_TLSv1_2 + ssl_context_options |= ssl.OP_NO_TLSv1_3 + elif entry == "-SSLv2": + ssl_context_options |= ssl.OP_NO_SSLv2 + logger.debug("SSL context options, disable SSLv2") + elif entry == "-SSLv3": + ssl_context_options |= ssl.OP_NO_SSLv3 + logger.debug("SSL context options, disable SSLv3") + elif entry == "-TLSv1": + logger.debug("SSL context options, disable TLSv1") + ssl_context_options |= ssl.OP_NO_TLSv1 + elif entry == "-TLSv1.1": + logger.debug("SSL context options, disable TLSv1.1") + ssl_context_options |= ssl.OP_NO_TLSv1_1 + elif entry == "-TLSv1.2": + logger.debug("SSL context options, disable TLSv1.2") + ssl_context_options |= ssl.OP_NO_TLSv1_2 + elif entry == "-TLSv1.3": + logger.debug("SSL context options, disable TLSv1.3") + ssl_context_options |= ssl.OP_NO_TLSv1_3 + else: + raise RuntimeError("SSL protocol config contains unsupported entry '%s'" % (entry)) + + logger.debug("SSL resulting context options: '0x%x'", ssl_context_options) + return ssl_context_options + + +def ssl_context_minimum_version_by_options(ssl_context_options): + logger.debug("SSL calculate minimum version by context options: '0x%x'", ssl_context_options) + ssl_context_minimum_version = ssl.TLSVersion.SSLv3 # default + if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_minimum_version == ssl.TLSVersion.SSLv3)): + ssl_context_minimum_version = ssl.TLSVersion.TLSv1 + if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1)): + ssl_context_minimum_version = ssl.TLSVersion.TLSv1_1 + if ((ssl_context_options & ssl.OP_NO_TLSv1_1) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_1)): + ssl_context_minimum_version = ssl.TLSVersion.TLSv1_2 + if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_2)): + ssl_context_minimum_version = ssl.TLSVersion.TLSv1_3 + if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_minimum_version == ssl.TLSVersion.TLSv1_3)): + ssl_context_minimum_version = 0 # all disabled + + logger.debug("SSL context options: '0x%x' results in minimum version: %s", ssl_context_options, ssl_context_minimum_version) + return ssl_context_minimum_version + + +def ssl_context_maximum_version_by_options(ssl_context_options): + logger.debug("SSL calculate maximum version by context options: '0x%x'", ssl_context_options) + ssl_context_maximum_version = ssl.TLSVersion.TLSv1_3 # default + if ((ssl_context_options & ssl.OP_NO_TLSv1_3) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_3)): + ssl_context_maximum_version = ssl.TLSVersion.TLSv1_2 + if ((ssl_context_options & ssl.OP_NO_TLSv1_2) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_2)): + ssl_context_maximum_version = ssl.TLSVersion.TLSv1_1 + if ((ssl_context_options & ssl.OP_NO_TLSv1_1) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1_1)): + ssl_context_maximum_version = ssl.TLSVersion.TLSv1 + if ((ssl_context_options & ssl.OP_NO_TLSv1) and (ssl_context_maximum_version == ssl.TLSVersion.TLSv1)): + ssl_context_maximum_version = ssl.TLSVersion.SSLv3 + if ((ssl_context_options & ssl.OP_NO_SSLv3) and (ssl_context_maximum_version == ssl.TLSVersion.SSLv3)): + ssl_context_maximum_version = 0 + + logger.debug("SSL context options: '0x%x' results in maximum version: %s", ssl_context_options, ssl_context_maximum_version) + return ssl_context_maximum_version + + +def ssl_get_protocols(context): + protocols = [] + if not (context.options & ssl.OP_NO_SSLv3): + if (context.minimum_version < ssl.TLSVersion.TLSv1): + protocols.append("SSLv3") + if not (context.options & ssl.OP_NO_TLSv1): + if (context.minimum_version < ssl.TLSVersion.TLSv1_1) and (context.maximum_version >= ssl.TLSVersion.TLSv1): + protocols.append("TLSv1") + if not (context.options & ssl.OP_NO_TLSv1_1): + if (context.minimum_version < ssl.TLSVersion.TLSv1_2) and (context.maximum_version >= ssl.TLSVersion.TLSv1_1): + protocols.append("TLSv1.1") + if not (context.options & ssl.OP_NO_TLSv1_2): + if (context.minimum_version <= ssl.TLSVersion.TLSv1_2) and (context.maximum_version >= ssl.TLSVersion.TLSv1_2): + protocols.append("TLSv1.2") + if not (context.options & ssl.OP_NO_TLSv1_3): + if (context.minimum_version <= ssl.TLSVersion.TLSv1_3) and (context.maximum_version >= ssl.TLSVersion.TLSv1_3): + protocols.append("TLSv1.3") + return protocols diff --git a/setup.cfg b/setup.cfg index 10786e2a..ba916303 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,5 +2,5 @@ # Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398) # DNE: DOES-NOT-EXIST select = E,F,W,C90,DNE000 -ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501 +ignore = E121,E123,E126,E226,E24,E704,W503,W504,DNE000,E501,E261 extend-exclude = build