From e8690245afde655098ca82d2dc46e6695fa4685d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Taylan=20Bak=C4=B1rc=C4=B1o=C4=9Flu?= Date: Tue, 18 Nov 2025 20:44:36 +0300 Subject: [PATCH] fix(ssl): add change detection and validators for SSL advanced options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical additions: 1. Change Detection (backend/routers/config.py) - Added SSL advanced options to bulk import change detection logic - Detects changes in ssl_alpn, ssl_npn, ssl_ciphers, ssl_ciphersuites - Detects changes in ssl_min_ver, ssl_max_ver, ssl_strict_sni - Changes will now appear in version diff 2. Pydantic Validators (backend/models/frontend.py, backend/models/backend.py) - TLS version validator: Only allows valid versions (SSLv3, TLSv1.0-1.3) - ALPN protocol validator: Only allows h2, http/1.1, http/1.0, h2c, spdy/* - NPN protocol validator: Only allows http/1.1, http/1.0, spdy/* - Prevents invalid values from being saved to database - HAProxy validation will not fail due to invalid SSL options Impact: - Bulk import will correctly detect SSL option changes - Apply Management diff will show SSL changes - User cannot enter invalid TLS versions or protocols - Improved UX with early validation errors Previous fixes in this series: - Frontend GET API: Added SSL fields to response - Bulk Import UPDATE: Added SSL fields to UPDATE statement - Backend Server GET API: Added SSL fields to response Test: Bulk import with ALPN change → Should see change in version diff --- backend/models/backend.py | 8 ++++++++ backend/models/frontend.py | 30 ++++++++++++++++++++++++++++++ backend/routers/config.py | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+) diff --git a/backend/models/backend.py b/backend/models/backend.py index c4c53ed..c6a6597 100644 --- a/backend/models/backend.py +++ b/backend/models/backend.py @@ -25,6 +25,14 @@ class ServerConfig(BaseModel): fall: Optional[int] = None rise: Optional[int] = None is_active: bool = True + + @validator('ssl_min_ver', 'ssl_max_ver') + def validate_tls_version(cls, v): + if v is not None: + valid_versions = ['SSLv3', 'TLSv1.0', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'] + if v not in valid_versions: + raise ValueError(f'Invalid TLS version: {v}. Must be one of: {", ".join(valid_versions)}') + return v class BackendConfig(BaseModel): name: str diff --git a/backend/models/frontend.py b/backend/models/frontend.py index 25c1661..bf408dc 100644 --- a/backend/models/frontend.py +++ b/backend/models/frontend.py @@ -233,6 +233,36 @@ class FrontendConfig(BaseModel): if v is not None and (v < 1 or v > 100000): raise ValueError('Max connections must be between 1 and 100000') return v + + @validator('ssl_min_ver', 'ssl_max_ver') + def validate_tls_version(cls, v): + if v is not None: + valid_versions = ['SSLv3', 'TLSv1.0', 'TLSv1.1', 'TLSv1.2', 'TLSv1.3'] + if v not in valid_versions: + raise ValueError(f'Invalid TLS version: {v}. Must be one of: {", ".join(valid_versions)}') + return v + + @validator('ssl_alpn') + def validate_alpn(cls, v): + if v is not None and v.strip(): + # ALPN protocols are comma-separated + protocols = [p.strip() for p in v.split(',')] + valid_protocols = ['h2', 'http/1.1', 'http/1.0', 'h2c', 'spdy/3', 'spdy/2', 'spdy/1'] + for proto in protocols: + if proto and proto not in valid_protocols: + raise ValueError(f'Invalid ALPN protocol: {proto}. Valid protocols: {", ".join(valid_protocols)}') + return v + + @validator('ssl_npn') + def validate_npn(cls, v): + if v is not None and v.strip(): + # NPN protocols are comma-separated (legacy) + protocols = [p.strip() for p in v.split(',')] + valid_protocols = ['http/1.1', 'http/1.0', 'spdy/3', 'spdy/2', 'spdy/1'] + for proto in protocols: + if proto and proto not in valid_protocols: + raise ValueError(f'Invalid NPN protocol: {proto}. Valid protocols: {", ".join(valid_protocols)}') + return v @validator('acl_rules') def validate_acl_rules(cls, v): diff --git a/backend/routers/config.py b/backend/routers/config.py index 51e1f7f..80f43ba 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -1060,6 +1060,29 @@ async def parse_bulk_config( has_changes = True changes["tcp_request_rules"] = {"old": existing["tcp_request_rules"], "new": frontend["tcp_request_rules"]} + # CRITICAL: SSL Advanced Options change detection + if frontend.get("ssl_alpn") is not None and frontend.get("ssl_alpn") != existing.get("ssl_alpn"): + has_changes = True + changes["ssl_alpn"] = {"old": existing.get("ssl_alpn"), "new": frontend.get("ssl_alpn")} + if frontend.get("ssl_npn") is not None and frontend.get("ssl_npn") != existing.get("ssl_npn"): + has_changes = True + changes["ssl_npn"] = {"old": existing.get("ssl_npn"), "new": frontend.get("ssl_npn")} + if frontend.get("ssl_ciphers") is not None and frontend.get("ssl_ciphers") != existing.get("ssl_ciphers"): + has_changes = True + changes["ssl_ciphers"] = {"old": existing.get("ssl_ciphers"), "new": frontend.get("ssl_ciphers")} + if frontend.get("ssl_ciphersuites") is not None and frontend.get("ssl_ciphersuites") != existing.get("ssl_ciphersuites"): + has_changes = True + changes["ssl_ciphersuites"] = {"old": existing.get("ssl_ciphersuites"), "new": frontend.get("ssl_ciphersuites")} + if frontend.get("ssl_min_ver") is not None and frontend.get("ssl_min_ver") != existing.get("ssl_min_ver"): + has_changes = True + changes["ssl_min_ver"] = {"old": existing.get("ssl_min_ver"), "new": frontend.get("ssl_min_ver")} + if frontend.get("ssl_max_ver") is not None and frontend.get("ssl_max_ver") != existing.get("ssl_max_ver"): + has_changes = True + changes["ssl_max_ver"] = {"old": existing.get("ssl_max_ver"), "new": frontend.get("ssl_max_ver")} + if "ssl_strict_sni" in frontend and frontend.get("ssl_strict_sni") != existing.get("ssl_strict_sni", False): + has_changes = True + changes["ssl_strict_sni"] = {"old": existing.get("ssl_strict_sni", False), "new": frontend.get("ssl_strict_sni")} + # Additional frontend fields (timeout_http_request, rate_limit, compression, log_separate, monitor_uri) if frontend.get("timeout_http_request") and frontend["timeout_http_request"] != existing.get("timeout_http_request"): has_changes = True