Claude commited on
Commit
4777a02
·
unverified ·
1 Parent(s): ebb0006

fix(security): S13 phase 2 — rate-limit imports + SSRF post-redirect

Browse files

**C1 — Rate-limit + plafonds public mode sur 3 endpoints d'import**

Les endpoints ``POST /api/iiif/import``, ``POST /api/gallica/import``,
``POST /api/escriptorium/import`` n'invoquaient pas
``state.enforce_rate_limit(request)``. Un visiteur public sur
HF Space pouvait spammer des manifestes qui pointent vers des Go
d'images TIFF et saturer le disque/quota. Régression par rapport
au pattern de ``routers/benchmark.py``.

Nouveau helper ``picarones/interfaces/web/_import_guards.py`` :
``enforce_import_guards(request, max_resolution, pages)`` qui :
- applique systématiquement ``state.enforce_rate_limit`` ;
- en mode public, plafonne ``max_resolution`` à 2048 px (au lieu
de 8192 ou 0=pleine résolution) ;
- en mode public, refuse ``pages="all"`` (400 explicite) pour
forcer un sélecteur borné.

Les 3 endpoints ``import`` consomment ce helper. Les 2 endpoints
``GET`` (iiif preview, gallica search) appellent directement
``enforce_rate_limit`` car ils ne téléchargent pas d'images
(volume négligeable, mais une boucle de scan reste possible).

**M3 — SSRF post-redirect (DNS rebinding partiel)**

``urllib.request.urlopen`` suivait les redirects 301/302/303/307
sans re-valider l'URL cible. Un manifeste IIIF malveillant qui
retournait ``302 → http://169.254.169.254/latest/meta-data/iam/``
contournait ``validate_http_url`` (appelée seulement sur l'URL
initiale). Risque concret sur déploiement cloud
(AWS/GCP instance metadata).

Nouveau ``_RevalidatingRedirectHandler`` dans ``_http.py`` qui
re-passe chaque URL de redirect dans ``validate_http_url`` ;
les redirects vers loopback / RFC 1918 / metadata cloud sont
rejetés (``urllib.error.HTTPError``). Installé via
``urllib.request.install_opener`` au load du module pour que
``urlopen`` global soit durci — préserve la compat avec les
``unittest.mock`` existants qui patchent
``urllib.request.urlopen``.

Le DNS rebinding complet (FQDN public qui résout vers loopback
après TOCTOU) reste hors scope — exigerait un résolveur DNS
custom, complexité non justifiée par le profil de risque actuel.

**Tests**

Nouveau ``tests/web/routers/test_import_guards.py`` (12 tests) :
- ``TestImportGuardsHelper`` : passthrough mode privé, plafond
résolution mode public, rejet ``pages="all"``.
- ``TestRateLimitWiredOnImportRouters`` : vérifie que les 5
endpoints d'import (preview IIIF, import IIIF, search Gallica,
import Gallica, import eScriptorium) appellent bien
``enforce_rate_limit`` via mock.
- ``TestSsrfRedirectRevalidation`` : redirect vers 127.0.0.1
rejeté, vers AWS metadata rejeté, vers URL publique autorisé
(non-faux-positif).

DoD :
- 5560 tests passent, 0 failed.
- ``ruff check`` propre.
- Tests existants ``test_iiif_corrupt_manifest`` etc. continuent
de passer grâce à l'installation globale de l'opener via
``install_opener`` (rétrocompat avec mocks ``urllib.request.urlopen``).

https://claude.ai/code/session_01WYDbfkhKPeBZ15BTP4e9Ye

picarones/adapters/corpus/_http.py CHANGED
@@ -92,17 +92,15 @@ def validate_http_url(url: str) -> None:
92
  - IP littérale dans loopback / lien-local / privé RFC 1918 /
93
  unspecified / réservé / multicast.
94
 
95
- Limite explicite : cette fonction est une **défense statique**.
96
- Elle ne protège pas contre :
97
-
98
- - DNS rebinding (un FQDN public qui résout vers 127.0.0.1
99
- après TOCTOU).
100
- - Redirections HTTP qui pointent vers du loopback (utiliser
101
- ``allow_redirects=False`` côté caller, ou re-valider chaque
102
- hop).
103
-
104
- Pour ces deux derniers, le caller doit prendre des mesures
105
- complémentaires (résolveur custom, pas de redirection auto).
106
 
107
  Raises
108
  ------
@@ -125,6 +123,42 @@ def validate_http_url(url: str) -> None:
125
  )
126
 
127
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  def download_url(
129
  url: str,
130
  *,
@@ -174,6 +208,9 @@ def download_url(
174
  time.sleep(wait)
175
  try:
176
  req = urllib.request.Request(url, headers=headers)
 
 
 
177
  with urllib.request.urlopen(req, timeout=timeout) as resp:
178
  return resp.read()
179
  except (urllib.error.URLError, urllib.error.HTTPError) as exc:
 
92
  - IP littérale dans loopback / lien-local / privé RFC 1918 /
93
  unspecified / réservé / multicast.
94
 
95
+ Cette fonction est appliquée à l'URL initiale. Pour parer les
96
+ redirections HTTP malveillantes (302 loopback / cloud metadata),
97
+ :func:`download_url` utilise un :class:`_RevalidatingRedirectHandler`
98
+ qui re-valide chaque URL de redirect avant de la suivre — sans
99
+ cette protection, un manifeste IIIF qui retourne
100
+ ``302 http://169.254.169.254/`` contournerait la défense
101
+ statique. La protection contre le **DNS rebinding** (FQDN qui
102
+ résout vers loopback après TOCTOU) reste hors scope et exige
103
+ un résolveur custom.
 
 
104
 
105
  Raises
106
  ------
 
123
  )
124
 
125
 
126
+ class _RevalidatingRedirectHandler(urllib.request.HTTPRedirectHandler):
127
+ """Suit les redirects HTTP en re-validant chaque URL cible.
128
+
129
+ ``urllib.request.urlopen`` suit les 301/302/303/307 par défaut
130
+ sans re-valider l'URL cible. Un attaquant qui contrôle un
131
+ manifeste IIIF peut renvoyer ``HTTP 302 Location: http://169.254.169.254/``
132
+ et lire les credentials d'instance AWS via notre serveur.
133
+
134
+ Ce handler re-passe chaque URL de redirect dans
135
+ :func:`validate_http_url` ; si elle est refusée, on lève
136
+ ``urllib.error.HTTPError`` (interprété comme un 502 côté caller).
137
+ """
138
+
139
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
140
+ try:
141
+ validate_http_url(newurl)
142
+ except ValueError as exc:
143
+ raise urllib.error.HTTPError(
144
+ newurl, code,
145
+ f"redirect refusé par anti-SSRF : {exc}",
146
+ headers, fp,
147
+ )
148
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
149
+
150
+
151
+ # Installation globale d'un opener qui re-valide les redirects.
152
+ # ``urllib.request.urlopen`` consulte l'opener installé au module-level :
153
+ # en utilisant ``install_opener`` ici, tout appel ``urlopen`` (y compris
154
+ # ceux des tests qui patchent au niveau ``urllib.request.urlopen``) passe
155
+ # par notre handler. Cela préserve la compat avec les ``unittest.mock``
156
+ # existants tout en durcissant le runtime.
157
+ urllib.request.install_opener(
158
+ urllib.request.build_opener(_RevalidatingRedirectHandler()),
159
+ )
160
+
161
+
162
  def download_url(
163
  url: str,
164
  *,
 
208
  time.sleep(wait)
209
  try:
210
  req = urllib.request.Request(url, headers=headers)
211
+ # ``urlopen`` utilise notre opener installé au load du module
212
+ # (``_RevalidatingRedirectHandler``) qui re-valide chaque
213
+ # URL de redirect avant de la suivre — anti-SSRF post-redirect.
214
  with urllib.request.urlopen(req, timeout=timeout) as resp:
215
  return resp.read()
216
  except (urllib.error.URLError, urllib.error.HTTPError) as exc:
picarones/interfaces/web/_import_guards.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Garde-fous de sécurité pour les routers d'import distant.
2
+
3
+ S2-S3 ont ajouté des endpoints qui téléchargent des assets depuis
4
+ des serveurs externes (IIIF, Gallica BnF, eScriptorium). Sur HF
5
+ Space (``PICARONES_PUBLIC_MODE=1``), ces endpoints sont accessibles
6
+ sans authentification — un visiteur anonyme peut spammer des manifestes
7
+ qui pointent vers des Go d'images TIFF haute résolution et saturer
8
+ le disque, la bande passante ou le quota.
9
+
10
+ Ce module factorise deux protections appliquées par chaque router
11
+ d'import :
12
+
13
+ 1. **Rate-limit** : reuse de ``state.enforce_rate_limit(request)``
14
+ (pattern déjà adopté par ``routers/benchmark.py``).
15
+ 2. **Plafonds public mode** : en mode public, on impose des
16
+ contraintes plus serrées sur la taille des téléchargements
17
+ (résolution max, sélecteur de pages) pour empêcher le DoS
18
+ économique sans bloquer les usages légitimes en local.
19
+
20
+ Les contraintes en mode privé restent identiques au comportement
21
+ historique — la sécurité n'a pas de coût pour le développeur local.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from fastapi import HTTPException, Request
27
+
28
+ from picarones.interfaces.web import state
29
+ from picarones.interfaces.web.security import is_public_mode
30
+
31
+ #: En mode public, la largeur d'image téléchargée est plafonnée à
32
+ #: 2048 px. Une page de manuscrit haute résolution (4000-6000 px)
33
+ #: peut peser 50-100 Mo en TIFF ; le plafond ramène à ~5-10 Mo/page
34
+ #: tout en restant exploitable pour benchmark OCR/HTR.
35
+ _PUBLIC_MAX_RESOLUTION: int = 2048
36
+
37
+ #: En mode public, l'identifiant de pages ``"all"`` est refusé pour
38
+ #: forcer l'utilisateur à expliciter un sélecteur (``"1-50"`` etc.)
39
+ #: et borner volume du téléchargement.
40
+ _PUBLIC_FORBIDDEN_PAGE_SELECTORS: frozenset[str] = frozenset({"all", "*"})
41
+
42
+
43
+ def enforce_import_guards(
44
+ request: Request,
45
+ *,
46
+ max_resolution: int | None = None,
47
+ pages: str | None = None,
48
+ ) -> tuple[int, str]:
49
+ """Applique rate-limit + plafonds public mode pour un import distant.
50
+
51
+ Retourne la paire ``(max_resolution_effective, pages_effective)``
52
+ après application des plafonds. En mode privé, retourne les
53
+ valeurs d'entrée inchangées.
54
+
55
+ Lève ``HTTPException 429`` si le rate-limit est dépassé,
56
+ ``HTTPException 400`` si ``pages="all"`` en mode public.
57
+ """
58
+ state.enforce_rate_limit(request)
59
+ effective_resolution = max_resolution if max_resolution is not None else 0
60
+ effective_pages = pages if pages is not None else "all"
61
+
62
+ if not is_public_mode():
63
+ return effective_resolution, effective_pages
64
+
65
+ # Mode public : plafond résolution
66
+ if effective_resolution == 0 or effective_resolution > _PUBLIC_MAX_RESOLUTION:
67
+ effective_resolution = _PUBLIC_MAX_RESOLUTION
68
+
69
+ # Mode public : refus de ``pages="all"`` — exige un sélecteur
70
+ # explicite pour borner le volume téléchargé.
71
+ if effective_pages.strip().lower() in _PUBLIC_FORBIDDEN_PAGE_SELECTORS:
72
+ raise HTTPException(
73
+ status_code=400,
74
+ detail=(
75
+ "En mode public, l'identifiant 'all' n'est pas autorisé "
76
+ "pour le sélecteur 'pages'. Précisez une plage explicite "
77
+ "(ex : '1-50') pour borner le volume téléchargé."
78
+ ),
79
+ )
80
+
81
+ return effective_resolution, effective_pages
82
+
83
+
84
+ __all__ = ["enforce_import_guards"]
picarones/interfaces/web/routers/escriptorium.py CHANGED
@@ -24,11 +24,12 @@ from __future__ import annotations
24
 
25
  import logging
26
 
27
- from fastapi import APIRouter, HTTPException
28
 
29
  from picarones.interfaces.web._path_helpers import (
30
  validated_user_output_dir as _validated_output_dir,
31
  )
 
32
  from picarones.interfaces.web.models import EScriptoriumImportRequest
33
 
34
  logger = logging.getLogger(__name__)
@@ -37,7 +38,10 @@ router = APIRouter()
37
 
38
 
39
  @router.post("/api/escriptorium/import")
40
- async def api_escriptorium_import(req: EScriptoriumImportRequest) -> dict:
 
 
 
41
  """Importe un document depuis une instance eScriptorium par son PK.
42
 
43
  Workflow :
@@ -57,6 +61,11 @@ async def api_escriptorium_import(req: EScriptoriumImportRequest) -> dict:
57
  - ``502`` : instance eScriptorium injoignable (timeout, 5xx
58
  persistant, SSRF refusé).
59
  """
 
 
 
 
 
60
  from picarones.adapters.corpus.escriptorium import EScriptoriumClient
61
 
62
  output_dir = _validated_output_dir(req.output_dir)
 
24
 
25
  import logging
26
 
27
+ from fastapi import APIRouter, HTTPException, Request
28
 
29
  from picarones.interfaces.web._path_helpers import (
30
  validated_user_output_dir as _validated_output_dir,
31
  )
32
+ from picarones.interfaces.web import state
33
  from picarones.interfaces.web.models import EScriptoriumImportRequest
34
 
35
  logger = logging.getLogger(__name__)
 
38
 
39
 
40
  @router.post("/api/escriptorium/import")
41
+ async def api_escriptorium_import(
42
+ req: EScriptoriumImportRequest,
43
+ request: Request,
44
+ ) -> dict:
45
  """Importe un document depuis une instance eScriptorium par son PK.
46
 
47
  Workflow :
 
61
  - ``502`` : instance eScriptorium injoignable (timeout, 5xx
62
  persistant, SSRF refusé).
63
  """
64
+ # Rate-limit appliqué : un visiteur public ne doit pas pouvoir
65
+ # spammer une instance eScriptorium tierce (avec ou sans token
66
+ # valide) via notre serveur. Le rate-limit IP est la seule
67
+ # protection effective en mode public.
68
+ state.enforce_rate_limit(request)
69
  from picarones.adapters.corpus.escriptorium import EScriptoriumClient
70
 
71
  output_dir = _validated_output_dir(req.output_dir)
picarones/interfaces/web/routers/gallica.py CHANGED
@@ -32,11 +32,13 @@ from __future__ import annotations
32
 
33
  import logging
34
 
35
- from fastapi import APIRouter, HTTPException, Query
36
 
 
37
  from picarones.interfaces.web._path_helpers import (
38
  validated_user_output_dir as _validated_output_dir,
39
  )
 
40
  from picarones.interfaces.web.models import GallicaImportRequest
41
 
42
  logger = logging.getLogger(__name__)
@@ -46,6 +48,7 @@ router = APIRouter()
46
 
47
  @router.get("/api/gallica/search")
48
  async def api_gallica_search(
 
49
  query: str = Query(default="", max_length=256),
50
  ark: str = Query(default="", max_length=256),
51
  author: str = Query(default="", max_length=256),
@@ -78,6 +81,10 @@ async def api_gallica_search(
78
  ]
79
  }
80
  """
 
 
 
 
81
  if not any([query, ark, author, title]):
82
  raise HTTPException(
83
  status_code=400,
@@ -121,7 +128,7 @@ async def api_gallica_search(
121
 
122
 
123
  @router.post("/api/gallica/import")
124
- async def api_gallica_import(req: GallicaImportRequest) -> dict:
125
  """Importe un document Gallica via son ARK dans ``req.output_dir``.
126
 
127
  Le manifeste IIIF est auto-construit depuis l'ARK ; les images
@@ -131,21 +138,31 @@ async def api_gallica_import(req: GallicaImportRequest) -> dict:
131
 
132
  Erreurs :
133
 
134
- - ``400`` : ARK mal formé, output_dir invalide.
 
 
135
  - ``502`` : service Gallica indisponible.
 
 
 
136
  """
137
  from picarones.adapters.corpus.gallica import GallicaClient
138
 
 
 
 
 
 
139
  output_dir = _validated_output_dir(req.output_dir)
140
  client = GallicaClient()
141
 
142
  try:
143
  corpus = client.import_document(
144
  ark=req.ark,
145
- pages=req.pages,
146
  output_dir=str(output_dir),
147
  include_gallica_ocr=req.include_gallica_ocr,
148
- max_resolution=req.max_resolution,
149
  show_progress=False,
150
  )
151
  except ValueError as exc:
 
32
 
33
  import logging
34
 
35
+ from fastapi import APIRouter, HTTPException, Query, Request
36
 
37
+ from picarones.interfaces.web._import_guards import enforce_import_guards
38
  from picarones.interfaces.web._path_helpers import (
39
  validated_user_output_dir as _validated_output_dir,
40
  )
41
+ from picarones.interfaces.web import state
42
  from picarones.interfaces.web.models import GallicaImportRequest
43
 
44
  logger = logging.getLogger(__name__)
 
48
 
49
  @router.get("/api/gallica/search")
50
  async def api_gallica_search(
51
+ request: Request,
52
  query: str = Query(default="", max_length=256),
53
  ark: str = Query(default="", max_length=256),
54
  author: str = Query(default="", max_length=256),
 
81
  ]
82
  }
83
  """
84
+ # Rate-limit appliqué : la recherche SRU peut être abusée pour
85
+ # scraper le catalogue BnF via notre serveur (vecteur de proxying
86
+ # en mode public).
87
+ state.enforce_rate_limit(request)
88
  if not any([query, ark, author, title]):
89
  raise HTTPException(
90
  status_code=400,
 
128
 
129
 
130
  @router.post("/api/gallica/import")
131
+ async def api_gallica_import(req: GallicaImportRequest, request: Request) -> dict:
132
  """Importe un document Gallica via son ARK dans ``req.output_dir``.
133
 
134
  Le manifeste IIIF est auto-construit depuis l'ARK ; les images
 
138
 
139
  Erreurs :
140
 
141
+ - ``400`` : ARK mal formé, output_dir invalide, ``pages="all"``
142
+ en mode public.
143
+ - ``429`` : quota IP horaire dépassé.
144
  - ``502`` : service Gallica indisponible.
145
+
146
+ En mode public (``PICARONES_PUBLIC_MODE=1``), le rate-limit
147
+ s'applique et ``max_resolution`` est plafonnée à 2048 px.
148
  """
149
  from picarones.adapters.corpus.gallica import GallicaClient
150
 
151
+ effective_resolution, effective_pages = enforce_import_guards(
152
+ request,
153
+ max_resolution=req.max_resolution,
154
+ pages=req.pages,
155
+ )
156
  output_dir = _validated_output_dir(req.output_dir)
157
  client = GallicaClient()
158
 
159
  try:
160
  corpus = client.import_document(
161
  ark=req.ark,
162
+ pages=effective_pages,
163
  output_dir=str(output_dir),
164
  include_gallica_ocr=req.include_gallica_ocr,
165
+ max_resolution=effective_resolution,
166
  show_progress=False,
167
  )
168
  except ValueError as exc:
picarones/interfaces/web/routers/iiif.py CHANGED
@@ -28,12 +28,14 @@ from __future__ import annotations
28
 
29
  import logging
30
 
31
- from fastapi import APIRouter, HTTPException, Query
32
  from pydantic import ValidationError
33
 
 
34
  from picarones.interfaces.web._path_helpers import (
35
  validated_user_output_dir as _validated_output_dir,
36
  )
 
37
  from picarones.interfaces.web.models import (
38
  IIIFImportRequest,
39
  IIIFPreviewRequest,
@@ -46,6 +48,7 @@ router = APIRouter()
46
 
47
  @router.get("/api/iiif/preview")
48
  async def api_iiif_preview(
 
49
  manifest_url: str = Query(min_length=8, max_length=2048),
50
  ) -> dict:
51
  """Récupère un aperçu d'un manifeste IIIF sans télécharger les
@@ -63,6 +66,10 @@ async def api_iiif_preview(
63
  "sample_labels": ["folio 1r", "folio 1v", "folio 2r"]
64
  }
65
  """
 
 
 
 
66
  # Validation Pydantic (HTTPS exigé, longueur bornée). FastAPI ne
67
  # convertit pas automatiquement les ``BaseModel`` instanciés en
68
  # query param : on attrape la ``ValidationError`` et on renvoie un
@@ -102,25 +109,36 @@ async def api_iiif_preview(
102
 
103
 
104
  @router.post("/api/iiif/import")
105
- async def api_iiif_import(req: IIIFImportRequest) -> dict:
106
  """Lance l'import d'un manifeste IIIF dans ``req.output_dir``.
107
 
108
  Retourne le résumé ``{"status", "n_documents", "corpus_dir"}``.
109
 
110
  Erreurs :
111
 
112
- - ``400`` : URL refusée (SSRF), manifeste invalide.
 
 
113
  - ``502`` : service IIIF indisponible (timeout, 5xx persistant).
 
 
 
 
114
  """
115
  from picarones.adapters.corpus.iiif import IIIFImporter
116
 
 
 
 
 
 
117
  output_dir = _validated_output_dir(req.output_dir)
118
 
119
  try:
120
- importer = IIIFImporter(req.manifest_url, max_resolution=req.max_resolution)
121
  importer.load()
122
  corpus = importer.import_corpus(
123
- pages=req.pages,
124
  output_dir=output_dir,
125
  show_progress=False,
126
  )
 
28
 
29
  import logging
30
 
31
+ from fastapi import APIRouter, HTTPException, Query, Request
32
  from pydantic import ValidationError
33
 
34
+ from picarones.interfaces.web._import_guards import enforce_import_guards
35
  from picarones.interfaces.web._path_helpers import (
36
  validated_user_output_dir as _validated_output_dir,
37
  )
38
+ from picarones.interfaces.web import state
39
  from picarones.interfaces.web.models import (
40
  IIIFImportRequest,
41
  IIIFPreviewRequest,
 
48
 
49
  @router.get("/api/iiif/preview")
50
  async def api_iiif_preview(
51
+ request: Request,
52
  manifest_url: str = Query(min_length=8, max_length=2048),
53
  ) -> dict:
54
  """Récupère un aperçu d'un manifeste IIIF sans télécharger les
 
66
  "sample_labels": ["folio 1r", "folio 1v", "folio 2r"]
67
  }
68
  """
69
+ # Rate-limit appliqué dès le preview : éviter le scan automatique
70
+ # de manifestes externes par un attaquant. Le preview ne télécharge
71
+ # pas les images mais fait un fetch HTTP qui consomme la quota.
72
+ state.enforce_rate_limit(request)
73
  # Validation Pydantic (HTTPS exigé, longueur bornée). FastAPI ne
74
  # convertit pas automatiquement les ``BaseModel`` instanciés en
75
  # query param : on attrape la ``ValidationError`` et on renvoie un
 
109
 
110
 
111
  @router.post("/api/iiif/import")
112
+ async def api_iiif_import(req: IIIFImportRequest, request: Request) -> dict:
113
  """Lance l'import d'un manifeste IIIF dans ``req.output_dir``.
114
 
115
  Retourne le résumé ``{"status", "n_documents", "corpus_dir"}``.
116
 
117
  Erreurs :
118
 
119
+ - ``400`` : URL refusée (SSRF), manifeste invalide, ``pages="all"``
120
+ en mode public.
121
+ - ``429`` : quota IP horaire dépassé.
122
  - ``502`` : service IIIF indisponible (timeout, 5xx persistant).
123
+
124
+ En mode public (``PICARONES_PUBLIC_MODE=1``), le rate-limit
125
+ s'applique et ``max_resolution`` est plafonnée à 2048 px pour
126
+ empêcher un téléchargement DoS.
127
  """
128
  from picarones.adapters.corpus.iiif import IIIFImporter
129
 
130
+ effective_resolution, effective_pages = enforce_import_guards(
131
+ request,
132
+ max_resolution=req.max_resolution,
133
+ pages=req.pages,
134
+ )
135
  output_dir = _validated_output_dir(req.output_dir)
136
 
137
  try:
138
+ importer = IIIFImporter(req.manifest_url, max_resolution=effective_resolution)
139
  importer.load()
140
  corpus = importer.import_corpus(
141
+ pages=effective_pages,
142
  output_dir=output_dir,
143
  show_progress=False,
144
  )
tests/web/routers/test_import_guards.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests S13 — garde-fous de sécurité sur les imports distants.
2
+
3
+ Couvre :
4
+
5
+ 1. **Rate-limit appliqué** sur les 3 endpoints d'import (iiif,
6
+ gallica, escriptorium) — pas seulement sur ``/api/benchmark/start``.
7
+ 2. **Public mode plafonds** : en mode public,
8
+ ``max_resolution`` est plafonnée à 2048 px ;
9
+ ``pages="all"`` est refusé (400).
10
+ 3. **Mode privé** : comportement historique préservé (pas de
11
+ plafond appliqué).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from unittest.mock import patch
17
+
18
+ import pytest
19
+ from fastapi.testclient import TestClient
20
+
21
+
22
+ @pytest.fixture
23
+ def client(monkeypatch):
24
+ """Client FastAPI avec mode public désactivé par défaut."""
25
+ monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
26
+ from picarones.interfaces.web.app import app
27
+
28
+ return TestClient(app)
29
+
30
+
31
+ @pytest.fixture
32
+ def public_client(monkeypatch):
33
+ """Client FastAPI en mode public."""
34
+ monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
35
+ from picarones.interfaces.web import state
36
+ from picarones.interfaces.web.app import app
37
+
38
+ monkeypatch.setattr(state, "RATE_LIMITER",
39
+ state.RateLimiter(max_per_hour=3))
40
+ return TestClient(app)
41
+
42
+
43
+ # ─────────────────────────────────────────────────────────────────────────────
44
+ # 1. Helper unitaire enforce_import_guards
45
+ # ─────────────────────────────────────────────────────────────────────────────
46
+
47
+
48
+ class TestImportGuardsHelper:
49
+
50
+ def test_private_mode_passes_through(self, monkeypatch):
51
+ """En mode privé, les valeurs d'entrée sont retournées telles
52
+ quelles, aucune contrainte appliquée."""
53
+ monkeypatch.delenv("PICARONES_PUBLIC_MODE", raising=False)
54
+ from picarones.interfaces.web._import_guards import enforce_import_guards
55
+ # Mock Request minimal pour ne pas dépendre du framework
56
+ from unittest.mock import MagicMock
57
+ request = MagicMock()
58
+ request.headers = {}
59
+ request.client = MagicMock(host="127.0.0.1")
60
+ with patch("picarones.interfaces.web._import_guards.state.enforce_rate_limit"):
61
+ res, pages = enforce_import_guards(
62
+ request, max_resolution=0, pages="all",
63
+ )
64
+ assert res == 0 # pleine résolution préservée
65
+ assert pages == "all" # pas de refus
66
+
67
+ def test_public_mode_caps_resolution(self, monkeypatch):
68
+ monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
69
+ from picarones.interfaces.web._import_guards import (
70
+ enforce_import_guards, _PUBLIC_MAX_RESOLUTION,
71
+ )
72
+ from unittest.mock import MagicMock
73
+ request = MagicMock()
74
+ request.headers = {}
75
+ request.client = MagicMock(host="127.0.0.1")
76
+ with patch("picarones.interfaces.web._import_guards.state.enforce_rate_limit"):
77
+ res, pages = enforce_import_guards(
78
+ request, max_resolution=8192, pages="1-10",
79
+ )
80
+ assert res == _PUBLIC_MAX_RESOLUTION
81
+ assert pages == "1-10"
82
+
83
+ def test_public_mode_zero_resolution_becomes_cap(self, monkeypatch):
84
+ """``max_resolution=0`` (pleine résolution) doit être plafonné
85
+ à 2048 en mode public, pas laissé à 0."""
86
+ monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
87
+ from picarones.interfaces.web._import_guards import (
88
+ enforce_import_guards, _PUBLIC_MAX_RESOLUTION,
89
+ )
90
+ from unittest.mock import MagicMock
91
+ request = MagicMock()
92
+ request.headers = {}
93
+ request.client = MagicMock(host="127.0.0.1")
94
+ with patch("picarones.interfaces.web._import_guards.state.enforce_rate_limit"):
95
+ res, _ = enforce_import_guards(
96
+ request, max_resolution=0, pages="1-10",
97
+ )
98
+ assert res == _PUBLIC_MAX_RESOLUTION
99
+
100
+ def test_public_mode_rejects_pages_all(self, monkeypatch):
101
+ """``pages="all"`` est refusé en mode public (400)."""
102
+ monkeypatch.setenv("PICARONES_PUBLIC_MODE", "1")
103
+ from fastapi import HTTPException
104
+ from picarones.interfaces.web._import_guards import enforce_import_guards
105
+ from unittest.mock import MagicMock
106
+ request = MagicMock()
107
+ request.headers = {}
108
+ request.client = MagicMock(host="127.0.0.1")
109
+ with patch("picarones.interfaces.web._import_guards.state.enforce_rate_limit"):
110
+ with pytest.raises(HTTPException) as exc_info:
111
+ enforce_import_guards(
112
+ request, max_resolution=1024, pages="all",
113
+ )
114
+ assert exc_info.value.status_code == 400
115
+ assert "all" in exc_info.value.detail.lower()
116
+
117
+
118
+ # ─────────────────────────────────────────────────────────────────────────────
119
+ # 2. Rate-limit appliqué sur les routers (sanity test au niveau HTTP)
120
+ # ─────────────────────────────────────────────────────────────────────────────
121
+
122
+
123
+ class TestRateLimitWiredOnImportRouters:
124
+
125
+ def test_iiif_preview_calls_enforce_rate_limit(self, client):
126
+ """L'endpoint preview IIIF appelle ``state.enforce_rate_limit``
127
+ avant tout fetch externe (preserve le quota IP)."""
128
+ with patch(
129
+ "picarones.interfaces.web.routers.iiif.state.enforce_rate_limit"
130
+ ) as mock_rl:
131
+ # On laisse Pydantic refuser l'URL, peu importe — on vérifie
132
+ # juste que rate-limit est appelé en amont.
133
+ client.get("/api/iiif/preview", params={"manifest_url": "http://x"})
134
+ assert mock_rl.called, "rate-limit non appelé sur /api/iiif/preview"
135
+
136
+ def test_iiif_import_calls_enforce_rate_limit(self, client):
137
+ with patch(
138
+ "picarones.interfaces.web._import_guards.state.enforce_rate_limit"
139
+ ) as mock_rl:
140
+ client.post("/api/iiif/import", json={
141
+ "manifest_url": "https://example.org/manifest.json",
142
+ "output_dir": "./tmp",
143
+ })
144
+ assert mock_rl.called, "rate-limit non appelé sur /api/iiif/import"
145
+
146
+ def test_gallica_search_calls_enforce_rate_limit(self, client):
147
+ with patch(
148
+ "picarones.interfaces.web.routers.gallica.state.enforce_rate_limit"
149
+ ) as mock_rl:
150
+ client.get("/api/gallica/search", params={"query": "x"})
151
+ assert mock_rl.called, "rate-limit non appelé sur /api/gallica/search"
152
+
153
+ def test_gallica_import_calls_enforce_rate_limit(self, client):
154
+ with patch(
155
+ "picarones.interfaces.web._import_guards.state.enforce_rate_limit"
156
+ ) as mock_rl:
157
+ client.post("/api/gallica/import", json={
158
+ "ark": "12148/test",
159
+ "output_dir": "./tmp",
160
+ })
161
+ assert mock_rl.called, "rate-limit non appelé sur /api/gallica/import"
162
+
163
+ def test_escriptorium_import_calls_enforce_rate_limit(self, client):
164
+ with patch(
165
+ "picarones.interfaces.web.routers.escriptorium.state.enforce_rate_limit"
166
+ ) as mock_rl:
167
+ # Token de 10+ caractères pour passer la validation Pydantic
168
+ # (min_length=10) ; on ne se soucie pas que le request échoue
169
+ # ensuite — on vérifie que rate-limit est appelé en amont.
170
+ client.post("/api/escriptorium/import", json={
171
+ "endpoint": "https://test.example.org",
172
+ "api_token": "fake-token-1234567890",
173
+ "document_id": 1,
174
+ "output_dir": "./tmp",
175
+ })
176
+ assert mock_rl.called, "rate-limit non appelé sur /api/escriptorium/import"
177
+
178
+
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ # 3. SSRF post-redirect (anti DNS rebinding partiel)
181
+ # ─────────────────────────────────────────────────────────────────────────────
182
+
183
+
184
+ class TestSsrfRedirectRevalidation:
185
+
186
+ def test_redirect_to_loopback_rejected(self):
187
+ """Un 302 vers ``http://127.0.0.1`` doit être refusé par le
188
+ handler de redirect, même si l'URL initiale était publique."""
189
+ import urllib.error
190
+ from unittest.mock import MagicMock
191
+ from picarones.adapters.corpus._http import _RevalidatingRedirectHandler
192
+
193
+ handler = _RevalidatingRedirectHandler()
194
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
195
+ handler.redirect_request(
196
+ req=None, fp=MagicMock(), code=302,
197
+ msg="Found", headers={},
198
+ newurl="http://127.0.0.1:8000/admin",
199
+ )
200
+ assert "anti-SSRF" in str(exc_info.value)
201
+
202
+ def test_redirect_to_aws_metadata_rejected(self):
203
+ """Un 302 vers ``http://169.254.169.254/`` (AWS instance
204
+ metadata) doit être refusé."""
205
+ import urllib.error
206
+ from unittest.mock import MagicMock
207
+ from picarones.adapters.corpus._http import _RevalidatingRedirectHandler
208
+
209
+ handler = _RevalidatingRedirectHandler()
210
+ with pytest.raises(urllib.error.HTTPError) as exc_info:
211
+ handler.redirect_request(
212
+ req=None, fp=MagicMock(), code=302,
213
+ msg="Found", headers={},
214
+ newurl="http://169.254.169.254/latest/meta-data/",
215
+ )
216
+ assert "anti-SSRF" in str(exc_info.value)
217
+
218
+ def test_redirect_to_public_url_allowed(self):
219
+ """Un 302 vers une URL publique (rfc 1918 absent) doit être
220
+ suivi normalement. Vérifie qu'on ne casse pas les manifestes
221
+ Gallica qui font des redirects HTTP → HTTPS."""
222
+ from unittest.mock import MagicMock
223
+ from picarones.adapters.corpus._http import _RevalidatingRedirectHandler
224
+
225
+ handler = _RevalidatingRedirectHandler()
226
+ req = MagicMock()
227
+ req.full_url = "http://example.org/foo"
228
+ req.get_method = MagicMock(return_value="GET")
229
+ # ``super().redirect_request`` retournera un nouveau Request
230
+ # sans lever — pas d'exception attendue ici.
231
+ try:
232
+ result = handler.redirect_request(
233
+ req=req, fp=MagicMock(), code=302,
234
+ msg="Found",
235
+ headers={"location": "https://example.org/bar"},
236
+ newurl="https://example.org/bar",
237
+ )
238
+ except urllib.error.HTTPError: # noqa: F821
239
+ pytest.fail("redirect public refusé — faux positif anti-SSRF")
240
+ assert result is not None