Spaces:
Sleeping
fix(web): durcir le parsing XML (defusedxml en dépendance dure) + exceptions précises
Browse filesL'audit a identifié deux dettes de robustesse :
**1. XXE — fallback silencieux sur le parser stdlib non sécurisé**
Avant : ``corpus_utils.safe_parse_xml`` tentait ``defusedxml`` puis
fallback silencieux sur ``xml.etree.ElementTree.XMLParser`` (qui
n'a **pas** de protection XXE / Billion Laughs / DTD retrieval en
Python 3.8+). En cas d'absence de la dépendance, un attaquant
pouvait soumettre un manifeste ALTO/PAGE qui :
- déclenche une expansion exponentielle d'entités → ``MemoryError``
ou freeze ;
- résout des entités externes vers des fichiers locaux ou des URL
distantes → exfiltration ou SSRF.
Fix : ``defusedxml>=0.7.1`` ajouté dans ``[project] dependencies``
(dépendance dure, pas optionnelle). Plus de fallback : on importe
``defusedxml`` au top du module, et on capture explicitement
``defusedxml.DefusedXmlException`` (parent de ``EntitiesForbidden``,
``ExternalReferenceForbidden``, ``DTDForbidden``,
``NotSupportedError``) en plus de ``ET.ParseError``.
**2. ``except Exception`` masquant des bugs**
Plusieurs ``except Exception:`` dans ``engine_utils.py`` capturaient
silencieusement des erreurs qui ne devraient pas survenir (par ex.
``AttributeError`` sur un module mal écrit). Resserré aux
exceptions précises :
- ``check_engine`` : ``ImportError``, ``AttributeError``,
``pytesseract.TesseractNotFoundError``, ``OSError``.
- ``fetch_ollama_info`` : ``urllib.error.URLError``, ``OSError``,
``json.JSONDecodeError``, ``UnicodeDecodeError``.
- ``get_tesseract_langs`` : ``ImportError``, ``OSError``.
Tout autre type d'exception est désormais propagé pour ne pas
masquer un vrai bug en production.
**3. Lifespan FastAPI — log level corrigé**
``app._lifespan`` loguait en ``warning`` quand
``mark_orphaned_jobs_interrupted`` échouait. Si la base SQLite est
cassée au démarrage, c'est un signal opérationnel à remonter en
``error`` : l'app tourne dans un état dégradé (jobs zombies sur le
tableau de bord). ``except`` resserré à ``sqlite3.Error`` pour ne
capturer que les pannes BD légitimes.
Pytest : 3354 passed, 2 skipped, 0 failed. Ruff : All checks passed.
https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L
- picarones/web/app.py +10 -2
- picarones/web/corpus_utils.py +21 -11
- picarones/web/engine_utils.py +31 -9
- pyproject.toml +4 -0
|
@@ -26,6 +26,7 @@ logique métier vit dans les sous-modules :
|
|
| 26 |
from __future__ import annotations
|
| 27 |
|
| 28 |
import logging
|
|
|
|
| 29 |
from contextlib import asynccontextmanager
|
| 30 |
from pathlib import Path
|
| 31 |
|
|
@@ -70,8 +71,15 @@ async def _lifespan(app: FastAPI):
|
|
| 70 |
# un store isolé soient effectivement vues par le lifespan.
|
| 71 |
try:
|
| 72 |
state.JOB_STORE.mark_orphaned_jobs_interrupted()
|
| 73 |
-
except
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
yield
|
| 76 |
|
| 77 |
|
|
|
|
| 26 |
from __future__ import annotations
|
| 27 |
|
| 28 |
import logging
|
| 29 |
+
import sqlite3
|
| 30 |
from contextlib import asynccontextmanager
|
| 31 |
from pathlib import Path
|
| 32 |
|
|
|
|
| 71 |
# un store isolé soient effectivement vues par le lifespan.
|
| 72 |
try:
|
| 73 |
state.JOB_STORE.mark_orphaned_jobs_interrupted()
|
| 74 |
+
except sqlite3.Error as exc: # pragma: no cover — défense en profondeur
|
| 75 |
+
# Si la base de jobs est cassée au démarrage, on log en ``error``
|
| 76 |
+
# (pas ``warning``) — c'est un signal opérationnel : l'app
|
| 77 |
+
# tourne dans un état dégradé, le tableau de bord va être incorrect.
|
| 78 |
+
_logger.error(
|
| 79 |
+
"[jobs] mark_orphaned_jobs_interrupted ÉCHOUÉ — "
|
| 80 |
+
"base SQLite inaccessible (%s) : le tableau de bord "
|
| 81 |
+
"affichera des jobs zombies.", exc,
|
| 82 |
+
)
|
| 83 |
yield
|
| 84 |
|
| 85 |
|
|
@@ -7,11 +7,15 @@ ZIP avec garde-fous (taille décompressée, nombre de fichiers).
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import xml.etree.ElementTree as ET
|
| 11 |
import zipfile
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Optional
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
from picarones.web.state import IMAGE_EXTS
|
| 16 |
|
| 17 |
# Garde-fous ZIP-bomb pour l'upload
|
|
@@ -27,17 +31,23 @@ MAX_ZIP_FILES = 2000
|
|
| 27 |
# ──────────────────────────────────────────────────────────────────────────
|
| 28 |
|
| 29 |
def safe_parse_xml(xml_bytes: bytes) -> Optional[ET.Element]:
|
| 30 |
-
"""Parse du XML en
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
try:
|
| 39 |
-
return
|
| 40 |
-
except ET.ParseError:
|
| 41 |
return None
|
| 42 |
|
| 43 |
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
import zipfile
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Optional
|
| 13 |
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
|
| 16 |
+
import defusedxml
|
| 17 |
+
import defusedxml.ElementTree as _SafeET
|
| 18 |
+
|
| 19 |
from picarones.web.state import IMAGE_EXTS
|
| 20 |
|
| 21 |
# Garde-fous ZIP-bomb pour l'upload
|
|
|
|
| 31 |
# ──────────────────────────────────────────────────────────────────────────
|
| 32 |
|
| 33 |
def safe_parse_xml(xml_bytes: bytes) -> Optional[ET.Element]:
|
| 34 |
+
"""Parse du XML en bloquant les entités externes (protection XXE).
|
| 35 |
+
|
| 36 |
+
Délègue à :mod:`defusedxml` (dépendance dure du projet) qui durcit
|
| 37 |
+
le parser stdlib contre :
|
| 38 |
+
|
| 39 |
+
- **XXE** (``XML External Entity``) — résolution d'entités vers
|
| 40 |
+
des fichiers locaux ou des URL distantes ;
|
| 41 |
+
- **Billion Laughs** — expansion exponentielle d'entités ;
|
| 42 |
+
- **DTD retrieval** — fetch d'une DTD distante.
|
| 43 |
+
|
| 44 |
+
Retourne ``None`` si le payload n'est pas un XML valide ou si
|
| 45 |
+
``defusedxml`` détecte une attaque (``EntitiesForbidden``,
|
| 46 |
+
``ExternalReferenceForbidden``, etc.).
|
| 47 |
+
"""
|
| 48 |
try:
|
| 49 |
+
return _SafeET.fromstring(xml_bytes)
|
| 50 |
+
except (ET.ParseError, defusedxml.DefusedXmlException):
|
| 51 |
return None
|
| 52 |
|
| 53 |
|
|
@@ -40,7 +40,13 @@ OLLAMA_VISION_FAMILIES = frozenset({
|
|
| 40 |
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
|
| 42 |
def check_engine(engine_id: str, module_name: str, label: str = "") -> dict:
|
| 43 |
-
"""Vérifie qu'un moteur OCR local est installé et retourne son statut.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
label = label or engine_id.replace("_", " ").title()
|
| 45 |
try:
|
| 46 |
__import__(module_name)
|
|
@@ -53,13 +59,15 @@ def check_engine(engine_id: str, module_name: str, label: str = "") -> dict:
|
|
| 53 |
try:
|
| 54 |
import pytesseract
|
| 55 |
version = str(pytesseract.get_tesseract_version())
|
| 56 |
-
except
|
|
|
|
|
|
|
| 57 |
version = "installé"
|
| 58 |
elif installed:
|
| 59 |
try:
|
| 60 |
mod = __import__(module_name)
|
| 61 |
version = getattr(mod, "__version__", "installé")
|
| 62 |
-
except
|
| 63 |
version = "installé"
|
| 64 |
|
| 65 |
return {
|
|
@@ -73,7 +81,14 @@ def check_engine(engine_id: str, module_name: str, label: str = "") -> dict:
|
|
| 73 |
|
| 74 |
|
| 75 |
def fetch_ollama_info() -> tuple[bool, list[str]]:
|
| 76 |
-
"""Vérifie la disponibilité d'Ollama et liste ses modèles en un seul appel HTTP.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
import urllib.error
|
| 78 |
import urllib.request
|
| 79 |
try:
|
|
@@ -81,19 +96,26 @@ def fetch_ollama_info() -> tuple[bool, list[str]]:
|
|
| 81 |
if r.status != 200:
|
| 82 |
return False, []
|
| 83 |
data = json.loads(r.read().decode())
|
| 84 |
-
|
| 85 |
-
return True, models
|
| 86 |
-
except Exception: # noqa: BLE001
|
| 87 |
return False, []
|
|
|
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def get_tesseract_langs() -> list[str]:
|
| 91 |
-
"""Liste les langues Tesseract installées (avec fallback éditorial).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
import pytesseract
|
| 94 |
langs = pytesseract.get_languages(config="")
|
| 95 |
return sorted(lg for lg in langs if lg != "osd")
|
| 96 |
-
except
|
|
|
|
| 97 |
return ["fra", "lat", "eng", "deu", "ita", "spa"]
|
| 98 |
|
| 99 |
|
|
|
|
| 40 |
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
|
| 42 |
def check_engine(engine_id: str, module_name: str, label: str = "") -> dict:
|
| 43 |
+
"""Vérifie qu'un moteur OCR local est installé et retourne son statut.
|
| 44 |
+
|
| 45 |
+
On ne fait que ``__import__(module_name)`` + ``getattr(mod, "__version__")`` —
|
| 46 |
+
seules ``ImportError`` et ``AttributeError`` peuvent légitimement
|
| 47 |
+
survenir. Tout autre type d'exception (panne disque, OSError…) est
|
| 48 |
+
propagé pour ne pas masquer un vrai bug.
|
| 49 |
+
"""
|
| 50 |
label = label or engine_id.replace("_", " ").title()
|
| 51 |
try:
|
| 52 |
__import__(module_name)
|
|
|
|
| 59 |
try:
|
| 60 |
import pytesseract
|
| 61 |
version = str(pytesseract.get_tesseract_version())
|
| 62 |
+
except (ImportError, pytesseract.TesseractNotFoundError, OSError):
|
| 63 |
+
# ``TesseractNotFoundError`` : binaire absent ; ``OSError`` :
|
| 64 |
+
# ``PATH`` manquant ; ``ImportError`` : racine du sous-import.
|
| 65 |
version = "installé"
|
| 66 |
elif installed:
|
| 67 |
try:
|
| 68 |
mod = __import__(module_name)
|
| 69 |
version = getattr(mod, "__version__", "installé")
|
| 70 |
+
except (ImportError, AttributeError):
|
| 71 |
version = "installé"
|
| 72 |
|
| 73 |
return {
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def fetch_ollama_info() -> tuple[bool, list[str]]:
|
| 84 |
+
"""Vérifie la disponibilité d'Ollama et liste ses modèles en un seul appel HTTP.
|
| 85 |
+
|
| 86 |
+
Capture explicitement ``URLError`` (Ollama pas démarré, port fermé,
|
| 87 |
+
timeout) et ``json.JSONDecodeError`` (réponse non-JSON inattendue).
|
| 88 |
+
Toute autre exception (par ex. ``OSError`` sur lecture réseau,
|
| 89 |
+
``UnicodeDecodeError``) est aussi traitée comme "Ollama
|
| 90 |
+
indisponible" — c'est l'intention du caller (UX dégradée gracieuse).
|
| 91 |
+
"""
|
| 92 |
import urllib.error
|
| 93 |
import urllib.request
|
| 94 |
try:
|
|
|
|
| 96 |
if r.status != 200:
|
| 97 |
return False, []
|
| 98 |
data = json.loads(r.read().decode())
|
| 99 |
+
except (urllib.error.URLError, OSError, json.JSONDecodeError, UnicodeDecodeError):
|
|
|
|
|
|
|
| 100 |
return False, []
|
| 101 |
+
models = [m.get("name", "") for m in data.get("models", [])]
|
| 102 |
+
return True, models
|
| 103 |
|
| 104 |
|
| 105 |
def get_tesseract_langs() -> list[str]:
|
| 106 |
+
"""Liste les langues Tesseract installées (avec fallback éditorial).
|
| 107 |
+
|
| 108 |
+
``TesseractNotFoundError`` quand le binaire est absent du ``PATH``,
|
| 109 |
+
``ImportError`` si pytesseract n'est pas installé, ``OSError`` sur
|
| 110 |
+
appel système échoué — tous traités comme "lister les langues
|
| 111 |
+
indisponible, fallback à la liste éditoriale historique".
|
| 112 |
+
"""
|
| 113 |
try:
|
| 114 |
import pytesseract
|
| 115 |
langs = pytesseract.get_languages(config="")
|
| 116 |
return sorted(lg for lg in langs if lg != "osd")
|
| 117 |
+
except (ImportError, OSError):
|
| 118 |
+
# ``pytesseract.TesseractNotFoundError`` hérite d'``OSError``.
|
| 119 |
return ["fra", "lat", "eng", "deu", "ita", "spa"]
|
| 120 |
|
| 121 |
|
|
@@ -31,6 +31,10 @@ dependencies = [
|
|
| 31 |
"tqdm>=4.66.0",
|
| 32 |
"numpy>=1.24.0",
|
| 33 |
"jinja2>=3.1.0",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
]
|
| 35 |
|
| 36 |
[project.urls]
|
|
|
|
| 31 |
"tqdm>=4.66.0",
|
| 32 |
"numpy>=1.24.0",
|
| 33 |
"jinja2>=3.1.0",
|
| 34 |
+
# XML parsing sécurisé contre les attaques XXE / Billion Laughs.
|
| 35 |
+
# Utilisé par ``picarones.web.corpus_utils`` pour le parsing ALTO/PAGE
|
| 36 |
+
# quand un utilisateur uploade un corpus XML.
|
| 37 |
+
"defusedxml>=0.7.1",
|
| 38 |
]
|
| 39 |
|
| 40 |
[project.urls]
|