File size: 13,242 Bytes
b5266dc 7f08e54 b5266dc 7f08e54 b5266dc 9455e73 b5266dc bf29bad 7d8e3d9 bf29bad 7d8e3d9 bf29bad 7d8e3d9 bf29bad b5266dc e72949d 7f08e54 c854a30 b5266dc 7f08e54 b5266dc 54483ae b5266dc 54483ae b5266dc 7f08e54 b5266dc c854a30 cc33823 5725448 cc33823 5725448 cc33823 5725448 cc33823 5725448 cc33823 c854a30 b5266dc bf29bad | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | """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 |