Spaces:
Sleeping
Sleeping
Claude
fix(security): Phase 1 — SSRF eScriptorium + Tesseract lang + bandit nosec
3836b05 unverified | """Phase 1.1 du plan d'audit — l'adapter eScriptorium passe | |
| désormais par ``validate_http_url`` pour les fetch GET/POST et par | |
| ``download_url`` pour les téléchargements d'images. | |
| Audit code-quality (2026-05) : ``escriptorium._get/_post`` et le | |
| ``urllib.request.urlretrieve(part.image_url)`` ligne 410 fetchaient | |
| sans valider l'URL — un manifeste pointant | |
| ``http://169.254.169.254/...`` exfiltrait les métadonnées cloud, | |
| ``http://127.0.0.1:6379/...`` parlait au Redis local, etc. Le | |
| helper ``validate_http_url`` existait déjà pour IIIF/Gallica/ | |
| HTR-United mais n'était pas branché pour eScriptorium. | |
| """ | |
| from __future__ import annotations | |
| from unittest.mock import patch | |
| import pytest | |
| from picarones.adapters.corpus.escriptorium import EScriptoriumClient | |
| def client() -> EScriptoriumClient: | |
| """Client eScriptorium configuré sur un hôte fictif valide. | |
| Le constructeur n'effectue aucun fetch — on peut donc fabriquer | |
| un client avec une URL publique fictive et tester les méthodes | |
| individuellement. | |
| """ | |
| return EScriptoriumClient("https://escriptorium.example.org", token="dummy") | |
| # -------------------------------------------------------------------------- | |
| # _get / _post : hostnames bloqués | |
| # -------------------------------------------------------------------------- | |
| class TestGetBlocksDangerousHosts: | |
| """``_get`` doit refuser les hostnames internes avant tout fetch.""" | |
| def test_get_refuses_internal_host(self, base_url: str) -> None: | |
| """Chaque IP/host interne fait lever RuntimeError sans fetch.""" | |
| client = EScriptoriumClient(base_url, token="dummy") | |
| with patch("urllib.request.urlopen") as mock_urlopen: | |
| with pytest.raises(RuntimeError, match="(anti-SSRF|refusé|Schéma)"): | |
| client._get("projects/") | |
| # Le fetch ne doit jamais avoir lieu. | |
| mock_urlopen.assert_not_called() | |
| def test_get_refuses_file_scheme(self) -> None: | |
| """Le schéma ``file://`` est refusé avant fetch.""" | |
| client = EScriptoriumClient("file:///etc/passwd", token="dummy") | |
| with patch("urllib.request.urlopen") as mock_urlopen: | |
| with pytest.raises(RuntimeError): | |
| client._get("anything") | |
| mock_urlopen.assert_not_called() | |
| class TestPostBlocksDangerousHosts: | |
| """``_post`` (création de couche OCR) doit aussi valider.""" | |
| def test_post_refuses_internal_host(self, base_url: str) -> None: | |
| client = EScriptoriumClient(base_url, token="dummy") | |
| with patch("urllib.request.urlopen") as mock_urlopen: | |
| with pytest.raises(RuntimeError, match="(anti-SSRF|refusé|Schéma)"): | |
| client._post("documents/1/parts/2/transcriptions/", {"key": "value"}) | |
| mock_urlopen.assert_not_called() | |
| # -------------------------------------------------------------------------- | |
| # Image download via download_url (Phase 1.1) — anti-SSRF | |
| # -------------------------------------------------------------------------- | |
| class TestImageDownloadValidatesURL: | |
| """``import_document`` doit refuser de fetch une image dont | |
| l'``image_url`` pointe vers un hôte interne. | |
| On teste ici uniquement la sous-routine qui télécharge l'image | |
| (le helper ``download_url`` lève ``ValueError`` validate_http_url). | |
| """ | |
| def test_download_url_rejects_metadata_host(self) -> None: | |
| """Vérification directe de l'invariant : download_url | |
| ne fetch pas une URL metadata cloud.""" | |
| from picarones.adapters.corpus._http import download_url | |
| with patch("urllib.request.urlopen") as mock_urlopen: | |
| with pytest.raises(ValueError, match="(anti-SSRF|refusé)"): | |
| download_url("http://169.254.169.254/latest/meta-data/") | |
| mock_urlopen.assert_not_called() | |
| # -------------------------------------------------------------------------- | |
| # Garde-fou — l'import du module ne plante pas | |
| # -------------------------------------------------------------------------- | |
| def test_module_imports_validate_http_url() -> None: | |
| """Le module ``escriptorium`` doit avoir importé ``validate_http_url`` | |
| au top-level — protection contre une régression d'import lazy | |
| qui contournerait la vérification. | |
| """ | |
| import picarones.adapters.corpus.escriptorium as mod | |
| assert hasattr(mod, "validate_http_url"), ( | |
| "escriptorium.py n'importe plus validate_http_url — " | |
| "régression Phase 1.1 de l'audit code-quality." | |
| ) | |
| assert hasattr(mod, "download_url"), ( | |
| "escriptorium.py n'importe plus download_url — " | |
| "régression Phase 1.1 de l'audit code-quality." | |
| ) | |