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 <test-engineer@agents.invalid>
Co-authored-by: opencode/coder <coder@agents.invalid>
This commit is contained in:
@@ -12,7 +12,13 @@ from datetime import date
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
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 pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
from pronote_sync.utils.redaction import redact_url
|
from pronote_sync.utils.redaction import redact_url
|
||||||
@@ -124,25 +130,66 @@ class CalDAVSettings(BaseSettings):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
_XMPP_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
|
||||||
|
|
||||||
|
|
||||||
class XmppSettings(BaseSettings):
|
class XmppSettings(BaseSettings):
|
||||||
"""Paramètres du canal de notifications XMPP (désactivé par défaut).
|
"""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
|
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
|
inactif tant qu'il n'est pas explicitement activé. Les variables
|
||||||
d'environnement correspondantes sont préfixées par ``XMPP_``.
|
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
|
enabled: bool = False
|
||||||
jid: str | None = None
|
jid: str | None = None
|
||||||
password: SecretStr | None = None
|
password: SecretStr | None = None
|
||||||
host: str = ""
|
host: str = ""
|
||||||
port: int = 5222
|
port: int = Field(default=5222, ge=1, le=65535)
|
||||||
to: str | None = None
|
to: str | None = None
|
||||||
resource: str = "pronote-sync"
|
resource: str = "pronote-sync"
|
||||||
use_tls: bool = True
|
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):
|
class AISettings(BaseSettings):
|
||||||
|
|||||||
199
tests/unit/test_xmpp_settings.py
Normal file
199
tests/unit/test_xmpp_settings.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user