Spaces:
Build error
fix(sprint-f1): sécurité — path traversal, SSRF, CORS, validation inputs
Browse filesSprint 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 +4 -4
- backend/app/api/v1/ingest.py +71 -10
- backend/app/api/v1/models_api.py +4 -4
- backend/app/api/v1/pages.py +6 -6
- backend/app/api/v1/profiles.py +6 -0
- backend/app/api/v1/search.py +1 -1
- backend/app/main.py +5 -4
- backend/tests/test_security.py +215 -0
|
@@ -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):
|
|
@@ -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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 362 |
|
| 363 |
created: list[PageModel] = []
|
| 364 |
skipped = 0
|
| 365 |
-
for i, (url, folio_label) in enumerate(zip(body.urls,
|
| 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,
|
|
@@ -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):
|
|
@@ -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):
|
|
@@ -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")
|
|
@@ -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 (
|
| 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 |
|
|
@@ -65,11 +65,11 @@ app = FastAPI(
|
|
| 65 |
lifespan=lifespan,
|
| 66 |
)
|
| 67 |
|
| 68 |
-
# ── CORS (dev :
|
| 69 |
app.add_middleware(
|
| 70 |
CORSMiddleware,
|
| 71 |
allow_origins=["*"],
|
| 72 |
-
allow_credentials=
|
| 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 |
-
|
|
|
|
| 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():
|
|
@@ -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
|