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:
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user