feat: durcit l'exécution SQL IA, borne les uploads et ajoute des tests
- sql_executor: remplace le filtre regex fragile par une autorisation SQLite (set_authorizer) en complément de mode=ro ; rejette les instructions multiples - uploads: lecture bornée des PDF (helper read_upload_limited, limite 20 Mo, HTTP 413) branchée sur /extract et /save-with-pdf - tests: suite pytest (54 tests) couvrant amounts, dates, storage, sql_executor, uploads et DatabaseService.save_document ; pytest ajouté en dépendance dev Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ from ...database import get_session, DatabaseService, storage
|
||||
from ...database.models import Document, Immeuble, Lot, Locataire, Revenu, Depense
|
||||
from ...database.service import DuplicateDocumentError
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.uploads import read_upload_limited, UploadTooLargeError
|
||||
from ..schemas import SaveRequest, SaveResponse, DocumentSummary
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["documents"])
|
||||
@@ -103,9 +104,11 @@ async def save_document_with_pdf(
|
||||
status_code=400, detail=f"JSON invalide pour depenses_tags: {e}"
|
||||
)
|
||||
|
||||
# Read PDF content
|
||||
# Read PDF content (taille bornée)
|
||||
try:
|
||||
pdf_content = await pdf_file.read()
|
||||
pdf_content = await read_upload_limited(pdf_file)
|
||||
except UploadTooLargeError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Erreur lecture du PDF: {str(e)}")
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ...extractor import extract_compte_rendu
|
||||
from ...utils.uploads import read_upload_limited, UploadTooLargeError
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["extraction"])
|
||||
|
||||
@@ -47,10 +48,15 @@ async def extract_pdf(
|
||||
|
||||
tmp_path: Path | None = None
|
||||
|
||||
# Lecture bornée du contenu uploadé
|
||||
try:
|
||||
content = await read_upload_limited(file)
|
||||
except UploadTooLargeError as e:
|
||||
raise HTTPException(status_code=413, detail=str(e))
|
||||
|
||||
# Sauvegarde temporaire du fichier uploadé
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp_file:
|
||||
content = await file.read()
|
||||
tmp_file.write(content)
|
||||
tmp_path = Path(tmp_file.name)
|
||||
|
||||
|
||||
@@ -1,49 +1,73 @@
|
||||
"""Exécution SQL read-only sécurisée pour l'assistant IA."""
|
||||
"""Exécution SQL read-only sécurisée pour l'assistant IA.
|
||||
|
||||
Trois niveaux de défense, du plus fort au plus faible :
|
||||
1. Connexion SQLite ouverte en ``mode=ro`` — garantie réelle au niveau du fichier.
|
||||
2. Autorisation SQLite (``set_authorizer``) qui refuse toute opération non
|
||||
read-only (writes, DDL, ATTACH…) au niveau du moteur, sans faux positifs.
|
||||
3. Garde lexicale légère : une seule instruction, débutant par SELECT/WITH/PRAGMA,
|
||||
uniquement pour produire des messages d'erreur clairs en amont.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
from ..database.connection import get_db_path
|
||||
|
||||
|
||||
# Requêtes interdites (défense en profondeur)
|
||||
_FORBIDDEN_PATTERN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|ATTACH|DETACH|REPLACE|GRANT|REVOKE)\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# PRAGMA autorisés
|
||||
# PRAGMA autorisés (lecture de métadonnées uniquement)
|
||||
_ALLOWED_PRAGMAS = {"table_info", "database_list", "table_list"}
|
||||
|
||||
# Limite de résultats par défaut
|
||||
MAX_ROWS = 500
|
||||
|
||||
# Codes d'action de l'autorisation SQLite (stables dans l'ABI SQLite ; définis
|
||||
# en dur car les constantes sqlite3.SQLITE_* ne sont disponibles qu'à partir de
|
||||
# Python 3.11 alors que le projet cible >= 3.10).
|
||||
_SQLITE_OK = 0
|
||||
_SQLITE_DENY = 1
|
||||
_SQLITE_READ = 20
|
||||
_SQLITE_SELECT = 21
|
||||
_SQLITE_PRAGMA = 19
|
||||
_SQLITE_FUNCTION = 31
|
||||
_SQLITE_RECURSIVE = 33
|
||||
|
||||
_READONLY_ACTIONS = {_SQLITE_READ, _SQLITE_SELECT, _SQLITE_FUNCTION, _SQLITE_RECURSIVE}
|
||||
|
||||
|
||||
def _authorizer(action: int, arg1, arg2, db_name, trigger) -> int:
|
||||
"""Callback d'autorisation SQLite : n'autorise que les opérations de lecture."""
|
||||
if action in _READONLY_ACTIONS:
|
||||
return _SQLITE_OK
|
||||
if action == _SQLITE_PRAGMA:
|
||||
# arg1 = nom du PRAGMA
|
||||
if arg1 and arg1.lower() in _ALLOWED_PRAGMAS:
|
||||
return _SQLITE_OK
|
||||
return _SQLITE_DENY
|
||||
return _SQLITE_DENY
|
||||
|
||||
|
||||
def _validate_sql(query: str) -> None:
|
||||
"""Valide qu'une requête SQL est read-only.
|
||||
"""Garde lexicale légère pour messages d'erreur clairs (pas la garde principale).
|
||||
|
||||
Raises:
|
||||
ValueError: si la requête n'est pas autorisée.
|
||||
ValueError: si la requête n'est manifestement pas une lecture.
|
||||
"""
|
||||
stripped = query.strip().rstrip(";").strip()
|
||||
|
||||
# Refuser les instructions multiples (stacked queries)
|
||||
if ";" in stripped:
|
||||
raise ValueError("Une seule instruction SQL est autorisée")
|
||||
|
||||
upper = stripped.upper()
|
||||
|
||||
# Autoriser les PRAGMA spécifiques
|
||||
if upper.startswith("PRAGMA"):
|
||||
pragma_name = stripped.split("(")[0].split()[-1].lower().strip()
|
||||
if pragma_name not in _ALLOWED_PRAGMAS:
|
||||
raise ValueError(f"PRAGMA '{pragma_name}' non autorisé")
|
||||
return
|
||||
|
||||
# La requête doit commencer par SELECT ou WITH
|
||||
if not (upper.startswith("SELECT") or upper.startswith("WITH")):
|
||||
raise ValueError("Seules les requêtes SELECT ou WITH sont autorisées")
|
||||
|
||||
# Vérifier l'absence de mots-clés dangereux
|
||||
match = _FORBIDDEN_PATTERN.search(stripped)
|
||||
if match:
|
||||
raise ValueError(f"Mot-clé SQL interdit détecté : {match.group()}")
|
||||
|
||||
|
||||
def _ensure_limit(query: str) -> str:
|
||||
"""Ajoute LIMIT si absent."""
|
||||
@@ -64,7 +88,7 @@ def execute_readonly_sql(query: str) -> dict:
|
||||
|
||||
Raises:
|
||||
ValueError: si la requête n'est pas autorisée.
|
||||
sqlite3.Error: si l'exécution échoue.
|
||||
sqlite3.Error: si l'exécution échoue (y compris refus de l'autorisation).
|
||||
"""
|
||||
_validate_sql(query)
|
||||
query = _ensure_limit(query)
|
||||
@@ -73,6 +97,7 @@ def execute_readonly_sql(query: str) -> dict:
|
||||
uri = f"file:{db_path}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
try:
|
||||
conn.set_authorizer(_authorizer)
|
||||
cursor = conn.execute(query)
|
||||
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
||||
rows = cursor.fetchall()
|
||||
|
||||
48
src/plesna_gerance/utils/uploads.py
Normal file
48
src/plesna_gerance/utils/uploads.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Lecture bornée des fichiers uploadés (protection mémoire / DoS)."""
|
||||
|
||||
from fastapi import UploadFile
|
||||
|
||||
# Taille maximale acceptée pour un PDF uploadé (20 Mo)
|
||||
MAX_UPLOAD_SIZE = 20 * 1024 * 1024
|
||||
|
||||
_CHUNK_SIZE = 1024 * 1024 # 1 Mo
|
||||
|
||||
|
||||
class UploadTooLargeError(Exception):
|
||||
"""Levée quand un fichier uploadé dépasse la taille maximale autorisée."""
|
||||
|
||||
def __init__(self, max_size: int):
|
||||
self.max_size = max_size
|
||||
super().__init__(
|
||||
f"Fichier trop volumineux (maximum {max_size // (1024 * 1024)} Mo)"
|
||||
)
|
||||
|
||||
|
||||
async def read_upload_limited(
|
||||
file: UploadFile, max_size: int = MAX_UPLOAD_SIZE
|
||||
) -> bytes:
|
||||
"""Lit un fichier uploadé par morceaux en plafonnant la taille totale.
|
||||
|
||||
Évite de charger en mémoire un fichier arbitrairement gros.
|
||||
|
||||
Args:
|
||||
file: Fichier uploadé (FastAPI UploadFile).
|
||||
max_size: Taille maximale en octets.
|
||||
|
||||
Returns:
|
||||
Contenu binaire du fichier.
|
||||
|
||||
Raises:
|
||||
UploadTooLargeError: si le fichier dépasse ``max_size``.
|
||||
"""
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_size:
|
||||
raise UploadTooLargeError(max_size)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
Reference in New Issue
Block a user