Claude commited on
Commit
965e8e0
·
unverified ·
1 Parent(s): c7f9d01

fix(security+infra): Sprint Fix 1 — path traversal, docker-compose, CORS

Browse files

- Fix critical path traversal vulnerability in job_runner.py: validate that
image_master_path resolves under data_dir before reading (arbitrary file
read via crafted image_master_path)
- Fix docker-compose.yml: correct Dockerfile path (was pointing to deleted
infra/Dockerfile), replace obsolete env vars (AI_PROVIDER, GOOGLE_AI_API_KEY,
GOOGLE_VERTEX_PROJECT, GOOGLE_VERTEX_LOCATION) with actual ones used by
the backend (VERTEX_API_KEY, VERTEX_SERVICE_ACCOUNT_JSON, MISTRAL_API_KEY)
- Create .env.example referenced by README but previously missing
- Make CORS origins configurable via CORS_ORIGINS env var (default: ["*"])
- Add 3 security tests for path traversal attack vectors

563 tests pass (560 original + 3 new), 0 regressions.

https://claude.ai/code/session_01UB4he7RdRPHLvNjky4X8Sw

.env.example ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ─── IIIF Studio — Variables d'environnement ───────────────────────────────
2
+ # Copier ce fichier : cp .env.example .env
3
+ # Renseigner les clés des providers IA que vous souhaitez utiliser.
4
+ # Au moins UNE clé est nécessaire pour que le pipeline fonctionne.
5
+ #
6
+ # Le backend détecte automatiquement quels providers sont disponibles
7
+ # selon les clés présentes. Pas besoin de sélecteur AI_PROVIDER.
8
+
9
+ # ── Google AI Studio (développement, gratuit) ────────────────────────────────
10
+ GOOGLE_AI_STUDIO_API_KEY=
11
+
12
+ # ── Vertex AI avec clé API Express (production) ─────────────────────────────
13
+ VERTEX_API_KEY=
14
+
15
+ # ── Vertex AI avec compte de service (institutions) ─────────────────────────
16
+ # Coller le JSON complet du compte de service sur une seule ligne.
17
+ VERTEX_SERVICE_ACCOUNT_JSON=
18
+
19
+ # ── Mistral AI ───────────────────────────────────────────────────────────────
20
+ MISTRAL_API_KEY=
21
+
22
+ # ── CORS (optionnel) ─────────────────────────────────────────────────────────
23
+ # Par défaut : * (toutes origines, adapté au développement).
24
+ # En production, restreindre : CORS_ORIGINS=["https://mon-domaine.fr"]
25
+ # CORS_ORIGINS=*
backend/app/config.py CHANGED
@@ -34,6 +34,7 @@ class Settings(BaseSettings):
34
  # ── Serveur ──────────────────────────────────────────────────────────────
35
  base_url: str = "http://localhost:8000"
36
  data_dir: Path = Path("data")
 
37
 
38
  # ── Chemins des ressources statiques ─────────────────────────────────────
39
  # Calculés depuis la racine du dépôt ; surchargeables via variables d'env.
 
34
  # ── Serveur ──────────────────────────────────────────────────────────────
35
  base_url: str = "http://localhost:8000"
36
  data_dir: Path = Path("data")
37
+ cors_origins: list[str] = ["*"]
38
 
39
  # ── Chemins des ressources statiques ─────────────────────────────────────
40
  # Calculés depuis la racine du dépôt ; surchargeables via variables d'env.
backend/app/main.py CHANGED
@@ -65,10 +65,12 @@ app = FastAPI(
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=["*"],
 
65
  lifespan=lifespan,
66
  )
67
 
68
+ # ── CORS (configurable via CORS_ORIGINS ; défaut : ["*"] pour le dev) ─────────
69
+ from app.config import settings as _settings
70
+
71
  app.add_middleware(
72
  CORSMiddleware,
73
+ allow_origins=_settings.cors_origins,
74
  allow_credentials=False,
75
  allow_methods=["*"],
76
  allow_headers=["*"],
backend/app/services/job_runner.py CHANGED
@@ -135,7 +135,18 @@ async def _run_job_impl(job_id: str, db: AsyncSession) -> None:
135
  image_source, corpus.slug, page.folio_label, data_dir
136
  )
137
  elif image_source:
138
- source_bytes = Path(image_source).read_bytes()
 
 
 
 
 
 
 
 
 
 
 
139
  image_info = create_derivatives(
140
  source_bytes, image_source, corpus.slug, page.folio_label, data_dir
141
  )
 
135
  image_source, corpus.slug, page.folio_label, data_dir
136
  )
137
  elif image_source:
138
+ # Validation anti path-traversal : le chemin résolu doit être
139
+ # sous data_dir. Empêche la lecture de fichiers arbitraires
140
+ # si image_master_path contient des séquences ../ ou un
141
+ # chemin absolu hors du répertoire de données.
142
+ source_path = Path(image_source).resolve()
143
+ data_dir_resolved = data_dir.resolve()
144
+ if not str(source_path).startswith(str(data_dir_resolved) + "/") and source_path != data_dir_resolved:
145
+ raise ValueError(
146
+ f"Chemin image hors du répertoire de données interdit : "
147
+ f"{image_source!r} (résolu : {source_path})"
148
+ )
149
+ source_bytes = source_path.read_bytes()
150
  image_info = create_derivatives(
151
  source_bytes, image_source, corpus.slug, page.folio_label, data_dir
152
  )
backend/tests/test_security.py CHANGED
@@ -1,17 +1,33 @@
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
@@ -213,3 +229,104 @@ async def test_corrections_restore_negative_version(async_client):
213
  "restore_to_version": 0,
214
  })
215
  assert resp.status_code == 422
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ Tests de sécurité — Sprint F1 + Sprint Fix 1.
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
+ - Path traversal sur image_master_path dans le job_runner
7
  - SSRF sur manifest_url
8
  - Validation des entrées (taille, format)
9
  """
10
  # 1. stdlib
11
+ import uuid
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
  import pytest
16
+ import pytest_asyncio
17
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
18
 
19
  # 2. third-party — fixtures API
20
  from tests.conftest_api import async_client, db_session # noqa: F401
21
 
22
+ # 3. local — job_runner path traversal tests
23
+ import app.models # noqa: F401
24
+ import app.services.job_runner as job_runner_module
25
+ from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
26
+ from app.models.database import Base
27
+ from app.models.job import JobModel
28
+ from app.models.model_config_db import ModelConfigDB
29
+ from app.services.job_runner import _run_job_impl
30
+
31
 
32
  # ---------------------------------------------------------------------------
33
  # Path traversal — profiles
 
229
  "restore_to_version": 0,
230
  })
231
  assert resp.status_code == 422
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # Path traversal — job_runner (image_master_path)
236
+ # ---------------------------------------------------------------------------
237
+
238
+ _NOW = datetime.now(timezone.utc)
239
+
240
+
241
+ @pytest_asyncio.fixture
242
+ async def _sec_db():
243
+ """Session SQLite en mémoire pour les tests path traversal."""
244
+ engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
245
+ async with engine.begin() as conn:
246
+ await conn.run_sync(Base.metadata.create_all)
247
+ factory = async_sessionmaker(engine, expire_on_commit=False)
248
+ async with factory() as session:
249
+ yield session
250
+ async with engine.begin() as conn:
251
+ await conn.run_sync(Base.metadata.drop_all)
252
+ await engine.dispose()
253
+
254
+
255
+ async def _create_traversal_setup(_sec_db, image_path: str) -> dict:
256
+ """Crée un jeu de données complet avec le chemin image fourni."""
257
+ corpus = CorpusModel(
258
+ id=str(uuid.uuid4()), slug="sec-test", title="Sec",
259
+ profile_id="medieval-illuminated", created_at=_NOW, updated_at=_NOW,
260
+ )
261
+ _sec_db.add(corpus)
262
+ await _sec_db.commit()
263
+
264
+ ms = ManuscriptModel(
265
+ id=str(uuid.uuid4()), corpus_id=corpus.id, title="MS", total_pages=1,
266
+ )
267
+ _sec_db.add(ms)
268
+ await _sec_db.commit()
269
+
270
+ page = PageModel(
271
+ id=str(uuid.uuid4()), manuscript_id=ms.id, folio_label="f001r",
272
+ sequence=1, image_master_path=image_path,
273
+ processing_status="INGESTED",
274
+ )
275
+ _sec_db.add(page)
276
+ await _sec_db.commit()
277
+
278
+ model_cfg = ModelConfigDB(
279
+ corpus_id=corpus.id, provider_type="google_ai_studio",
280
+ selected_model_id="gemini-2.0-flash",
281
+ selected_model_display_name="Gemini 2.0 Flash",
282
+ updated_at=_NOW,
283
+ )
284
+ _sec_db.add(model_cfg)
285
+ await _sec_db.commit()
286
+
287
+ job = JobModel(
288
+ id=str(uuid.uuid4()), corpus_id=corpus.id, page_id=page.id,
289
+ status="pending", created_at=_NOW,
290
+ )
291
+ _sec_db.add(job)
292
+ await _sec_db.commit()
293
+ return {"job": job, "page": page}
294
+
295
+
296
+ @pytest.mark.asyncio
297
+ async def test_path_traversal_dotdot_rejected(_sec_db):
298
+ """image_master_path avec ../../etc/passwd doit provoquer un échec du job."""
299
+ s = await _create_traversal_setup(_sec_db, "../../etc/passwd")
300
+
301
+ await _run_job_impl(s["job"].id, _sec_db)
302
+ await _sec_db.refresh(s["job"])
303
+
304
+ assert s["job"].status == "failed"
305
+ assert "interdit" in (s["job"].error_message or "").lower() or \
306
+ "hors" in (s["job"].error_message or "").lower()
307
+
308
+
309
+ @pytest.mark.asyncio
310
+ async def test_path_traversal_absolute_path_rejected(_sec_db):
311
+ """image_master_path absolu hors data_dir doit provoquer un échec du job."""
312
+ s = await _create_traversal_setup(_sec_db, "/etc/shadow")
313
+
314
+ await _run_job_impl(s["job"].id, _sec_db)
315
+ await _sec_db.refresh(s["job"])
316
+
317
+ assert s["job"].status == "failed"
318
+ assert "interdit" in (s["job"].error_message or "").lower() or \
319
+ "hors" in (s["job"].error_message or "").lower()
320
+
321
+
322
+ @pytest.mark.asyncio
323
+ async def test_path_traversal_symlink_escape_rejected(_sec_db, tmp_path):
324
+ """Un chemin sous data_dir mais avec traversal intermédiaire est rejeté."""
325
+ s = await _create_traversal_setup(
326
+ _sec_db, "data/../../../etc/passwd"
327
+ )
328
+
329
+ await _run_job_impl(s["job"].id, _sec_db)
330
+ await _sec_db.refresh(s["job"])
331
+
332
+ assert s["job"].status == "failed"
infra/docker-compose.yml CHANGED
@@ -9,17 +9,19 @@ services:
9
  api:
10
  build:
11
  context: .. # racine du dépôt = contexte de build
12
- dockerfile: infra/Dockerfile
13
  ports:
14
  - "7860:7860"
15
  volumes:
16
  - ../data:/app/data # artefacts montés en volume, hors image
17
  environment:
18
- # Provider IA : google_ai_studio | google_vertex | google_ai_api
19
- - AI_PROVIDER=${AI_PROVIDER:-google_ai_studio}
20
- # Clés selon le provider choisi à définir dans .env
21
  - GOOGLE_AI_STUDIO_API_KEY=${GOOGLE_AI_STUDIO_API_KEY:-}
22
- - GOOGLE_AI_API_KEY=${GOOGLE_AI_API_KEY:-}
23
- - GOOGLE_VERTEX_PROJECT=${GOOGLE_VERTEX_PROJECT:-}
24
- - GOOGLE_VERTEX_LOCATION=${GOOGLE_VERTEX_LOCATION:-us-central1}
 
 
25
  restart: unless-stopped
 
9
  api:
10
  build:
11
  context: .. # racine du dépôt = contexte de build
12
+ dockerfile: Dockerfile
13
  ports:
14
  - "7860:7860"
15
  volumes:
16
  - ../data:/app/data # artefacts montés en volume, hors image
17
  environment:
18
+ # Clés IA à définir dans .env (R06 : jamais en clair ici)
19
+ # Le backend détecte automatiquement quels providers sont disponibles
20
+ # selon les clés présentes. Pas de AI_PROVIDER global.
21
  - GOOGLE_AI_STUDIO_API_KEY=${GOOGLE_AI_STUDIO_API_KEY:-}
22
+ - VERTEX_API_KEY=${VERTEX_API_KEY:-}
23
+ - VERTEX_SERVICE_ACCOUNT_JSON=${VERTEX_SERVICE_ACCOUNT_JSON:-}
24
+ - MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
25
+ # CORS — par défaut toutes origines (dev). Restreindre en production.
26
+ - CORS_ORIGINS=${CORS_ORIGINS:-*}
27
  restart: unless-stopped