From 24aaa8fd8f087872af173407423908a3c164ef06 Mon Sep 17 00:00:00 2001 From: Pavel Tisnovsky Date: Wed, 16 Oct 2024 11:19:05 +0200 Subject: [PATCH 1/2] TLS-related structures and constants Signed-off-by: Pavel Tisnovsky --- ols/utils/tls.py | 89 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 ols/utils/tls.py diff --git a/ols/utils/tls.py b/ols/utils/tls.py new file mode 100644 index 00000000..a154ec48 --- /dev/null +++ b/ols/utils/tls.py @@ -0,0 +1,89 @@ +"""TLS-related data structures and constants.""" + +# For further information please look at TLS security profiles source: +# wokeignore:rule=master +# https://github.com/openshift/api/blob/master/config/v1/types_tlssecurityprofile.go + +from enum import StrEnum + + +class TLSProfiles(StrEnum): + """TLS profile names.""" + + OLD_TYPE = "OldType" + INTERMEDIATE_TYPE = "IntermediateType" + MODERN_TYPE = "ModernType" + + +class TLSProtocolVersion(StrEnum): + """TLS protocol versions.""" + + # version 1.0 of the TLS security protocol. + VERSION_TLS_10 = "VersionTLS10" + # version 1.1 of the TLS security protocol. + VERSION_TLS_11 = "VersionTLS11" + # version 1.2 of the TLS security protocol. + VERSION_TLS_12 = "VersionTLS12" + # version 1.3 of the TLS security protocol. + VERSION_TLS_13 = "VersionTLS13" + + +# Minimal TLS versions required for each TLS profile +MIN_TLS_VERSIONS = { + TLSProfiles.OLD_TYPE: TLSProtocolVersion.VERSION_TLS_10, + TLSProfiles.INTERMEDIATE_TYPE: TLSProtocolVersion.VERSION_TLS_12, + TLSProfiles.MODERN_TYPE: TLSProtocolVersion.VERSION_TLS_13, +} + +# TLS ciphers defined for each TLS profile +TLS_CIPHERS = { + TLSProfiles.OLD_TYPE: [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-CHACHA20-POLY1305", + "ECDHE-RSA-CHACHA20-POLY1305", + "DHE-RSA-AES128-GCM-SHA256", + "DHE-RSA-AES256-GCM-SHA384", + "DHE-RSA-CHACHA20-POLY1305", + "ECDHE-ECDSA-AES128-SHA256", + "ECDHE-RSA-AES128-SHA256", + "ECDHE-ECDSA-AES128-SHA", + "ECDHE-RSA-AES128-SHA", + "ECDHE-ECDSA-AES256-SHA384", + "ECDHE-RSA-AES256-SHA384", + "ECDHE-ECDSA-AES256-SHA", + "ECDHE-RSA-AES256-SHA", + "DHE-RSA-AES128-SHA256", + "DHE-RSA-AES256-SHA256", + "AES128-GCM-SHA256", + "AES256-GCM-SHA384", + "AES128-SHA256", + "AES256-SHA256", + "AES128-SHA", + "AES256-SHA", + "DES-CBC3-SHA", + ], + TLSProfiles.INTERMEDIATE_TYPE: [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + "ECDHE-ECDSA-AES128-GCM-SHA256", + "ECDHE-RSA-AES128-GCM-SHA256", + "ECDHE-ECDSA-AES256-GCM-SHA384", + "ECDHE-RSA-AES256-GCM-SHA384", + "ECDHE-ECDSA-CHACHA20-POLY1305", + "ECDHE-RSA-CHACHA20-POLY1305", + "DHE-RSA-AES128-GCM-SHA256", + "DHE-RSA-AES256-GCM-SHA384", + ], + TLSProfiles.MODERN_TYPE: [ + "TLS_AES_128_GCM_SHA256", + "TLS_AES_256_GCM_SHA384", + "TLS_CHACHA20_POLY1305_SHA256", + ], +} From 089912b09427ff2e0739ae800fb96aa4a1965121 Mon Sep 17 00:00:00 2001 From: Pavel Tisnovsky Date: Thu, 17 Oct 2024 15:37:52 +0200 Subject: [PATCH 2/2] TLS security profile in config Signed-off-by: Pavel Tisnovsky --- ols/app/models/config.py | 53 +++++++++ ols/utils/tls.py | 1 + tests/unit/app/models/test_config.py | 154 +++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) diff --git a/ols/app/models/config.py b/ols/app/models/config.py index 17bc5628..5c0f6e55 100644 --- a/ols/app/models/config.py +++ b/ols/app/models/config.py @@ -17,6 +17,7 @@ ) from ols import constants +from ols.utils import tls def _is_valid_http_url(url: AnyHttpUrl) -> bool: @@ -872,6 +873,51 @@ def check_storage_location_is_set_when_needed(self) -> Self: return self +class TLSSecurityProfile(BaseModel): + """TLS security profile structure.""" + + profile_type: Optional[str] = None + min_tls_version: Optional[str] = None + ciphers: Optional[list[str]] = None + + def __init__(self, data: Optional[dict] = None) -> None: + """Initialize configuration and perform basic validation.""" + super().__init__() + if data is not None: + self.profile_type = data.get("type") + self.min_tls_version = data.get("minTLSVersion") + self.ciphers = data.get("ciphers") + + def validate_yaml(self) -> None: + """Validate structure content.""" + # check the TLS profile type + if self.profile_type is not None: + try: + tls.TLSProfiles(self.profile_type) + except ValueError: + raise InvalidConfigurationError( + f"Invalid TLS profile type '{self.profile_type}'" + ) + # check the TLS protocol version + if self.min_tls_version is not None: + try: + tls.TLSProtocolVersion(self.min_tls_version) + except ValueError: + raise InvalidConfigurationError( + f"Invalid minimal TLS version '{self.min_tls_version}'" + ) + # check ciphers + if self.ciphers is not None: + # just perform the check for non-custom TLS profile type + if self.profile_type is not None and self.profile_type != "Custom": + supported_ciphers = tls.TLS_CIPHERS[tls.TLSProfiles(self.profile_type)] + for cipher in self.ciphers: + if cipher not in supported_ciphers: + raise InvalidConfigurationError( + f"Unsupported cipher '{cipher}' found in configuration" + ) + + class OLSConfig(BaseModel): """OLS configuration.""" @@ -889,6 +935,7 @@ class OLSConfig(BaseModel): query_validation_method: Optional[str] = constants.QueryValidationMethod.DISABLED user_data_collection: UserDataCollection = UserDataCollection() + tls_security_profile: Optional[TLSSecurityProfile] = None extra_ca: list[FilePath] = [] certificate_directory: Optional[str] = None @@ -932,6 +979,9 @@ def __init__( self.certificate_directory = data.get( "certificate_directory", constants.DEFAULT_CERTIFICATE_DIRECTORY ) + self.tls_security_profile = TLSSecurityProfile( + data.get("tlsSecurityProfile", None) + ) def __eq__(self, other: object) -> bool: """Compare two objects for equality.""" @@ -947,6 +997,7 @@ def __eq__(self, other: object) -> bool: and self.tls_config == other.tls_config and self.certificate_directory == other.certificate_directory and self.system_prompt == other.system_prompt + and self.tls_security_profile == other.tls_security_profile ) return False @@ -961,6 +1012,8 @@ def validate_yaml(self, disable_tls: bool = False) -> None: if self.query_filters is not None: for query_filter in self.query_filters: query_filter.validate_yaml() + if self.tls_security_profile is not None: + self.tls_security_profile.validate_yaml() valid_query_validation_methods = list(constants.QueryValidationMethod) if self.query_validation_method not in valid_query_validation_methods: diff --git a/ols/utils/tls.py b/ols/utils/tls.py index a154ec48..131e8664 100644 --- a/ols/utils/tls.py +++ b/ols/utils/tls.py @@ -13,6 +13,7 @@ class TLSProfiles(StrEnum): OLD_TYPE = "OldType" INTERMEDIATE_TYPE = "IntermediateType" MODERN_TYPE = "ModernType" + CUSTOM_TYPE = "Custom" class TLSProtocolVersion(StrEnum): diff --git a/tests/unit/app/models/test_config.py b/tests/unit/app/models/test_config.py index a61ec099..b7039bf9 100644 --- a/tests/unit/app/models/test_config.py +++ b/tests/unit/app/models/test_config.py @@ -5,6 +5,7 @@ import pytest from pydantic import ValidationError +import ols.utils.tls as tls from ols import constants from ols.app.models.config import ( AuthenticationConfig, @@ -24,6 +25,7 @@ RedisConfig, ReferenceContent, TLSConfig, + TLSSecurityProfile, UserDataCollection, UserDataCollectorConfig, ) @@ -1405,6 +1407,158 @@ def test_invalid_values(): LoggingConfig(uvicorn_log_level="foo") +def test_tls_security_profile_default_values(): + """Test the TLSSecurityProfile model.""" + tls_security_profile = TLSSecurityProfile() + assert tls_security_profile.profile_type is None + assert tls_security_profile.min_tls_version is None + assert tls_security_profile.ciphers is None + + +def test_tls_security_profile_correct_values(): + """Test the TLSSecurityProfile model.""" + tls_security_profile = TLSSecurityProfile( + { + "type": "Custom", + "minTLSVersion": "VersionTLS13", + "ciphers": [ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + ], + } + ) + assert tls_security_profile.profile_type == "Custom" + assert tls_security_profile.min_tls_version == "VersionTLS13" + assert "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256" in tls_security_profile.ciphers + assert "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384" in tls_security_profile.ciphers + + +tls_types = ( + tls.TLSProfiles.OLD_TYPE, + tls.TLSProfiles.INTERMEDIATE_TYPE, + tls.TLSProfiles.MODERN_TYPE, + tls.TLSProfiles.CUSTOM_TYPE, +) + + +tls_versions = ( + tls.TLSProtocolVersion.VERSION_TLS_10, + tls.TLSProtocolVersion.VERSION_TLS_11, + tls.TLSProtocolVersion.VERSION_TLS_12, + tls.TLSProtocolVersion.VERSION_TLS_13, +) + + +@pytest.mark.parametrize("tls_type", tls_types) +@pytest.mark.parametrize("min_tls_version", tls_versions) +def test_tls_security_profile_validate_yaml(tls_type, min_tls_version): + """Test the TLSSecurityProfile model validation.""" + tls_security_profile = TLSSecurityProfile() + tls_security_profile.validate_yaml() + + tls_security_profile = TLSSecurityProfile( + { + "type": tls_type, + "minTLSVersion": min_tls_version, + "ciphers": [], + } + ) + tls_security_profile.validate_yaml() + + +def test_tls_security_profile_validate_invalid_yaml_type(): + """Test the TLSSecurityProfile model validation.""" + tls_security_profile = TLSSecurityProfile( + { + "type": "Foo", + "minTLSVersion": "VersionTLS13", + "ciphers": [ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + ], + } + ) + with pytest.raises( + InvalidConfigurationError, + match="Invalid TLS profile type 'Foo'", + ): + tls_security_profile.validate_yaml() + + +def test_tls_security_profile_validate_invalid_yaml_min_tls_version(): + """Test the TLSSecurityProfile model validation.""" + tls_security_profile = TLSSecurityProfile( + { + "type": "Custom", + "minTLSVersion": "foo", + "ciphers": [ + "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", + ], + } + ) + with pytest.raises( + InvalidConfigurationError, + match="Invalid minimal TLS version 'foo'", + ): + tls_security_profile.validate_yaml() + + +tls_types_without_custom = ( + tls.TLSProfiles.OLD_TYPE, + tls.TLSProfiles.INTERMEDIATE_TYPE, + tls.TLSProfiles.MODERN_TYPE, +) + + +@pytest.mark.parametrize("tls_type", tls_types_without_custom) +def test_tls_security_profile_validate_invalid_yaml_ciphers(tls_type): + """Test the TLSSecurityProfile model validation.""" + tls_security_profile = TLSSecurityProfile( + { + "type": tls_type, + "minTLSVersion": "VersionTLS13", + "ciphers": [ + "foo", + "bar", + ], + } + ) + with pytest.raises( + InvalidConfigurationError, + match="Unsupported cipher 'foo' found in configuration", + ): + tls_security_profile.validate_yaml() + + +def test_tls_security_profile_equality(): + """Test equality or inequality of two security profiles.""" + profile1 = TLSSecurityProfile( + { + "type": "Custom", + "minTLSVersion": "VersionTLS13", + "ciphers": [], + } + ) + profile2 = TLSSecurityProfile( + { + "type": "Custom", + "minTLSVersion": "VersionTLS13", + "ciphers": [], + } + ) + assert profile1 == profile2 + + profile3 = TLSSecurityProfile( + { + "type": "Custom", + "minTLSVersion": "VersionTLS12", + "ciphers": [], + } + ) + assert profile1 != profile3 + + def test_tls_config_default_values(): """Test the TLSConfig model.""" tls_config = TLSConfig()