ml-sql / routers /_execute.py
wram1708's picture
Upload routers/_execute.py with huggingface_hub
e72949d verified
Raw
History Blame Contribute Delete
13.2 kB
"""SQL safety validation and execution against SQLite / PostgreSQL.
Guardrails:
- SELECT-only whitelist (sqlparse statement type check)
- Expanded blocked-keyword regex (covers DROP, DELETE, INSERT, etc.)
- Both comment styles stripped before keyword scan (-- and /* */)
- Multi-statement detection (semicolon separation)
- SQLite opened in URI read-only mode (file:...?mode=ro)
- PostgreSQL wrapped in a read-only transaction that is always rolled back
- Hard row limit injected if absent
"""
from __future__ import annotations
import re
import time
import urllib.parse
from dataclasses import dataclass
import aiosqlite
import sqlparse
from sqlparse.sql import Statement
class UnsafeQueryError(Exception):
pass
_BLOCKED_KEYWORDS = (
"DROP", "DELETE", "INSERT", "UPDATE", "CREATE", "ALTER",
"TRUNCATE", "EXEC", "EXECUTE", "GRANT", "REVOKE", "REPLACE",
"MERGE", "UPSERT", "LOAD", "ATTACH", "DETACH", "PRAGMA",
"VACUUM", "ANALYZE", "EXPLAIN", "SET",
)
# Sensitive column names — values are masked in results sent to client + LLM
_SENSITIVE_COL_RE = re.compile(
r"\b(password|passwd|secret|token|api_key|apikey|ssn|credit_card|cvv|"
r"private_key|salt|hash|otp|pin)\b",
re.IGNORECASE,
)
_MAX_RESULT_BYTES = 5_000_000 # 5 MB cap on raw result data
_COMMENT_STRIP = re.compile(
r"(--[^\n]*|/\*.*?\*/)", re.DOTALL
)
def _strip_comments(sql: str) -> str:
return _COMMENT_STRIP.sub(" ", sql)
def validate_sql(sql: str) -> None:
"""Raise UnsafeQueryError if the statement is not a safe SELECT."""
stripped = sql.strip()
if not stripped:
raise UnsafeQueryError("Empty SQL query")
# Block multi-statement queries (stacked injections: SELECT 1; DROP TABLE foo)
# A lone semicolon at the very end is fine — strip it first.
no_trailing = stripped.rstrip(";").strip()
if ";" in no_trailing:
raise UnsafeQueryError("Multiple statements are not allowed")
parsed = sqlparse.parse(stripped)
if not parsed:
raise UnsafeQueryError("Could not parse SQL")
stmt: Statement = parsed[0]
stmt_type = stmt.get_type()
if stmt_type not in ("SELECT", "UNKNOWN", None):
raise UnsafeQueryError(f"Only SELECT queries are allowed (got: {stmt_type})")
clean = _strip_comments(stripped).upper()
for kw in _BLOCKED_KEYWORDS:
if re.search(rf"\b{kw}\b", clean):
raise UnsafeQueryError(f"Blocked keyword: {kw}")
if not re.search(r"\bSELECT\b", clean):
raise UnsafeQueryError("Query must contain SELECT")
# Structural pre-validation — catch malformed LLM output before hitting the DB
# 1. FROM clause required (subqueries with no outer FROM are rare and suspicious)
if not re.search(r"\bFROM\b", clean):
raise UnsafeQueryError("Query must contain a FROM clause")
# 2. Balanced parentheses
if stripped.count("(") != stripped.count(")"):
raise UnsafeQueryError("Unbalanced parentheses in query")
# 3. Unmatched single quotes (odd count means an open string literal)
# Strip escaped quotes ('') before counting
no_escaped = stripped.replace("''", "")
if no_escaped.count("'") % 2 != 0:
raise UnsafeQueryError("Unmatched single quote in query")
# 4. Truncated query — ends on a dangling keyword (LLM cut off mid-generation)
_DANGLING = re.compile(
r"\b(WHERE|AND|OR|ON|JOIN|LEFT|RIGHT|INNER|OUTER|HAVING|GROUP|ORDER|BY|FROM|SELECT|BETWEEN|NOT|IN|LIKE|AS|CASE|WHEN|THEN|ELSE)\s*$",
re.IGNORECASE,
)
if _DANGLING.search(stripped.rstrip(";")):
raise UnsafeQueryError("Query appears truncated (ends on a keyword)")
@dataclass
class QueryResult:
columns: list[str]
rows: list[list]
count: int
exec_time_ms: float
total_count: int = -1 # -1 = unknown; set when count query is run
def _extract_user_limit(sql: str) -> int | None:
m = re.search(r"\bLIMIT\s+(\d+)(\s+OFFSET\s+\d+)?\s*$", sql.rstrip(";").strip(), flags=re.IGNORECASE)
return int(m.group(1)) if m else None
def _strip_limit(sql: str) -> str:
return re.sub(r"\bLIMIT\s+\d+(\s+OFFSET\s+\d+)?\s*$", "", sql.rstrip(";").strip(), flags=re.IGNORECASE).strip()
def paginate_sql(sql: str, page: int, page_size: int) -> str:
"""Wrap sql with LIMIT/OFFSET for the given 1-indexed page, honoring user's LIMIT if smaller."""
user_limit = _extract_user_limit(sql)
inner = _strip_limit(sql)
offset = (page - 1) * page_size
if user_limit is not None and user_limit <= page_size + offset:
effective = max(0, user_limit - offset)
return f"SELECT * FROM ({inner}) AS _paged LIMIT {effective} OFFSET {offset}"
return f"SELECT * FROM ({inner}) AS _paged LIMIT {page_size} OFFSET {offset}"
def _count_sql(sql: str) -> str:
user_limit = _extract_user_limit(sql)
inner = _strip_limit(sql)
if user_limit is not None:
return f"SELECT COUNT(*) FROM (SELECT * FROM ({inner}) AS _inner LIMIT {user_limit}) AS _cnt"
return f"SELECT COUNT(*) FROM ({inner}) AS _cnt"
async def count_rows_sqlite(db_path: str, sql: str) -> int:
try:
async with aiosqlite.connect(_ro_uri(db_path), uri=True) as db:
async with db.execute(_count_sql(sql)) as cur:
row = await cur.fetchone()
return int(row[0]) if row else 0
except Exception:
return -1
async def count_rows_remote(session: dict, sql: str) -> int:
"""Count total rows for pg/mysql/mssql via a COUNT(*) subquery."""
try:
count_sql = _count_sql(sql)
stype = session["type"]
if stype == "mysql":
result = await execute_mysql(session["conn_str"], count_sql)
elif stype == "mssql":
result = await execute_mssql(session["conn_str"], count_sql)
else:
result = await execute_pg(session["conn_str"], count_sql)
return int(result.rows[0][0]) if result.rows else -1
except Exception:
return -1
def mask_sensitive_columns(result: "QueryResult") -> "QueryResult":
"""Replace cell values in sensitive columns with *** before returning to client or LLM."""
sensitive_idx = [
i for i, col in enumerate(result.columns)
if _SENSITIVE_COL_RE.search(col)
]
if not sensitive_idx:
return result
masked = [
[("***" if j in sensitive_idx else cell) for j, cell in enumerate(row)]
for row in result.rows
]
return QueryResult(
columns=result.columns, rows=masked,
count=result.count, exec_time_ms=result.exec_time_ms,
)
def _trim_oversized(rows: list[list]) -> list[list]:
"""Trim rows if total serialized size exceeds _MAX_RESULT_BYTES."""
total = 0
for i, row in enumerate(rows):
total += sum(len(str(c)) for c in row)
if total > _MAX_RESULT_BYTES:
return rows[:max(i, 1)]
return rows
def _parse_mysql_url(conn_str: str) -> dict:
p = urllib.parse.urlparse(conn_str)
return {
"host": p.hostname or "localhost",
"port": p.port or 3306,
"user": p.username or "",
"password": p.password or "",
"db": (p.path or "").lstrip("/"),
}
_ROW_LIMIT = 500
def _ro_uri(db_path: str) -> str:
"""Return a file:// URI that opens SQLite in read-only mode."""
encoded = urllib.parse.quote(db_path, safe="/:")
return f"file:{encoded}?mode=ro"
async def execute_sqlite(db_path: str, sql: str) -> QueryResult:
t0 = time.monotonic()
limited_sql = _inject_limit(sql, _ROW_LIMIT)
# uri=True enables the ?mode=ro flag → SQLite refuses any write operation
async with aiosqlite.connect(_ro_uri(db_path), uri=True) as db:
db.row_factory = aiosqlite.Row
async with db.execute(limited_sql) as cur:
raw_rows = await cur.fetchall()
if not raw_rows:
cols = [d[0] for d in (cur.description or [])]
return QueryResult(columns=cols, rows=[], count=0, exec_time_ms=0)
cols = list(raw_rows[0].keys())
rows = _trim_oversized([list(r) for r in raw_rows])
exec_ms = (time.monotonic() - t0) * 1000
return QueryResult(
columns=cols, rows=rows,
count=len(rows), exec_time_ms=round(exec_ms, 1),
)
async def execute_pg(conn_str: str, sql: str) -> QueryResult:
"""Execute against PostgreSQL with explicit rollback after fetch.
Two independent read-only layers:
1. SET TRANSACTION READ ONLY — PostgreSQL rejects any write attempt at engine level.
2. Explicit tr.rollback() — transaction is never committed regardless, so even if
layer 1 were somehow bypassed the changes would not persist.
"""
import asyncpg
t0 = time.monotonic()
limited_sql = _inject_limit(sql, _ROW_LIMIT)
conn = await asyncpg.connect(conn_str)
try:
tr = conn.transaction()
await tr.start()
await conn.execute("SET TRANSACTION READ ONLY")
records = await conn.fetch(limited_sql)
await tr.rollback() # explicit — never commits, even on clean exit
if not records:
return QueryResult(columns=[], rows=[], count=0, exec_time_ms=0)
cols = list(records[0].keys())
rows = _trim_oversized([list(r.values()) for r in records])
finally:
await conn.close()
exec_ms = (time.monotonic() - t0) * 1000
return QueryResult(
columns=cols, rows=rows,
count=len(rows), exec_time_ms=round(exec_ms, 1),
)
def _inject_limit(sql: str, limit: int) -> str:
sql_stripped = sql.rstrip(";").strip()
if not re.search(r"\bLIMIT\b", sql_stripped, re.IGNORECASE):
sql_stripped += f" LIMIT {limit}"
return sql_stripped
async def execute_mysql(conn_str: str, sql: str) -> QueryResult:
import asyncmy
params = _parse_mysql_url(conn_str)
t0 = time.monotonic()
conn = await asyncmy.connect(**params)
try:
limited_sql = _inject_limit(sql, _ROW_LIMIT)
async with conn.cursor() as cur:
await cur.execute("SET SESSION TRANSACTION READ ONLY")
await cur.execute(limited_sql)
raw = await cur.fetchall()
cols = [d[0] for d in (cur.description or [])]
rows = _trim_oversized([list(r) for r in raw])
finally:
conn.close()
exec_ms = (time.monotonic() - t0) * 1000
return QueryResult(columns=cols, rows=rows, count=len(rows), exec_time_ms=round(exec_ms, 1))
def paginate_mssql_sql(sql: str, page: int, page_size: int) -> str:
"""Wrap sql with MSSQL OFFSET/FETCH pagination (strips ORDER BY for subquery compat)."""
inner = re.sub(r"\bORDER\s+BY\s+.+$", "", sql.rstrip(";").strip(), flags=re.IGNORECASE | re.DOTALL).strip()
inner = re.sub(r"\bTOP\s+\d+\b", "", inner, flags=re.IGNORECASE).strip()
offset = (page - 1) * page_size
return (
f"SELECT * FROM ({inner}) AS _q "
f"ORDER BY (SELECT NULL) OFFSET {offset} ROWS FETCH NEXT {page_size} ROWS ONLY"
)
async def execute_mssql(conn_str: str, sql: str) -> QueryResult:
import asyncio
from urllib.parse import urlparse
def _run() -> tuple[list[str], list[list]]:
import pymssql
p = urlparse(conn_str)
conn = pymssql.connect(
server=p.hostname, port=p.port or 1433,
user=p.username, password=p.password,
database=(p.path or "").lstrip("/"),
)
try:
cur = conn.cursor()
cur.execute(sql)
raw = cur.fetchall()
cols = [d[0] for d in (cur.description or [])]
return cols, [list(r) for r in raw]
finally:
conn.close()
t0 = time.monotonic()
cols, rows = await asyncio.to_thread(_run)
exec_ms = (time.monotonic() - t0) * 1000
rows = _trim_oversized(rows)
return QueryResult(columns=cols, rows=rows, count=len(rows), exec_time_ms=round(exec_ms, 1))
async def execute_duckdb(db_path: str, sql: str) -> QueryResult:
import asyncio
from pathlib import Path as _Path
ext = _Path(db_path).suffix.lower()
def _exec():
import duckdb
if ext == ".duckdb":
con = duckdb.connect(db_path, read_only=True)
else:
con = duckdb.connect()
if ext == ".parquet":
con.execute(f"CREATE VIEW data AS SELECT * FROM read_parquet('{db_path}')")
elif ext == ".csv":
con.execute(f"CREATE VIEW data AS SELECT * FROM read_csv_auto('{db_path}')")
rel = con.execute(_inject_limit(sql, _ROW_LIMIT))
cols = [d[0] for d in rel.description]
rows = [list(r) for r in rel.fetchall()]
con.close()
return cols, rows
t0 = time.monotonic()
cols, rows = await asyncio.to_thread(_exec)
exec_ms = (time.monotonic() - t0) * 1000
rows = _trim_oversized(rows)
return QueryResult(columns=cols, rows=rows, count=len(rows), exec_time_ms=round(exec_ms, 1))
def result_to_dict(result: QueryResult) -> dict:
d = {"columns": result.columns, "rows": result.rows, "count": result.count, "exec_time_ms": result.exec_time_ms}
if result.total_count >= 0:
d["total_count"] = result.total_count
return d