Claude commited on
Commit
d03b796
·
unverified ·
1 Parent(s): 193eb98

fix(sprint-f1): sécurité — path traversal, SSRF, CORS, validation inputs

Browse files

Sprint F1 — fermeture de toutes les vulnérabilités identifiées :

- Path traversal profiles : validation regex profile_id [a-z0-9_-]
- Path traversal frontend : resolve() + vérification sous _STATIC_DIR
- Path traversal slug : Field(pattern=r'^[a-z0-9][a-z0-9_-]{0,63}$')
- Path traversal folio_label : sanitisation _sanitize_label()
- Upload fichiers : _sanitize_filename(), limite 100Mo, validation MIME
- SSRF manifest_url : blocklist IP privées, schéma http(s) uniquement,
limite taille manifest 10Mo
- CORS : allow_credentials=False (incompatible avec allow_origins=["*"])
- Inputs : max_length sur search (500), corrections (500k/100k),
model_id (256), restore_to_version >= 1

17 tests de sécurité ajoutés (test_security.py).
477 tests passants, 0 échecs.

https://claude.ai/code/session_015Lht7wNQRzhUaLw94dE9z9

backend/app/api/v1/corpora.py CHANGED
@@ -15,7 +15,7 @@ from datetime import datetime, timezone
15
 
16
  # 2. third-party
17
  from fastapi import APIRouter, Depends, HTTPException
18
- from pydantic import BaseModel, ConfigDict
19
  from sqlalchemy import select
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
 
@@ -29,9 +29,9 @@ router = APIRouter(prefix="/corpora", tags=["corpora"])
29
  # ── Schémas de requête / réponse ─────────────────────────────────────────────
30
 
31
  class CorpusCreate(BaseModel):
32
- slug: str
33
- title: str
34
- profile_id: str
35
 
36
 
37
  class CorpusResponse(BaseModel):
 
15
 
16
  # 2. third-party
17
  from fastapi import APIRouter, Depends, HTTPException
18
+ from pydantic import BaseModel, ConfigDict, Field
19
  from sqlalchemy import select
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
 
 
29
  # ── Schémas de requête / réponse ─────────────────────────────────────────────
30
 
31
  class CorpusCreate(BaseModel):
32
+ slug: str = Field(..., pattern=r"^[a-z0-9][a-z0-9_-]{0,63}$")
33
+ title: str = Field(..., min_length=1, max_length=256)
34
+ profile_id: str = Field(..., pattern=r"^[a-z0-9][a-z0-9_-]*$")
35
 
36
 
37
  class CorpusResponse(BaseModel):
backend/app/api/v1/ingest.py CHANGED
@@ -11,13 +11,14 @@ Règle : ingestion = création des PageModel en BDD uniquement.
11
  """
12
  # 1. stdlib
13
  import logging
 
14
  import uuid
15
  from pathlib import Path
16
 
17
  # 2. third-party
18
  import httpx
19
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
20
- from pydantic import BaseModel
21
  from sqlalchemy import func, select
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
 
@@ -30,6 +31,28 @@ logger = logging.getLogger(__name__)
30
 
31
  router = APIRouter(tags=["ingestion"])
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  # ── Schémas ───────────────────────────────────────────────────────────────────
35
 
@@ -38,8 +61,8 @@ class IIIFManifestRequest(BaseModel):
38
 
39
 
40
  class IIIFImagesRequest(BaseModel):
41
- urls: list[str]
42
- folio_labels: list[str]
43
 
44
 
45
  class IngestResponse(BaseModel):
@@ -144,11 +167,31 @@ _MANIFEST_HEADERS = {
144
  }
145
 
146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  async def _fetch_json_manifest(url: str) -> dict:
148
- """Télécharge un manifest IIIF. Fonction isolée pour faciliter les tests."""
 
149
  async with httpx.AsyncClient() as client:
150
  resp = await client.get(url, headers=_MANIFEST_HEADERS, follow_redirects=True, timeout=30.0)
151
  resp.raise_for_status()
 
 
152
  return resp.json()
153
 
154
 
@@ -202,16 +245,32 @@ async def ingest_files(
202
  seq = await _next_sequence(db, ms.id)
203
 
204
  # Collect labels and detect duplicates
205
- labels = [Path(f.filename or f"file_{i}").stem for i, f in enumerate(files)]
206
  dupes = _find_duplicate_labels(labels)
207
 
208
  created: list[PageModel] = []
209
  skipped = 0
210
  for i, upload in enumerate(files):
211
- filename = Path(upload.filename or f"file_{i}").name
 
 
 
 
 
 
 
 
212
  folio_label = labels[i]
213
  page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
214
 
 
 
 
 
 
 
 
 
215
  master_dir = (
216
  _config_module.settings.data_dir
217
  / "corpora"
@@ -221,7 +280,6 @@ async def ingest_files(
221
  )
222
  master_dir.mkdir(parents=True, exist_ok=True)
223
  master_path = master_dir / filename
224
- content = await upload.read()
225
  master_path.write_bytes(content)
226
 
227
  page = await _create_page(
@@ -260,6 +318,8 @@ async def ingest_iiif_manifest(
260
 
261
  try:
262
  manifest = await _fetch_json_manifest(body.manifest_url)
 
 
263
  except httpx.HTTPStatusError as exc:
264
  raise HTTPException(
265
  status_code=502,
@@ -302,7 +362,7 @@ async def ingest_iiif_manifest(
302
  seq = await _next_sequence(db, ms.id)
303
 
304
  # Collect labels and detect duplicates
305
- labels = [_extract_canvas_label(canvas, i) for i, canvas in enumerate(canvases)]
306
  dupes = _find_duplicate_labels(labels)
307
 
308
  created: list[PageModel] = []
@@ -358,11 +418,12 @@ async def ingest_iiif_images(
358
  ms = await _get_or_create_manuscript(db, corpus_id)
359
  seq = await _next_sequence(db, ms.id)
360
 
361
- dupes = _find_duplicate_labels(body.folio_labels)
 
362
 
363
  created: list[PageModel] = []
364
  skipped = 0
365
- for i, (url, folio_label) in enumerate(zip(body.urls, body.folio_labels)):
366
  page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
367
  page = await _create_page(
368
  db, ms.id, page_id, folio_label, seq + i,
 
11
  """
12
  # 1. stdlib
13
  import logging
14
+ import re
15
  import uuid
16
  from pathlib import Path
17
 
18
  # 2. third-party
19
  import httpx
20
  from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
21
+ from pydantic import BaseModel, Field
22
  from sqlalchemy import func, select
23
  from sqlalchemy.ext.asyncio import AsyncSession
24
 
 
31
 
32
  router = APIRouter(tags=["ingestion"])
33
 
34
+ # ── Constantes de sécurité ────────────────────────────────────────────────────
35
+
36
+ _SAFE_LABEL_RE = re.compile(r"^[\w\-\.]+$")
37
+ _MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 Mo par fichier
38
+ _ALLOWED_MIME_PREFIXES = ("image/",)
39
+
40
+
41
+ def _sanitize_label(label: str) -> str:
42
+ """Nettoie un folio_label : garde uniquement alphanum, -, _, ."""
43
+ clean = Path(label).name # retire tout chemin
44
+ if not _SAFE_LABEL_RE.match(clean) or not clean:
45
+ clean = re.sub(r"[^\w\-\.]", "_", clean) or "page"
46
+ return clean
47
+
48
+
49
+ def _sanitize_filename(name: str) -> str:
50
+ """Nettoie un nom de fichier uploadé : garde uniquement le basename sûr."""
51
+ clean = Path(name).name
52
+ if not _SAFE_LABEL_RE.match(clean) or not clean:
53
+ clean = f"{uuid.uuid4().hex[:12]}.bin"
54
+ return clean
55
+
56
 
57
  # ── Schémas ───────────────────────────────────────────────────────────────────
58
 
 
61
 
62
 
63
  class IIIFImagesRequest(BaseModel):
64
+ urls: list[str] = Field(..., max_length=5000)
65
+ folio_labels: list[str] = Field(..., max_length=5000)
66
 
67
 
68
  class IngestResponse(BaseModel):
 
167
  }
168
 
169
 
170
+ _MAX_MANIFEST_BYTES = 10 * 1024 * 1024 # 10 Mo max pour un manifest JSON
171
+
172
+
173
+ def _validate_url(url: str) -> None:
174
+ """Rejette les URLs non-HTTP et les cibles réseau privé (SSRF)."""
175
+ from urllib.parse import urlparse
176
+
177
+ parsed = urlparse(url)
178
+ if parsed.scheme not in ("http", "https"):
179
+ raise ValueError(f"Schéma non autorisé : {parsed.scheme!r}")
180
+ host = (parsed.hostname or "").lower()
181
+ # Bloquer les adresses privées / locales
182
+ blocked = ("localhost", "127.0.0.1", "0.0.0.0", "[::1]", "metadata.google.internal")
183
+ if host in blocked or host.startswith("169.254.") or host.startswith("10.") or host.startswith("192.168."):
184
+ raise ValueError(f"Hôte interdit : {host}")
185
+
186
+
187
  async def _fetch_json_manifest(url: str) -> dict:
188
+ """Télécharge un manifest IIIF avec protections SSRF + taille max."""
189
+ _validate_url(url)
190
  async with httpx.AsyncClient() as client:
191
  resp = await client.get(url, headers=_MANIFEST_HEADERS, follow_redirects=True, timeout=30.0)
192
  resp.raise_for_status()
193
+ if len(resp.content) > _MAX_MANIFEST_BYTES:
194
+ raise ValueError(f"Manifest trop volumineux ({len(resp.content)} octets)")
195
  return resp.json()
196
 
197
 
 
245
  seq = await _next_sequence(db, ms.id)
246
 
247
  # Collect labels and detect duplicates
248
+ labels = [_sanitize_label(Path(f.filename or f"file_{i}").stem) for i, f in enumerate(files)]
249
  dupes = _find_duplicate_labels(labels)
250
 
251
  created: list[PageModel] = []
252
  skipped = 0
253
  for i, upload in enumerate(files):
254
+ # Validation MIME type
255
+ ctype = upload.content_type or ""
256
+ if not any(ctype.startswith(p) for p in _ALLOWED_MIME_PREFIXES):
257
+ raise HTTPException(
258
+ status_code=422,
259
+ detail=f"Type MIME non autorisé : {ctype!r}. Seules les images sont acceptées.",
260
+ )
261
+
262
+ filename = _sanitize_filename(upload.filename or f"file_{i}.bin")
263
  folio_label = labels[i]
264
  page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
265
 
266
+ content = await upload.read()
267
+ # Validation taille
268
+ if len(content) > _MAX_UPLOAD_BYTES:
269
+ raise HTTPException(
270
+ status_code=413,
271
+ detail=f"Fichier trop volumineux ({len(content)} octets). Maximum : {_MAX_UPLOAD_BYTES}.",
272
+ )
273
+
274
  master_dir = (
275
  _config_module.settings.data_dir
276
  / "corpora"
 
280
  )
281
  master_dir.mkdir(parents=True, exist_ok=True)
282
  master_path = master_dir / filename
 
283
  master_path.write_bytes(content)
284
 
285
  page = await _create_page(
 
318
 
319
  try:
320
  manifest = await _fetch_json_manifest(body.manifest_url)
321
+ except ValueError as exc:
322
+ raise HTTPException(status_code=400, detail=str(exc))
323
  except httpx.HTTPStatusError as exc:
324
  raise HTTPException(
325
  status_code=502,
 
362
  seq = await _next_sequence(db, ms.id)
363
 
364
  # Collect labels and detect duplicates
365
+ labels = [_sanitize_label(_extract_canvas_label(canvas, i)) for i, canvas in enumerate(canvases)]
366
  dupes = _find_duplicate_labels(labels)
367
 
368
  created: list[PageModel] = []
 
418
  ms = await _get_or_create_manuscript(db, corpus_id)
419
  seq = await _next_sequence(db, ms.id)
420
 
421
+ sanitized_labels = [_sanitize_label(lbl) for lbl in body.folio_labels]
422
+ dupes = _find_duplicate_labels(sanitized_labels)
423
 
424
  created: list[PageModel] = []
425
  skipped = 0
426
+ for i, (url, folio_label) in enumerate(zip(body.urls, sanitized_labels)):
427
  page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
428
  page = await _create_page(
429
  db, ms.id, page_id, folio_label, seq + i,
backend/app/api/v1/models_api.py CHANGED
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
17
 
18
  # 2. third-party
19
  from fastapi import APIRouter, Depends, HTTPException
20
- from pydantic import BaseModel, ConfigDict
21
  from sqlalchemy.ext.asyncio import AsyncSession
22
 
23
  # 3. local
@@ -42,9 +42,9 @@ class ProviderInfo(BaseModel):
42
 
43
 
44
  class ModelSelectRequest(BaseModel):
45
- model_id: str
46
- provider_type: str
47
- display_name: str = ""
48
 
49
 
50
  class ModelConfigResponse(BaseModel):
 
17
 
18
  # 2. third-party
19
  from fastapi import APIRouter, Depends, HTTPException
20
+ from pydantic import BaseModel, ConfigDict, Field
21
  from sqlalchemy.ext.asyncio import AsyncSession
22
 
23
  # 3. local
 
42
 
43
 
44
  class ModelSelectRequest(BaseModel):
45
+ model_id: str = Field(..., min_length=1, max_length=256)
46
+ provider_type: str = Field(..., min_length=1, max_length=64)
47
+ display_name: str = Field("", max_length=256)
48
 
49
 
50
  class ModelConfigResponse(BaseModel):
backend/app/api/v1/pages.py CHANGED
@@ -18,7 +18,7 @@ from typing import Any
18
 
19
  # 2. third-party
20
  from fastapi import APIRouter, Depends, HTTPException
21
- from pydantic import BaseModel, ConfigDict, ValidationError
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
 
24
  # 3. local
@@ -42,12 +42,12 @@ class CorrectionsRequest(BaseModel):
42
  indiquée est restaurée (avec incrémentation de editorial.version).
43
  """
44
 
45
- ocr_diplomatic_text: str | None = None
46
- editorial_status: str | None = None
47
- commentary_public: str | None = None
48
- commentary_scholarly: str | None = None
49
  region_validations: dict[str, str] | None = None
50
- restore_to_version: int | None = None
51
 
52
 
53
  class VersionInfo(BaseModel):
 
18
 
19
  # 2. third-party
20
  from fastapi import APIRouter, Depends, HTTPException
21
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
22
  from sqlalchemy.ext.asyncio import AsyncSession
23
 
24
  # 3. local
 
42
  indiquée est restaurée (avec incrémentation de editorial.version).
43
  """
44
 
45
+ ocr_diplomatic_text: str | None = Field(None, max_length=500_000)
46
+ editorial_status: str | None = Field(None, max_length=50)
47
+ commentary_public: str | None = Field(None, max_length=100_000)
48
+ commentary_scholarly: str | None = Field(None, max_length=100_000)
49
  region_validations: dict[str, str] | None = None
50
+ restore_to_version: int | None = Field(None, ge=1)
51
 
52
 
53
  class VersionInfo(BaseModel):
backend/app/api/v1/profiles.py CHANGED
@@ -10,6 +10,7 @@ Ils sont validés par CorpusProfile avant d'être retournés.
10
  # 1. stdlib
11
  import json
12
  import logging
 
13
  from pathlib import Path
14
 
15
  # 2. third-party
@@ -57,9 +58,14 @@ async def list_profiles() -> list[dict]:
57
  return profiles
58
 
59
 
 
 
 
60
  @router.get("/{profile_id}", response_model=dict)
61
  async def get_profile(profile_id: str) -> dict:
62
  """Retourne un profil par son id (nom du fichier sans extension)."""
 
 
63
  path = settings.profiles_dir / f"{profile_id}.json"
64
  if not path.exists():
65
  raise HTTPException(status_code=404, detail="Profil introuvable")
 
10
  # 1. stdlib
11
  import json
12
  import logging
13
+ import re
14
  from pathlib import Path
15
 
16
  # 2. third-party
 
58
  return profiles
59
 
60
 
61
+ _SAFE_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
62
+
63
+
64
  @router.get("/{profile_id}", response_model=dict)
65
  async def get_profile(profile_id: str) -> dict:
66
  """Retourne un profil par son id (nom du fichier sans extension)."""
67
+ if not _SAFE_ID_RE.match(profile_id):
68
+ raise HTTPException(status_code=400, detail="profile_id invalide")
69
  path = settings.profiles_dir / f"{profile_id}.json"
70
  if not path.exists():
71
  raise HTTPException(status_code=404, detail="Profil introuvable")
backend/app/api/v1/search.py CHANGED
@@ -95,7 +95,7 @@ def _score_master(data: dict, query_normalized: str) -> tuple[int, str]:
95
 
96
  @router.get("/search", response_model=list[SearchResult])
97
  async def search_pages(
98
- q: str = Query(..., min_length=2, description="Requête de recherche (min. 2 caractères)"),
99
  ) -> list[SearchResult]:
100
  """Recherche plein texte dans les master.json de tous les corpus.
101
 
 
95
 
96
  @router.get("/search", response_model=list[SearchResult])
97
  async def search_pages(
98
+ q: str = Query(..., min_length=2, max_length=500, description="Requête de recherche (2–500 caractères)"),
99
  ) -> list[SearchResult]:
100
  """Recherche plein texte dans les master.json de tous les corpus.
101
 
backend/app/main.py CHANGED
@@ -65,11 +65,11 @@ app = FastAPI(
65
  lifespan=lifespan,
66
  )
67
 
68
- # ── CORS (dev : tous les origines autorisés) ──────────────────────────────────
69
  app.add_middleware(
70
  CORSMiddleware,
71
  allow_origins=["*"],
72
- allow_credentials=True,
73
  allow_methods=["*"],
74
  allow_headers=["*"],
75
  )
@@ -97,8 +97,9 @@ async def serve_frontend(full_path: str) -> FileResponse | RedirectResponse:
97
  if full_path.startswith("api/"):
98
  raise HTTPException(status_code=404, detail=f"Endpoint not found: /{full_path}")
99
  if _STATIC_DIR.is_dir():
100
- candidate = _STATIC_DIR / full_path
101
- if candidate.is_file():
 
102
  return FileResponse(candidate)
103
  index = _STATIC_DIR / "index.html"
104
  if index.exists():
 
65
  lifespan=lifespan,
66
  )
67
 
68
+ # ── CORS (dev : toutes les origines autorisées, sans credentials) ──────────────
69
  app.add_middleware(
70
  CORSMiddleware,
71
  allow_origins=["*"],
72
+ allow_credentials=False,
73
  allow_methods=["*"],
74
  allow_headers=["*"],
75
  )
 
97
  if full_path.startswith("api/"):
98
  raise HTTPException(status_code=404, detail=f"Endpoint not found: /{full_path}")
99
  if _STATIC_DIR.is_dir():
100
+ candidate = (_STATIC_DIR / full_path).resolve()
101
+ # Empêcher le path traversal : le fichier résolu doit être sous _STATIC_DIR
102
+ if candidate.is_file() and str(candidate).startswith(str(_STATIC_DIR.resolve())):
103
  return FileResponse(candidate)
104
  index = _STATIC_DIR / "index.html"
105
  if index.exists():
backend/tests/test_security.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests de sécurité — Sprint F1.
3
+
4
+ Vérifie que toutes les vulnérabilités identifiées sont corrigées :
5
+ - Path traversal sur profiles, slug, folio_label, frontend serving
6
+ - SSRF sur manifest_url
7
+ - Validation des entrées (taille, format)
8
+ """
9
+ # 1. stdlib
10
+ import pytest
11
+
12
+ # 2. third-party — fixtures API
13
+ from tests.conftest_api import async_client, db_session # noqa: F401
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Path traversal — profiles
18
+ # ---------------------------------------------------------------------------
19
+
20
+ @pytest.mark.asyncio
21
+ async def test_profile_path_traversal_dotdot(async_client):
22
+ """Un profile_id contenant '..' doit être rejeté (400)."""
23
+ resp = await async_client.get("/api/v1/profiles/..passwd")
24
+ assert resp.status_code == 400
25
+
26
+
27
+ @pytest.mark.asyncio
28
+ async def test_profile_path_traversal_slash(async_client):
29
+ """Un profile_id avec un slash (même encodé) doit être rejeté (400 ou 404)."""
30
+ # FastAPI normalise les chemins, donc un slash dans l'ID ne sera pas transmis.
31
+ # On teste avec un ID contenant des caractères spéciaux interdits.
32
+ resp = await async_client.get("/api/v1/profiles/UPPER_CASE")
33
+ assert resp.status_code == 400
34
+
35
+
36
+ @pytest.mark.asyncio
37
+ async def test_profile_path_traversal_special_chars(async_client):
38
+ """Un profile_id avec des caractères spéciaux doit être rejeté."""
39
+ resp = await async_client.get("/api/v1/profiles/test@profile")
40
+ assert resp.status_code == 400
41
+
42
+
43
+ @pytest.mark.asyncio
44
+ async def test_profile_valid_id_not_found(async_client):
45
+ """Un profile_id valide mais inexistant retourne 404 (pas 400)."""
46
+ resp = await async_client.get("/api/v1/profiles/does-not-exist")
47
+ assert resp.status_code == 404
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Path traversal — corpus slug
52
+ # ---------------------------------------------------------------------------
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_corpus_slug_path_traversal(async_client):
56
+ """Un slug avec ../ doit être rejeté par la validation Pydantic."""
57
+ resp = await async_client.post("/api/v1/corpora", json={
58
+ "slug": "../../malicious",
59
+ "title": "Test",
60
+ "profile_id": "medieval-illuminated",
61
+ })
62
+ assert resp.status_code == 422
63
+
64
+
65
+ @pytest.mark.asyncio
66
+ async def test_corpus_slug_with_spaces(async_client):
67
+ """Un slug avec des espaces doit être rejeté."""
68
+ resp = await async_client.post("/api/v1/corpora", json={
69
+ "slug": "my corpus",
70
+ "title": "Test",
71
+ "profile_id": "medieval-illuminated",
72
+ })
73
+ assert resp.status_code == 422
74
+
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_corpus_slug_uppercase(async_client):
78
+ """Un slug avec des majuscules doit être rejeté (lowercase only)."""
79
+ resp = await async_client.post("/api/v1/corpora", json={
80
+ "slug": "MyCorpus",
81
+ "title": "Test",
82
+ "profile_id": "medieval-illuminated",
83
+ })
84
+ assert resp.status_code == 422
85
+
86
+
87
+ @pytest.mark.asyncio
88
+ async def test_corpus_slug_valid(async_client):
89
+ """Un slug valide doit être accepté."""
90
+ resp = await async_client.post("/api/v1/corpora", json={
91
+ "slug": "my-corpus-01",
92
+ "title": "Test",
93
+ "profile_id": "medieval-illuminated",
94
+ })
95
+ assert resp.status_code == 201
96
+
97
+
98
+ @pytest.mark.asyncio
99
+ async def test_corpus_slug_empty(async_client):
100
+ """Un slug vide doit être rejeté."""
101
+ resp = await async_client.post("/api/v1/corpora", json={
102
+ "slug": "",
103
+ "title": "Test",
104
+ "profile_id": "medieval-illuminated",
105
+ })
106
+ assert resp.status_code == 422
107
+
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_corpus_title_too_long(async_client):
111
+ """Un titre trop long (>256 chars) doit être rejeté."""
112
+ resp = await async_client.post("/api/v1/corpora", json={
113
+ "slug": "test-long",
114
+ "title": "x" * 300,
115
+ "profile_id": "medieval-illuminated",
116
+ })
117
+ assert resp.status_code == 422
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # SSRF — manifest_url
122
+ # ---------------------------------------------------------------------------
123
+
124
+ @pytest.mark.asyncio
125
+ async def test_ssrf_localhost(async_client):
126
+ """Un manifest_url pointant vers localhost doit être rejeté."""
127
+ # Créer un corpus d'abord
128
+ create = await async_client.post("/api/v1/corpora", json={
129
+ "slug": "ssrf-test", "title": "SSRF", "profile_id": "test",
130
+ })
131
+ cid = create.json()["id"]
132
+
133
+ resp = await async_client.post(f"/api/v1/corpora/{cid}/ingest/iiif-manifest", json={
134
+ "manifest_url": "http://localhost:8000/secret",
135
+ })
136
+ assert resp.status_code == 400
137
+ assert "interdit" in resp.json()["detail"].lower() or "localhost" in resp.json()["detail"].lower()
138
+
139
+
140
+ @pytest.mark.asyncio
141
+ async def test_ssrf_metadata_ip(async_client):
142
+ """Un manifest_url vers 169.254.x.x (cloud metadata) doit être rejeté."""
143
+ create = await async_client.post("/api/v1/corpora", json={
144
+ "slug": "ssrf-meta", "title": "SSRF", "profile_id": "test",
145
+ })
146
+ cid = create.json()["id"]
147
+
148
+ resp = await async_client.post(f"/api/v1/corpora/{cid}/ingest/iiif-manifest", json={
149
+ "manifest_url": "http://169.254.169.254/latest/meta-data/",
150
+ })
151
+ assert resp.status_code == 400
152
+
153
+
154
+ @pytest.mark.asyncio
155
+ async def test_ssrf_file_scheme(async_client):
156
+ """Un manifest_url avec file:// doit être rejeté."""
157
+ create = await async_client.post("/api/v1/corpora", json={
158
+ "slug": "ssrf-file", "title": "SSRF", "profile_id": "test",
159
+ })
160
+ cid = create.json()["id"]
161
+
162
+ resp = await async_client.post(f"/api/v1/corpora/{cid}/ingest/iiif-manifest", json={
163
+ "manifest_url": "file:///etc/passwd",
164
+ })
165
+ assert resp.status_code == 400
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Input validation — search
170
+ # ---------------------------------------------------------------------------
171
+
172
+ @pytest.mark.asyncio
173
+ async def test_search_query_too_long(async_client):
174
+ """Une requête de recherche >500 chars doit être rejetée."""
175
+ resp = await async_client.get("/api/v1/search", params={"q": "x" * 501})
176
+ assert resp.status_code == 422
177
+
178
+
179
+ @pytest.mark.asyncio
180
+ async def test_search_query_max_length_ok(async_client):
181
+ """Une requête de recherche de 500 chars doit être acceptée (0 résultat)."""
182
+ resp = await async_client.get("/api/v1/search", params={"q": "x" * 500})
183
+ assert resp.status_code == 200
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # Input validation — model selection
188
+ # ---------------------------------------------------------------------------
189
+
190
+ @pytest.mark.asyncio
191
+ async def test_model_id_too_long(async_client):
192
+ """Un model_id >256 chars doit être rejeté."""
193
+ create = await async_client.post("/api/v1/corpora", json={
194
+ "slug": "model-test", "title": "T", "profile_id": "test",
195
+ })
196
+ cid = create.json()["id"]
197
+
198
+ resp = await async_client.put(f"/api/v1/corpora/{cid}/model", json={
199
+ "model_id": "x" * 300,
200
+ "provider_type": "google_ai_studio",
201
+ })
202
+ assert resp.status_code == 422
203
+
204
+
205
+ # ---------------------------------------------------------------------------
206
+ # Input validation — corrections
207
+ # ---------------------------------------------------------------------------
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_corrections_restore_negative_version(async_client):
211
+ """restore_to_version < 1 doit être rejeté."""
212
+ resp = await async_client.post("/api/v1/pages/fake-page/corrections", json={
213
+ "restore_to_version": 0,
214
+ })
215
+ assert resp.status_code == 422