Spaces:
Sleeping
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
|
@@ -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 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 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:
|
|
@@ -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"]
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -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=
|
| 146 |
output_dir=str(output_dir),
|
| 147 |
include_gallica_ocr=req.include_gallica_ocr,
|
| 148 |
-
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:
|
|
@@ -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=
|
| 121 |
importer.load()
|
| 122 |
corpus = importer.import_corpus(
|
| 123 |
-
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 |
)
|
|
@@ -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
|