Files
Mutual_Fund/nl2sql/sql_security.py
T
2026-09-14 10:57:48 +08:00

107 lines
3.9 KiB
Python

"""NL2SQL 只读 SQL 的 AST 安全校验。"""
from __future__ import annotations
from dataclasses import dataclass
from sqlglot import exp, parse
from sqlglot.errors import ParseError
_DANGEROUS_FUNCTIONS = {
"LOAD_FILE",
"UUID_FILE_NAME",
"BENCHMARK",
"SLEEP",
}
class SqlSecurityError(ValueError):
"""SQL 未通过只读和权限校验。"""
@dataclass(frozen=True)
class ValidatedSql:
sql: str
access_tables: set[str]
def _read_limit(statement: exp.Expression) -> int | None:
limit = statement.args.get("limit")
if limit is None:
return None
expression = limit.args.get("expression")
if not isinstance(expression, exp.Literal) or not expression.is_number:
raise SqlSecurityError("只允许使用数字 LIMIT")
value = int(expression.this)
if value < 0:
raise SqlSecurityError("LIMIT 不能为负数")
return value
def validate_select_sql(
sql: str,
*,
authorized_tables: set[str],
authorized_columns: dict[str, set[str]] | None = None,
max_rows: int,
max_joins: int = 5,
max_columns: int = 100,
) -> ValidatedSql:
"""校验单条 SELECT,检查授权表并将 LIMIT 控制在最大行数内。"""
if not isinstance(sql, str) or not sql.strip():
raise SqlSecurityError("SQL 不能为空")
if max_rows <= 0:
raise SqlSecurityError("最大行数必须为正数")
if max_joins < 0 or max_columns <= 0:
raise SqlSecurityError("SQL 复杂度限制参数无效")
try:
statements = parse(sql, read="mysql")
except ParseError as exc:
raise SqlSecurityError("SQL 解析失败") from exc
if len(statements) != 1 or not isinstance(statements[0], exp.Select):
raise SqlSecurityError("只允许执行单条 SELECT")
statement = statements[0]
join_count = len(list(statement.find_all(exp.Join)))
if join_count > max_joins:
raise SqlSecurityError("SQL JOIN 深度超过限制")
if len(statement.expressions) > max_columns:
raise SqlSecurityError("SQL 返回列数超过限制")
for function in statement.find_all(exp.Func):
function_name = getattr(function, "name", "") or function.sql_name()
if function_name.upper() in _DANGEROUS_FUNCTIONS:
raise SqlSecurityError("SQL 包含危险函数")
access_tables: set[str] = set()
aliases: dict[str, str] = {}
for table in statement.find_all(exp.Table):
if table.db or table.catalog:
raise SqlSecurityError("禁止跨库访问")
access_tables.add(table.name)
aliases[table.alias_or_name] = table.name
if not access_tables.issubset(authorized_tables):
raise SqlSecurityError("SQL 访问了未授权表")
if authorized_columns is not None:
if statement.find(exp.Star):
raise SqlSecurityError("配置字段权限时禁止 SELECT *")
default_table = next(iter(access_tables), None) if len(access_tables) == 1 else None
select_aliases = {
expression.alias
for expression in statement.expressions
if isinstance(expression, exp.Alias) and expression.alias
}
for column in statement.find_all(exp.Column):
# ORDER BY may legally reference an alias defined in SELECT. The
# alias is already covered by the expressions validated below.
if not column.table and column.name in select_aliases and isinstance(column.parent, exp.Ordered):
continue
table_name = aliases.get(column.table, column.table) or default_table
if table_name is None or column.name not in authorized_columns.get(table_name, set()):
raise SqlSecurityError("SQL 访问了未授权字段")
current_limit = _read_limit(statement)
if current_limit is None or current_limit > max_rows:
statement = statement.limit(max_rows)
return ValidatedSql(
sql=statement.sql(dialect="mysql"),
access_tables=access_tables,
)