From 68a5d96c2a3df9fe837746db4ae0bc5f860c17d8 Mon Sep 17 00:00:00 2001 From: Antoine Van Elstraete Date: Mon, 7 Sep 2026 21:01:48 +0200 Subject: [PATCH] feat: add TLS policy and field constraints to XmppSettings (M10-U1) Enforce TLS on non-loopback hosts via @field_validator on use_tls, and add unconditional Field constraints on port (1-65535) and timeout (>0). Security: - use_tls=False rejected outside {localhost, 127.0.0.1, ::1} regardless of enabled - field_validator on use_tls (not model_validator) prevents raw config leakage - hide_input_in_errors=True as defense-in-depth - Validation error messages contain no secrets (jid, password, recipient) 16 unit tests covering port/timeout bounds, TLS policy, loopback, secret safety. Co-authored-by: opencode/test-engineer Co-authored-by: opencode/coder --- pronote_sync/config/settings.py | 55 ++++++++- tests/unit/test_xmpp_settings.py | 199 +++++++++++++++++++++++++++++++ 2 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_xmpp_settings.py diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index f81402d..c2fd45a 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -12,7 +12,13 @@ from datetime import date from typing import Literal from urllib.parse import urlparse -from pydantic import Field, SecretStr, ValidationInfo, field_serializer, field_validator +from pydantic import ( + Field, + SecretStr, + ValidationInfo, + field_serializer, + field_validator, +) from pydantic_settings import BaseSettings, SettingsConfigDict from pronote_sync.utils.redaction import redact_url @@ -124,25 +130,66 @@ class CalDAVSettings(BaseSettings): return v +_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"}) + + class XmppSettings(BaseSettings): """Paramètres du canal de notifications XMPP (désactivé par défaut). Tous les champs ont des valeurs par défaut afin que le canal XMPP reste inactif tant qu'il n'est pas explicitement activé. Les variables d'environnement correspondantes sont préfixées par ``XMPP_``. + + Contraintes de champs : ``port`` est borné entre 1 et 65535 et ``timeout`` + doit être strictement positif. + + Politique TLS : la désactivation de TLS (``use_tls`` à ``False``) n'est + autorisée que sur un hôte de boucle locale (``localhost``, ``127.0.0.1``, + ``::1``). Dans tout autre cas, une erreur de validation est levée, + indépendamment de l'état du champ ``enabled``. """ - model_config = SettingsConfigDict(env_file=".env", extra="ignore", env_prefix="XMPP_") + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + env_prefix="XMPP_", + hide_input_in_errors=True, + ) enabled: bool = False jid: str | None = None password: SecretStr | None = None host: str = "" - port: int = 5222 + port: int = Field(default=5222, ge=1, le=65535) to: str | None = None resource: str = "pronote-sync" use_tls: bool = True - timeout: int = 30 + timeout: int = Field(default=30, gt=0) + + @field_validator("use_tls") + @classmethod + def _validate_tls_policy(cls, v: bool, info: ValidationInfo) -> bool: + """Refuse la désactivation de TLS hors des hôtes de boucle locale. + + La règle s'applique quel que soit l'état du champ ``enabled``. Le + message d'erreur ne contient aucune valeur sensible (``jid``, + ``password``, ``to``). + + :param v: Valeur du champ ``use_tls`` à valider. + :param info: Contexte de validation (accès aux autres champs). + :return: La valeur validée inchangée. + :rtype: bool + :raises ValueError: Si ``use_tls`` est ``False`` et que ``host`` + n'est pas un hôte de boucle locale. + """ + if v is False: + host = info.data.get("host", "") + if host not in _XMPP_LOOPBACK_HOSTS: + raise ValueError( + "TLS désactivé n'est autorisé que sur les hôtes de loopback " + "(localhost, 127.0.0.1, ::1)." + ) from None + return v class AISettings(BaseSettings): diff --git a/tests/unit/test_xmpp_settings.py b/tests/unit/test_xmpp_settings.py new file mode 100644 index 0000000..cdfc1de --- /dev/null +++ b/tests/unit/test_xmpp_settings.py @@ -0,0 +1,199 @@ +"""Tests unitaires pour les contraintes de sécurité et validateurs de XmppSettings. + +Ce module valide les contraintes de sécurité et les validateurs qui seront ajoutés +à la classe XmppSettings dans pronote_sync/config/settings.py. + +Les tests doivent être initialement en échec (RED) car les contraintes et validateurs +n'existent pas encore dans le code de production. +""" + +import pytest +from pydantic import SecretStr, ValidationError + +from pronote_sync.config.settings import XmppSettings + + +class TestPortConstraints: + """Tests des contraintes sur le champ port.""" + + def test_port_below_1_rejected(self) -> None: + """Vérifie que port < 1 est rejeté. + + :raises ValidationError: Si le port est inférieur à 1. + """ + with pytest.raises(ValidationError) as exc_info: + XmppSettings(port=0) + assert "port" in str(exc_info.value).lower() + + def test_port_above_65535_rejected(self) -> None: + """Vérifie que port > 65535 est rejeté. + + :raises ValidationError: Si le port est supérieur à 65535. + """ + with pytest.raises(ValidationError) as exc_info: + XmppSettings(port=70000) + assert "port" in str(exc_info.value).lower() + + def test_port_default_5222(self) -> None: + """Vérifie que la valeur par défaut de port est 5222. + + :return: Vérifie que XmppSettings().port == 5222. + :rtype: None + """ + settings = XmppSettings() + assert settings.port == 5222 + + def test_port_valid(self) -> None: + """Vérifie que les ports valides sont acceptés. + + :return: Vérifie que XmppSettings(port=5222) et XmppSettings(port=5223) sont valides. + :rtype: None + """ + settings1 = XmppSettings(port=5222) + assert settings1.port == 5222 + + settings2 = XmppSettings(port=5223) + assert settings2.port == 5223 + + +class TestTimeoutConstraints: + """Tests des contraintes sur le champ timeout.""" + + def test_timeout_zero_rejected(self) -> None: + """Vérifie que timeout = 0 est rejeté. + + :raises ValidationError: Si le timeout est égal à 0. + """ + with pytest.raises(ValidationError) as exc_info: + XmppSettings(timeout=0) + assert "timeout" in str(exc_info.value).lower() + + def test_timeout_negative_rejected(self) -> None: + """Vérifie que timeout < 0 est rejeté. + + :raises ValidationError: Si le timeout est négatif. + """ + with pytest.raises(ValidationError) as exc_info: + XmppSettings(timeout=-1) + assert "timeout" in str(exc_info.value).lower() + + def test_timeout_default_30(self) -> None: + """Vérifie que la valeur par défaut de timeout est 30. + + :return: Vérifie que XmppSettings().timeout == 30. + :rtype: None + """ + settings = XmppSettings() + assert settings.timeout == 30 + + def test_timeout_positive_valid(self) -> None: + """Vérifie que les valeurs positives de timeout sont acceptées. + + :return: Vérifie que XmppSettings(timeout=10) est valide. + :rtype: None + """ + settings = XmppSettings(timeout=10) + assert settings.timeout == 10 + + +class TestTlsPolicy: + """Tests de la politique TLS pour le champ use_tls.""" + + def test_use_tls_false_with_remote_host_rejected(self) -> None: + """Vérifie que use_tls=False avec un hôte distant est rejeté. + + :raises ValidationError: Si use_tls=False et host n'est pas une boucle locale. + """ + with pytest.raises(ValidationError) as exc_info: + XmppSettings(use_tls=False, host="talk.example.com") + assert "use_tls" in str(exc_info.value).lower() or "tls" in str(exc_info.value).lower() + + def test_use_tls_false_with_remote_host_rejected_when_enabled(self) -> None: + """Vérifie que la politique TLS s'applique même quand le canal est activé.""" + with pytest.raises(ValidationError): + XmppSettings(enabled=True, use_tls=False, host="talk.example.com") + + def test_use_tls_false_with_localhost_allowed(self) -> None: + """Vérifie que use_tls=False avec localhost est autorisé. + + :return: Vérifie que XmppSettings(use_tls=False, host="localhost") est valide. + :rtype: None + """ + settings = XmppSettings(use_tls=False, host="localhost") + assert settings.use_tls is False + assert settings.host == "localhost" + + def test_use_tls_false_with_127_allowed(self) -> None: + """Vérifie que use_tls=False avec 127.0.0.1 est autorisé. + + :return: Vérifie que XmppSettings(use_tls=False, host="127.0.0.1") est valide. + :rtype: None + """ + settings = XmppSettings(use_tls=False, host="127.0.0.1") + assert settings.use_tls is False + assert settings.host == "127.0.0.1" + + def test_use_tls_false_with_ipv6_loopback_allowed(self) -> None: + """Vérifie que use_tls=False avec ::1 est autorisé. + + :return: Vérifie que XmppSettings(use_tls=False, host="::1") est valide. + :rtype: None + """ + settings = XmppSettings(use_tls=False, host="::1") + assert settings.use_tls is False + assert settings.host == "::1" + + def test_use_tls_true_with_remote_host_allowed(self) -> None: + """Vérifie que use_tls=True avec un hôte distant est autorisé. + + :return: Vérifie que XmppSettings(use_tls=True, host="talk.example.com") est valide. + :rtype: None + """ + settings = XmppSettings(use_tls=True, host="talk.example.com") + assert settings.use_tls is True + assert settings.host == "talk.example.com" + + def test_use_tls_true_with_empty_host_allowed(self) -> None: + """Vérifie que use_tls=True avec host vide est autorisé. + + :return: Vérifie que XmppSettings(use_tls=True, host="") est valide. + :rtype: None + """ + settings = XmppSettings(use_tls=True, host="") + assert settings.use_tls is True + assert settings.host == "" + + +class TestNoSecretInErrorMessages: + """Tests de sécurité : vérifie que les messages d'erreur ne contiennent pas de secrets.""" + + def test_no_secret_in_validation_error(self) -> None: + """Vérifie que les messages de ValidationError ne contiennent pas de secrets. + + Crée une instance avec des valeurs sensibles et vérifie que l'erreur de validation + ne contient pas ces valeurs dans son message. + + :raises ValidationError: Si use_tls=False avec un hôte non-local. + :return: Vérifie que le message d'erreur ne contient pas les secrets. + :rtype: None + """ + # Utilisation de valeurs sentinelles pour éviter toute fuite + sentinel_jid = "test_jid@example.com" + sentinel_password = SecretStr("test_password_123") + sentinel_to = "test_to@example.com" + + with pytest.raises(ValidationError) as exc_info: + XmppSettings( + use_tls=False, + host="talk.example.com", + jid=sentinel_jid, + password=sentinel_password, + to=sentinel_to, + ) + + error_message = str(exc_info.value).lower() + # Vérifie que les valeurs sensibles ne sont pas dans le message d'erreur + assert "test_jid@example.com" not in error_message + assert "test_password_123" not in error_message + assert "test_to@example.com" not in error_message + assert "secret" not in error_message