From 5a3e251ad68442c991f06d631219b95e0451251e Mon Sep 17 00:00:00 2001 From: OpenCode Date: Sun, 13 Sep 2026 15:46:07 +0200 Subject: [PATCH] fix(config): valider l'alias CalDAV legacy et retirer file:// de fetch_ical MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime le contournement model_construct : l'alias CALDAV_URL est désormais validé comme le champ canonique (userinfo, host et port rejetés). Retire le support file:// de fetch_ical et corrige l'exemple résiduel du guide. Ajoute les tests négatifs de l'alias legacy et adapte le test de sécurité CalDAV au contrat durci. Refs #63 --- .secrets.baseline | 6 ++--- GUIDE_DEV_PYTHON.md | 31 ++++++++++++--------- pronote_sync/config/settings.py | 28 +++++-------------- pronote_sync/sources/pronote/ical.py | 34 +++++++---------------- tests/unit/test_caldav_security.py | 40 +++++++++++++++++----------- tests/unit/test_config.py | 26 ++++++++++++++++++ 6 files changed, 89 insertions(+), 76 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index aed4d4b..18a611c 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5198 + "line_number": 5205 } ], "tests/unit/test_caldav_gateway.py": [ @@ -172,7 +172,7 @@ "filename": "tests/unit/test_caldav_security.py", "hashed_secret": "6b554cd7b7e0115065fb4907307a74f1902154d4", "is_verified": false, - "line_number": 28 + "line_number": 29 } ], "tests/unit/test_xmpp_channel.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-13T13:32:54Z" + "generated_at": "2026-09-13T13:46:01Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 9c1f2cd..85fb3e5 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -1632,11 +1632,14 @@ from ..models.agenda import RawCalendarData def fetch_ical(url: str, timeout: int = 20) -> str: """ - Récupère le flux iCal depuis une URL Pronote. + Récupère le flux iCal depuis une URL Pronote (HTTPS uniquement). Inspiré de src/sources/pronote/fetch.ts. + Le schéma ``file://`` n'est **pas** supporté : les fixtures locales sont + injectées via un mock HTTP (``responses``) dans les tests. + Args: - url: URL du flux iCal (peut être file:// pour les tests). + url: URL HTTPS du flux iCal (avec token ``icalsecurise``). timeout: Timeout en secondes (défaut: 20s). Returns: @@ -1651,16 +1654,7 @@ def fetch_ical(url: str, timeout: int = 20) -> str: "user-agent": "pronote-sync", } - # Gestion des URLs file:// pour les tests - if url.startswith("file://"): - import pathlib - file_path = pathlib.Path(url.replace("file://", "")) - content = file_path.read_text(encoding="utf-8") - if "BEGIN:VCALENDAR" not in content: - raise ValueError(f"Fichier iCal invalide: {redact_url(url)}") - return content - - # Récupération HTTP + # Récupération HTTP (aucun accès fichier local) try: response = requests.get( url, @@ -1686,6 +1680,19 @@ def fetch_ical(url: str, timeout: int = 20) -> str: return content +# Test unitaire réseau mocké (aucun accès fichier) +@responses.activate +def test_fetch_ical_https() -> None: + responses.add( + responses.GET, + "https://pronote.example.test/ical.ics", + body="BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR", + status=200, + ) + content = fetch_ical("https://pronote.example.test/ical.ics") + assert content.startswith("BEGIN:VCALENDAR") + + def get_calendar_name(raw_ical: str) -> str | None: """ Extrait le nom du calendrier depuis X-WR-CALNAME. diff --git a/pronote_sync/config/settings.py b/pronote_sync/config/settings.py index 2a12afd..86d8b30 100644 --- a/pronote_sync/config/settings.py +++ b/pronote_sync/config/settings.py @@ -244,13 +244,12 @@ 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. + L'alias historique ``CALDAV_URL`` est migré via le chemin de validation + canonique : il est donc soumis exactement aux mêmes règles que + ``CALDAV_ENDPOINT__URL`` (schémas réseau uniquement, hôte obligatoire, + port valide, credentials embarqués refusé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. @@ -265,20 +264,7 @@ class CalDAVSettings(BaseSettings): stacklevel=2, ) if migrated_data.get("endpoint") is None: - 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} + migrated_data["endpoint"] = {"url": migrated_data["url"]} return migrated_data @model_validator(mode="after") diff --git a/pronote_sync/sources/pronote/ical.py b/pronote_sync/sources/pronote/ical.py index f03183f..20a96b6 100644 --- a/pronote_sync/sources/pronote/ical.py +++ b/pronote_sync/sources/pronote/ical.py @@ -1,7 +1,7 @@ """Récupération et parsing du flux iCal Pronote. -Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou -``file://`` pour les tests) ainsi que son parsing en modèles : cours +Ce module fournit le téléchargement réseau du flux iCal Pronote ainsi que +son parsing en modèles : cours (:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires (:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs (:class:`~pronote_sync.models.homework.Homework`). @@ -15,10 +15,8 @@ from __future__ import annotations import hashlib import re import unicodedata -import urllib.parse from datetime import date, datetime from html import unescape -from pathlib import Path from typing import NamedTuple, TypedDict import requests @@ -70,35 +68,21 @@ class ParsedHomeworkBlock(NamedTuple): def fetch_ical(url: str, timeout: int = 20) -> str: - """Récupère le contenu brut d'un flux iCal Pronote. + """Récupère le contenu brut d'un flux iCal Pronote par HTTPS. - Gère les URLs ``file://`` pour les tests locaux (le chemin est - décodé de l'échappement URI, ex. ``%20`` → espace) et valide que - le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs - sont relancées avec un message dont les secrets (token - ``icalsecurise``) sont masqués. + Le schéma ``file://`` n'est pas supporté : les fixtures locales doivent + être injectées via un mock HTTP (``responses``) dans les tests. Valide que + le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs sont + relancées avec un message dont les secrets (token ``icalsecurise``) sont + masqués. - :param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``. + :param url: URL HTTPS du flux iCal (avec token ``icalsecurise``). :param timeout: Timeout HTTP en secondes (défaut : 20). :return: Contenu brut du flux iCal. :rtype: str - :raises OSError: Si le fichier local ``file://`` est illisible. :raises requests.RequestException: Si la récupération HTTP échoue. :raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``. """ - if url.startswith("file://"): - parsed_url = urllib.parse.urlparse(url) - path = Path(urllib.parse.unquote(parsed_url.path)) - try: - content = path.read_text(encoding="utf-8") - except OSError as exc: - raise OSError( - f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}" - ) from exc - if not content.lstrip().startswith("BEGIN:VCALENDAR"): - raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}") - return content - try: response = requests.get(url, headers=_HEADERS, timeout=timeout) response.raise_for_status() diff --git a/tests/unit/test_caldav_security.py b/tests/unit/test_caldav_security.py index a8b5c38..c5c6567 100644 --- a/tests/unit/test_caldav_security.py +++ b/tests/unit/test_caldav_security.py @@ -15,7 +15,7 @@ from unittest.mock import MagicMock import pytest from pydantic import SecretStr, ValidationError -from pronote_sync.config.settings import CalDAVSettings +from pronote_sync.config.settings import CalDAVSettings, ExternalEndpoint from pronote_sync.errors import PronoteSyncError from pronote_sync.sync.caldav import CalDAVGateway @@ -25,6 +25,7 @@ if TYPE_CHECKING: # Sentinelles pour détecter les fuites de secrets dans les tests SENTINEL_URL = "https://user:pass-super-secret-12345@caldav.example.com/secret-path/" +SENTINEL_ENDPOINT_URL = "https://caldav.example.com/secret-path/?token=pass-super-secret-12345" SENTINEL_PASSWORD = "super-secret-password-67890" SENTINEL_HTTP_URL = "http://caldav.example.com/" SENTINEL_HTTP_LOCALHOST = "http://localhost:5232/caldav/" @@ -37,13 +38,13 @@ class TestCalDAVSettingsSecurity: def test_url_redaction_in_repr(self) -> None: """Vérifie que l'URL brute n'apparaît pas dans repr(settings).""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) repr_str = repr(settings) - assert SENTINEL_URL not in repr_str + assert SENTINEL_ENDPOINT_URL not in repr_str assert "pass-super-secret-12345" not in repr_str # L'URL est masquée par redact_url qui retourne REDACTED_URL ou une URL avec REDACTED assert "REDACTED" in repr_str or "**********" in repr_str @@ -51,13 +52,13 @@ class TestCalDAVSettingsSecurity: def test_url_redaction_in_str(self) -> None: """Vérifie que l'URL brute n'apparaît pas dans str(settings).""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", ) str_str = str(settings) - assert SENTINEL_URL not in str_str + assert SENTINEL_ENDPOINT_URL not in str_str assert "pass-super-secret-12345" not in str_str # L'URL est masquée par redact_url assert "REDACTED" in str_str or "**********" in str_str @@ -65,7 +66,7 @@ class TestCalDAVSettingsSecurity: def test_url_redaction_in_model_dump(self) -> None: """Vérifie que l'URL brute n'apparaît pas dans model_dump().""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -74,7 +75,7 @@ class TestCalDAVSettingsSecurity: # Vérifie que l'URL n'est pas dans les valeurs du dict for value in dumped.values(): if isinstance(value, str): - assert SENTINEL_URL not in value + assert SENTINEL_ENDPOINT_URL not in value assert "pass-super-secret-12345" not in value # Vérifie que la version rédigée est présente assert "REDACTED" in str(dumped) @@ -201,7 +202,7 @@ class TestCalDAVGatewaySecurity: raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -215,12 +216,14 @@ class TestCalDAVGatewaySecurity: # Vérifie que le message d'erreur ne contient pas les sentinelles error_msg = str(exc_info.value) assert SENTINEL_URL not in error_msg + assert SENTINEL_ENDPOINT_URL not in error_msg assert SENTINEL_PASSWORD not in error_msg # Vérifie que les logs ne contiennent pas les sentinelles for record in caplog.records: log_msg = record.getMessage() assert SENTINEL_URL not in log_msg + assert SENTINEL_ENDPOINT_URL not in log_msg assert SENTINEL_PASSWORD not in log_msg def test_exception_cause_and_context_is_none(self) -> None: @@ -230,7 +233,7 @@ class TestCalDAVGatewaySecurity: raise Exception(f"Connection failed to {SENTINEL_URL}?token={SENTINEL_PASSWORD}") settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -246,7 +249,7 @@ class TestCalDAVGatewaySecurity: def test_logs_redacted_on_list_managed_events_error(self, caplog: LogCaptureFixture) -> None: """Vérifie que les logs sont expurgés lors d'une erreur dans list_managed_events.""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -274,12 +277,14 @@ class TestCalDAVGatewaySecurity: # Vérifie que le message d'erreur ne contient pas les sentinelles error_msg = str(exc_info.value) assert SENTINEL_URL not in error_msg + assert SENTINEL_ENDPOINT_URL not in error_msg assert SENTINEL_PASSWORD not in error_msg # Vérifie que les logs ne contiennent pas les sentinelles for record in caplog.records: log_msg = record.getMessage() assert SENTINEL_URL not in log_msg + assert SENTINEL_ENDPOINT_URL not in log_msg assert SENTINEL_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute @@ -289,7 +294,7 @@ class TestCalDAVGatewaySecurity: def test_logs_redacted_on_upsert_event_error(self, caplog: LogCaptureFixture) -> None: """Vérifie que les logs sont expurgés lors d'une erreur dans upsert_event.""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -312,12 +317,14 @@ class TestCalDAVGatewaySecurity: # Vérifie que le message d'erreur ne contient pas les sentinelles error_msg = str(exc_info.value) assert SENTINEL_URL not in error_msg + assert SENTINEL_ENDPOINT_URL not in error_msg assert SENTINEL_PASSWORD not in error_msg # Vérifie que les logs ne contiennent pas les sentinelles for record in caplog.records: log_msg = record.getMessage() assert SENTINEL_URL not in log_msg + assert SENTINEL_ENDPOINT_URL not in log_msg assert SENTINEL_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute @@ -327,7 +334,7 @@ class TestCalDAVGatewaySecurity: def test_logs_redacted_on_delete_event_error(self, caplog: LogCaptureFixture) -> None: """Vérifie que les logs sont expurgés lors d'une erreur dans delete_event.""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -349,12 +356,14 @@ class TestCalDAVGatewaySecurity: # Vérifie que le message d'erreur ne contient pas les sentinelles error_msg = str(exc_info.value) assert SENTINEL_URL not in error_msg + assert SENTINEL_ENDPOINT_URL not in error_msg assert SENTINEL_PASSWORD not in error_msg # Vérifie que les logs ne contiennent pas les sentinelles for record in caplog.records: log_msg = record.getMessage() assert SENTINEL_URL not in log_msg + assert SENTINEL_ENDPOINT_URL not in log_msg assert SENTINEL_PASSWORD not in log_msg # Vérifie que l'exception n'est chaînée à aucune exception brute @@ -364,7 +373,7 @@ class TestCalDAVGatewaySecurity: def test_redacted_url_stored_in_gateway(self) -> None: """Vérifie que l'URL rédigée est stockée sur l'instance de la passerelle.""" settings = CalDAVSettings( - url=SecretStr(SENTINEL_URL), + endpoint=ExternalEndpoint(url=SecretStr(SENTINEL_ENDPOINT_URL)), username="test-user", password=SecretStr(SENTINEL_PASSWORD), calendar_path="/cal/", @@ -372,8 +381,9 @@ class TestCalDAVGatewaySecurity: gateway = CalDAVGateway(settings) # Vérifie que l'URL rédigée est stockée assert gateway._redacted_url is not None - assert SENTINEL_URL not in gateway._redacted_url + assert SENTINEL_ENDPOINT_URL not in gateway._redacted_url + assert "pass-super-secret-12345" not in gateway._redacted_url assert "REDACTED" in gateway._redacted_url # Vérifie que l'URL brute n'est pas stockée en clair assert gateway._url_secret is not None - assert SENTINEL_URL not in str(gateway._url_secret) + assert SENTINEL_ENDPOINT_URL not in str(gateway._url_secret) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8bc77c5..9cfb95a 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -212,6 +212,32 @@ def test_caldav_legacy_url_migrates_with_warning() -> None: assert settings.endpoint.url.get_secret_value() == "https://caldav.example.test/dav" +@pytest.mark.parametrize( + "url", + [ + "https://user:pass@caldav.example.test/dav", # pragma: allowlist secret + "https:///missing-host", + "https://host:bad", + "file:///tmp/fixture.ics", + ], +) +def test_caldav_legacy_url_alias_rejects_invalid_urls(url: str) -> None: + """L'alias obsolète ``CALDAV_URL`` ne contourne pas la validation canonique. + + Contrat corrigé : ``ExternalEndpoint.model_construct`` n'est plus utilisé + pour grandfathered les identifiants embarqués. L'alias est migré via le + chemin de validation normal, donc les credentials embarqués, l'hôte + manquant, le port invalide et le schéma ``file://`` sont refusés, + exactement comme pour ``CALDAV_ENDPOINT__URL``. + + :param url: URL invalide fournie via l'alias historique. + :return: None + """ + with pytest.raises(ValidationError): + with pytest.warns(DeprecationWarning, match="CALDAV_URL"): + CalDAVSettings(url=SecretStr(url)) + + def test_pronote_endpoints_load_from_nested_environment(monkeypatch: MonkeyPatch) -> None: """Vérifie le chargement des deux endpoints Pronote depuis l'environnement.