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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user