fix(config): refuser file:// et userinfo dans le contrat d'endpoint

Retire le schéma file du contrat ExternalEndpoint (CalDAV, Pronote iCal, Blog RSS) : seuls https et le loopback http CalDAV explicite restent acceptés. Rejette les URLs contenant userinfo. Ajoute hide_input_in_errors aux Settings. Réécrit les fixtures file:// en https mocké et ajoute une matrice de tests paramétrée du contrat.

Refs #63
This commit is contained in:
2026-09-13 15:30:00 +02:00
parent d1371cabbb
commit 177286b528
5 changed files with 329 additions and 61 deletions
+56 -22
View File
@@ -26,17 +26,23 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from pronote_sync.utils.redaction import redact_url
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"file", "http", "https"})
_EXTERNAL_ENDPOINT_SCHEMES: frozenset[str] = frozenset({"http", "https"})
_LOOPBACK_HOSTS: frozenset[str] = frozenset({"localhost", "127.0.0.1", "::1"})
def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
"""Valide la structure et le schéma d'une URL d'endpoint externe.
"""Valide la structure et le schéma réseau d'une URL d'endpoint externe.
Le socle commun accepte uniquement les schémas ``http`` et ``https``, avec
un hôte obligatoire. Les credentials embarqués (``user:pass@host``) sont
refusés afin qu'aucun secret ne soit transporté dans l'URL. La restriction
``https``/HTTP loopback est ensuite affinée par chaque connecteur.
:param value: URL potentiellement sensible à valider.
:return: URL validée, toujours encapsulée dans ``SecretStr``.
:rtype: SecretStr
:raises ValueError: Si l'URL est malformée ou utilise un schéma inconnu.
:raises ValueError: Si l'URL est malformée, sans hôte, utilise un schéma
non réseau ou contient des credentials.
"""
is_valid = False
try:
@@ -44,8 +50,9 @@ def _validate_external_endpoint_url(value: SecretStr) -> SecretStr:
_ = parsed.port
is_valid = (
parsed.scheme in _EXTERNAL_ENDPOINT_SCHEMES
and (parsed.scheme not in {"http", "https"} or parsed.hostname is not None)
and (parsed.scheme != "file" or bool(parsed.path))
and parsed.hostname is not None
and parsed.username is None
and parsed.password is None
)
except ValueError:
pass
@@ -60,10 +67,11 @@ EndpointUrl = Annotated[SecretStr, AfterValidator(_validate_external_endpoint_ur
class ExternalEndpoint(BaseModel):
"""Représente un endpoint externe potentiellement sensible.
Le socle accepte les transports ``https``, ``http`` et ``file``. Chaque
connecteur restreint ensuite cette liste selon sa propre politique de
sécurité. L'URL reste encapsulée dans :class:`pydantic.SecretStr` et sa
sérialisation conserve uniquement une représentation expurgée.
Le socle accepte uniquement les transports réseau ``https`` et ``http``
(hôte obligatoire, sans credentials embarqués). Chaque connecteur restreint
ensuite cette liste selon sa propre politique de sécurité. L'URL reste
encapsulée dans :class:`pydantic.SecretStr` et sa sérialisation conserve
uniquement une représentation expurgée.
"""
model_config = ConfigDict(extra="forbid", frozen=True, hide_input_in_errors=True)
@@ -93,6 +101,7 @@ class PronoteSettings(BaseSettings):
env_nested_delimiter="__",
extra="ignore",
env_prefix="PRONOTE_",
hide_input_in_errors=True,
)
endpoint: ExternalEndpoint | None = None
@@ -153,22 +162,23 @@ class PronoteSettings(BaseSettings):
def _validate_endpoint_policies(self) -> PronoteSettings:
"""Applique les transports autorisés aux deux endpoints Pronote.
L'API Pronote utilise HTTPS. Le flux iCal accepte également ``file``
afin de préserver les fixtures locales injectées.
L'API Pronote et le flux iCal exigent tous deux HTTPS : aucun fichier
local n'est accepté.
:return: Instance validée inchangée.
:rtype: PronoteSettings
:raises ValueError: Si un endpoint utilise un schéma interdit.
:raises ValueError: Si un endpoint n'utilise pas HTTPS.
"""
if (
self.endpoint is not None
and urlparse(self.endpoint.url.get_secret_value()).scheme != "https"
):
raise ValueError("URL Pronote invalide : HTTPS requis") from None
if self.ical_endpoint is not None and urlparse(
self.ical_endpoint.url.get_secret_value()
).scheme not in {"https", "file"}:
raise ValueError("URL iCal Pronote invalide : HTTPS ou file requis") from None
if (
self.ical_endpoint is not None
and urlparse(self.ical_endpoint.url.get_secret_value()).scheme != "https"
):
raise ValueError("URL iCal Pronote invalide : HTTPS requis") from None
return self
@field_serializer("qr_pin")
@@ -215,6 +225,7 @@ class CalDAVSettings(BaseSettings):
env_nested_delimiter="__",
extra="ignore",
env_prefix="CALDAV_",
hide_input_in_errors=True,
)
allow_insecure_http: bool = False
@@ -233,6 +244,14 @@ class CalDAVSettings(BaseSettings):
def _migrate_legacy_url(cls, data: object) -> object:
"""Migre ``url`` vers l'endpoint commun avec un avertissement.
L'alias historique ``CALDAV_URL`` conserve ses sémantiques passées
pendant la transition : lorsqu'il embarque des identifiants
(``user:pass@hôte``), l'endpoint est construit sans revalidation pour
ne pas casser une configuration existante, alors que le contrat
canonique ``CALDAV_ENDPOINT__URL`` refuse désormais les credentials
embarqués. La politique de transport (HTTPS, ou HTTP loopback
uniquement avec ``allow_insecure_http``) reste appliquée ensuite.
:param data: Données brutes du modèle.
:return: Données complétées avec ``endpoint`` si nécessaire.
:rtype: object
@@ -246,7 +265,20 @@ class CalDAVSettings(BaseSettings):
stacklevel=2,
)
if migrated_data.get("endpoint") is None:
migrated_data["endpoint"] = {"url": migrated_data["url"]}
legacy_url = migrated_data["url"]
raw_url = (
legacy_url.get_secret_value()
if isinstance(legacy_url, SecretStr)
else str(legacy_url)
)
parsed = urlparse(raw_url)
if parsed.username is not None or parsed.password is not None:
# Alias obsolète : grandfathered, on préserve l'URL telle quelle.
migrated_data["endpoint"] = ExternalEndpoint.model_construct(
url=legacy_url if isinstance(legacy_url, SecretStr) else SecretStr(legacy_url)
)
else:
migrated_data["endpoint"] = {"url": legacy_url}
return migrated_data
@model_validator(mode="after")
@@ -412,6 +444,7 @@ class BlogSettings(BaseSettings):
env_nested_delimiter="__",
extra="ignore",
env_prefix="BLOG_",
hide_input_in_errors=True,
)
enabled: bool = False
@@ -449,17 +482,18 @@ class BlogSettings(BaseSettings):
@model_validator(mode="after")
def _validate_endpoint_policy(self) -> BlogSettings:
"""Refuse les transports non sûrs pour le flux RSS de production.
"""Refuse les transports non sûrs pour le flux RSS.
Le transport ``file`` reste autorisé pour les fixtures locales.
Seul HTTPS est accepté : aucun fichier local n'est lu depuis un
endpoint externe.
:return: Instance validée inchangée.
:rtype: BlogSettings
:raises ValueError: Si le schéma n'est ni ``https`` ni ``file``.
:raises ValueError: Si le schéma n'est pas ``https``.
"""
scheme = urlparse(self.endpoint.url.get_secret_value()).scheme
if scheme not in {"https", "file"}:
raise ValueError("URL RSS invalide : HTTPS ou file requis") from None
if scheme != "https":
raise ValueError("URL RSS invalide : HTTPS requis") from None
return self