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:
2026-06-11 10:11:07 +02:00
parent c15b65f0d9
commit 635a094591
13 changed files with 646 additions and 22 deletions

View File

@@ -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)}")

View File

@@ -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)

View File

@@ -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()

View 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)