Spaces:
Build error
Build error
Claude commited on
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
try:
|
| 64 |
response = client.models.generate_content(
|
| 65 |
model=model_id,
|
| 66 |
-
contents=
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
try:
|
| 94 |
response = client.models.generate_content(
|
| 95 |
model=model_id,
|
| 96 |
-
contents=
|
| 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 |
+
)
|