Claude commited on
Commit
de46be0
·
unverified ·
1 Parent(s): dbab2ed

fix(web): durcir le parsing XML (defusedxml en dépendance dure) + exceptions précises

Browse files

L'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 CHANGED
@@ -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 Exception as exc: # pragma: no cover — défense en profondeur
74
- _logger.warning("[jobs] mark_orphaned_jobs_interrupted échoué : %s", exc)
 
 
 
 
 
 
 
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
 
picarones/web/corpus_utils.py CHANGED
@@ -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 désactivant les entités externes (protection XXE)."""
31
- try:
32
- import defusedxml.ElementTree as SafeET
33
- return SafeET.fromstring(xml_bytes)
34
- except ImportError:
35
- pass
36
- # Fallback : parser standard
37
- parser = ET.XMLParser()
 
 
 
 
 
 
38
  try:
39
- return ET.fromstring(xml_bytes, parser=parser)
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
 
picarones/web/engine_utils.py CHANGED
@@ -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 Exception: # noqa: BLE001
 
 
57
  version = "installé"
58
  elif installed:
59
  try:
60
  mod = __import__(module_name)
61
  version = getattr(mod, "__version__", "installé")
62
- except Exception: # noqa: BLE001
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
- models = [m.get("name", "") for m in data.get("models", [])]
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 Exception: # noqa: BLE001
 
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
 
pyproject.toml CHANGED
@@ -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]