Claude commited on
Commit
9df1925
·
unverified ·
1 Parent(s): b986b08

fix: Sprint 1b — null deref, supports_vision, empty response logging, test

Browse files

- Fix null dereference in export.py get_alto (manuscript/corpus null checks)
- Respect supports_vision flag in Google AI and Vertex SA providers
- Log warning on empty AI responses (safety filters, silent model)
- Log corrupted archive files in pages.py history endpoint
- Add test for path traversal startswith prefix confusion

https://claude.ai/code/session_012NCh8yLxMXkRmBYQgHCTik

backend/app/api/v1/export.py CHANGED
@@ -158,7 +158,12 @@ async def get_alto(page_id: str, db: AsyncSession = Depends(get_db)) -> Response
158
  raise HTTPException(status_code=404, detail="Page introuvable")
159
 
160
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
 
 
 
161
  corpus = await db.get(CorpusModel, manuscript.corpus_id)
 
 
162
 
163
  master = await _read_master_json(corpus.slug, page_id)
164
  if master is None:
 
158
  raise HTTPException(status_code=404, detail="Page introuvable")
159
 
160
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
161
+ if manuscript is None:
162
+ raise HTTPException(status_code=404, detail="Manuscrit introuvable")
163
+
164
  corpus = await db.get(CorpusModel, manuscript.corpus_id)
165
+ if corpus is None:
166
+ raise HTTPException(status_code=404, detail="Corpus introuvable")
167
 
168
  master = await _read_master_json(corpus.slug, page_id)
169
  if master is None:
backend/app/api/v1/pages.py CHANGED
@@ -410,7 +410,11 @@ async def get_page_history(
410
  versions.append(
411
  VersionInfo(version=version_num, saved_at=saved_at, status=status)
412
  )
413
- except (json.JSONDecodeError, KeyError, OSError):
 
 
 
 
414
  continue
415
 
416
  return sorted(versions, key=lambda v: v.version)
 
410
  versions.append(
411
  VersionInfo(version=version_num, saved_at=saved_at, status=status)
412
  )
413
+ except (json.JSONDecodeError, KeyError, OSError) as exc:
414
+ logger.warning(
415
+ "Archive master.json corrompue, ignorée",
416
+ extra={"path": str(vpath), "error": str(exc)},
417
+ )
418
  continue
419
 
420
  return sorted(versions, key=lambda v: v.version)
backend/app/services/ai/provider_google_ai.py CHANGED
@@ -59,11 +59,22 @@ class GoogleAIProvider(AIProvider):
59
  if not self.is_configured():
60
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
  client = genai.Client(api_key=os.environ[_ENV_KEY])
62
- image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
 
 
 
 
 
 
 
 
 
 
 
63
  try:
64
  response = client.models.generate_content(
65
  model=model_id,
66
- contents=[image_part, prompt],
67
  )
68
  except Exception as exc:
69
  logger.error(
@@ -71,4 +82,7 @@ class GoogleAIProvider(AIProvider):
71
  extra={"model": model_id, "error": str(exc)},
72
  )
73
  raise RuntimeError(f"Erreur API Google AI Studio ({model_id}) : {exc}") from exc
 
 
 
74
  return response.text or ""
 
59
  if not self.is_configured():
60
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
61
  client = genai.Client(api_key=os.environ[_ENV_KEY])
62
+
63
+ if supports_vision:
64
+ image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
65
+ contents = [image_part, prompt]
66
+ else:
67
+ logger.warning(
68
+ "Modèle texte seul sélectionné pour une analyse image : %s. "
69
+ "L'image ne sera pas transmise à l'API.",
70
+ model_id,
71
+ )
72
+ contents = [prompt]
73
+
74
  try:
75
  response = client.models.generate_content(
76
  model=model_id,
77
+ contents=contents,
78
  )
79
  except Exception as exc:
80
  logger.error(
 
82
  extra={"model": model_id, "error": str(exc)},
83
  )
84
  raise RuntimeError(f"Erreur API Google AI Studio ({model_id}) : {exc}") from exc
85
+
86
+ if not response.text:
87
+ logger.warning("Réponse IA vide (filtres de sécurité ou modèle muet)", extra={"model": model_id})
88
  return response.text or ""
backend/app/services/ai/provider_vertex_sa.py CHANGED
@@ -89,11 +89,22 @@ class VertexServiceAccountProvider(AIProvider):
89
  if not self.is_configured():
90
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
91
  client = self._build_client()
92
- image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
 
 
 
 
 
 
 
 
 
 
 
93
  try:
94
  response = client.models.generate_content(
95
  model=model_id,
96
- contents=[image_part, prompt],
97
  )
98
  except Exception as exc:
99
  logger.error(
@@ -101,4 +112,7 @@ class VertexServiceAccountProvider(AIProvider):
101
  extra={"model": model_id, "error": str(exc)},
102
  )
103
  raise RuntimeError(f"Erreur API Vertex AI ({model_id}) : {exc}") from exc
 
 
 
104
  return response.text or ""
 
89
  if not self.is_configured():
90
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
91
  client = self._build_client()
92
+
93
+ if supports_vision:
94
+ image_part = types.Part.from_bytes(data=image_bytes, mime_type="image/jpeg")
95
+ contents = [image_part, prompt]
96
+ else:
97
+ logger.warning(
98
+ "Modèle texte seul sélectionné pour une analyse image : %s. "
99
+ "L'image ne sera pas transmise à l'API.",
100
+ model_id,
101
+ )
102
+ contents = [prompt]
103
+
104
  try:
105
  response = client.models.generate_content(
106
  model=model_id,
107
+ contents=contents,
108
  )
109
  except Exception as exc:
110
  logger.error(
 
112
  extra={"model": model_id, "error": str(exc)},
113
  )
114
  raise RuntimeError(f"Erreur API Vertex AI ({model_id}) : {exc}") from exc
115
+
116
+ if not response.text:
117
+ logger.warning("Réponse IA vide (filtres de sécurité ou modèle muet)", extra={"model": model_id})
118
  return response.text or ""
backend/tests/test_security.py CHANGED
@@ -330,3 +330,33 @@ async def test_path_traversal_symlink_escape_rejected(_sec_db, tmp_path):
330
  await _sec_db.refresh(s["job"])
331
 
332
  assert s["job"].status == "failed"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  await _sec_db.refresh(s["job"])
331
 
332
  assert s["job"].status == "failed"
333
+
334
+
335
+ # ---------------------------------------------------------------------------
336
+ # Path traversal — frontend static serving (startswith prefix confusion)
337
+ # ---------------------------------------------------------------------------
338
+
339
+ def test_static_dir_startswith_prefix_confusion():
340
+ """Paths like /app/static-evil/foo must NOT pass the startswith check.
341
+
342
+ Before the fix, str(candidate).startswith(str(_STATIC_DIR.resolve()))
343
+ would accept '/app/static-evil/foo' because '/app/static-evil' starts
344
+ with '/app/static'. The fix appends '/' to the prefix.
345
+ """
346
+ from pathlib import Path
347
+
348
+ # Simulate the fixed check from main.py
349
+ _STATIC_DIR = Path("/app/static")
350
+ static_resolved = str(_STATIC_DIR.resolve()) + "/"
351
+
352
+ # A path under a sibling directory with a confusable prefix
353
+ evil_path = Path("/app/static-evil/foo.txt")
354
+ assert not str(evil_path.resolve()).startswith(static_resolved), (
355
+ "Path /app/static-evil/foo.txt should NOT be treated as under /app/static/"
356
+ )
357
+
358
+ # A legitimate path under /app/static/ should still pass
359
+ good_path = Path("/app/static/index.html")
360
+ assert str(good_path.resolve()).startswith(static_resolved), (
361
+ "Path /app/static/index.html SHOULD be treated as under /app/static/"
362
+ )