Skip to content

Commit 6cb69d0

Browse files
committed
feat: add startup option to skip SQL table checks
1 parent ca069c5 commit 6cb69d0

5 files changed

Lines changed: 182 additions & 16 deletions

File tree

‎backend/README.md‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1-
# FastAPI Project - Backend
1+
# FastAPI Project - Backend
2+
3+
## 表检查启动参数
4+
5+
`SQLBOT_SKIP_TABLE_CHECK` 默认为 `false`。不配置时,生成的 SQL 仍通过真实表名提取和本轮允许表集合检查。
6+
7+
仅在需要跳过这组检查时设置:
8+
9+
```dotenv
10+
SQLBOT_SKIP_TABLE_CHECK=true
11+
```
12+
13+
通过进程环境变量传入;本地开发也可写入仓库根目录 `.env`,安装版可写入安装目录的 `conf/sqlbot.conf`。Docker Compose 部署可在 SQLBot 服务的 `environment` 中设置。修改后需重启服务或重建容器,所有问数入口共用该启动配置,不提供配置页面或运行时修改。
14+
15+
开启后跳过表名提取、无法提取表名时的拒绝及表白名单检查,可能允许查询数据源账号可读取的未勾选表。SQL 只读、危险函数、元数据查询控制、行权限处理及行数限制仍按原有流程执行。

‎backend/apps/chat/task/llm.py‎

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1578,20 +1578,21 @@ def run_task(self, in_chat: bool = True, stream: bool = True,
15781578
sql_operate = OperationEnum.GENERATE_SQL
15791579
sql, tables = self.check_sql(session=_session, res=full_sql_text, operate=sql_operate)
15801580

1581-
# 表名安全检查:用 sqlglot 解析真实 SQL,不信任 AI 返回的 tables
1582-
actual_tables = extract_tables_from_sql(sql, ds_type=self.ds.type)
1583-
if not actual_tables:
1584-
raise SingleMessageError(
1585-
"SQL parsing failed: unable to extract table names. "
1586-
"This may indicate an unsupported SQL syntax or a security issue."
1587-
)
1588-
allowed_tables = set(self.table_name_list)
1589-
unauthorized_tables = actual_tables - allowed_tables
1590-
if unauthorized_tables:
1591-
raise SingleMessageError(
1592-
f"SQL contains unauthorized tables: {', '.join(unauthorized_tables)}. "
1593-
f"Allowed tables: {', '.join(allowed_tables)}"
1594-
)
1581+
if not settings.SQLBOT_SKIP_TABLE_CHECK:
1582+
# 表名安全检查:用 sqlglot 解析真实 SQL,不信任 AI 返回的 tables
1583+
actual_tables = extract_tables_from_sql(sql, ds_type=self.ds.type)
1584+
if not actual_tables:
1585+
raise SingleMessageError(
1586+
"SQL parsing failed: unable to extract table names. "
1587+
"This may indicate an unsupported SQL syntax or a security issue."
1588+
)
1589+
allowed_tables = set(self.table_name_list)
1590+
unauthorized_tables = actual_tables - allowed_tables
1591+
if unauthorized_tables:
1592+
raise SingleMessageError(
1593+
f"SQL contains unauthorized tables: {', '.join(unauthorized_tables)}. "
1594+
f"Allowed tables: {', '.join(allowed_tables)}"
1595+
)
15951596

15961597
if ((not self.current_assistant or is_page_embedded) and is_normal_user(
15971598
self.current_user)) or use_dynamic_ds:

‎backend/common/core/config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str:
117117
# 默认关闭,防止通过元数据查询泄露数据库结构
118118
SQLBOT_ALLOW_METADATA_QUERIES: bool = False
119119

120+
# 启动配置:显式开启时跳过生成 SQL 的表名提取和白名单检查
121+
SQLBOT_SKIP_TABLE_CHECK: bool = False
122+
120123
PARSE_REASONING_BLOCK_ENABLED: bool = True
121124
DEFAULT_REASONING_CONTENT_START: str = '<think>'
122125
DEFAULT_REASONING_CONTENT_END: str = '</think>'
@@ -136,6 +139,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str:
136139
ORACLE_CLIENT_PATH: str = '/opt/sqlbot/db_client/oracle_instant_client'
137140

138141
@field_validator('SQL_DEBUG',
142+
'SQLBOT_SKIP_TABLE_CHECK',
139143
'EMBEDDING_ENABLED',
140144
'GENERATE_SQL_QUERY_LIMIT_ENABLED',
141145
'PARSE_REASONING_BLOCK_ENABLED',

‎backend/tests/test_table_check.py‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"""Offline task regressions with model, persistence and database I/O replaced."""
2+
3+
from types import SimpleNamespace
4+
from unittest.mock import Mock
5+
6+
import pytest
7+
import sqlbot_xpack # noqa: F401 # Initialize the published wheel before app imports.
8+
from pydantic import ValidationError
9+
10+
from apps.chat.models.chat_model import ChatFinishStep
11+
from apps.chat.task import llm
12+
from common.core.config import Settings
13+
14+
15+
@pytest.mark.parametrize(
16+
"value,expected",
17+
[(None, False), ("false", False), ("true", True), (" TRUE ", True)],
18+
)
19+
def test_startup_configuration(monkeypatch, value, expected):
20+
monkeypatch.delenv("SQLBOT_SKIP_TABLE_CHECK", raising=False)
21+
if value is not None:
22+
monkeypatch.setenv("SQLBOT_SKIP_TABLE_CHECK", value)
23+
config = Settings(_env_file=None)
24+
assert config.SQLBOT_SKIP_TABLE_CHECK is expected
25+
monkeypatch.setenv("SQLBOT_SKIP_TABLE_CHECK", str(not expected))
26+
assert config.SQLBOT_SKIP_TABLE_CHECK is expected
27+
28+
29+
def test_invalid_startup_configuration_is_rejected(monkeypatch):
30+
monkeypatch.setenv("SQLBOT_SKIP_TABLE_CHECK", "invalid")
31+
with pytest.raises(ValidationError, match="SQLBOT_SKIP_TABLE_CHECK"):
32+
Settings(_env_file=None)
33+
34+
35+
@pytest.fixture
36+
def task(monkeypatch):
37+
monkeypatch.setattr(llm.settings, "SQLBOT_SKIP_TABLE_CHECK", False)
38+
monkeypatch.setattr(llm, "session_maker", Mock())
39+
monkeypatch.setattr(llm, "check_connection", Mock(return_value=True))
40+
monkeypatch.setattr(llm, "start_log", Mock())
41+
monkeypatch.setattr(llm, "end_log", Mock())
42+
monkeypatch.setattr(llm, "get_chat_chart_data", Mock(return_value=[]))
43+
service = Mock(spec=llm.LLMService)
44+
service.ds = SimpleNamespace(id=1, type="pg")
45+
service.chat_question = SimpleNamespace(question="sales")
46+
service.record = SimpleNamespace(id=1)
47+
service.get_record.return_value = service.record
48+
service.current_user = SimpleNamespace(id=2, isAdmin=False, weight=0)
49+
service.current_assistant = None
50+
service.change_title = False
51+
service.table_name_list = ["sales"]
52+
service.current_logs = {}
53+
service.generate_sql.return_value = [{"content": "{}"}]
54+
service.generate_filter.return_value = None
55+
service.execute_sql.return_value = {"data": []}
56+
return service
57+
58+
59+
def run(task, sql, reported_tables=None):
60+
# Model-reported tables are deliberately independent of the real SQL.
61+
task.check_sql.return_value = sql, reported_tables or ["sales"]
62+
task.check_save_sql.return_value = sql
63+
results = list(
64+
llm.LLMService.run_task(
65+
task, in_chat=False, stream=False, finish_step=ChatFinishStep.QUERY_DATA
66+
)
67+
)
68+
assert len(results) == 1
69+
return results[0]
70+
71+
72+
@pytest.mark.parametrize(
73+
"sql",
74+
[
75+
"SELECT id FROM sales",
76+
"WITH totals AS (SELECT id FROM sales) SELECT * FROM totals",
77+
],
78+
)
79+
def test_default_allows_known_tables_and_cte_aliases(task, sql):
80+
result = run(task, sql)
81+
assert result["success"] is True
82+
task.execute_sql.assert_called_once_with(sql=sql)
83+
task.check_save_sql.assert_called_once()
84+
task.save_error.assert_not_called()
85+
86+
87+
@pytest.mark.parametrize(
88+
"sql,error",
89+
[
90+
("SELECT * FROM secret_payroll", "unauthorized tables: secret_payroll"),
91+
(
92+
"SELECT id FROM sales UNION ALL SELECT id FROM secret_payroll",
93+
"unauthorized tables: secret_payroll",
94+
),
95+
("SELECT 1", "unable to extract table names"),
96+
("SELECT (", "unable to extract table names"),
97+
],
98+
)
99+
def test_default_rejects_invalid_or_unknown_tables_before_save_and_execute(
100+
task, sql, error
101+
):
102+
result = run(task, sql, reported_tables=["sales"])
103+
assert result["success"] is False
104+
assert error in result["message"]
105+
task.save_error.assert_called_once()
106+
task.check_save_sql.assert_not_called()
107+
task.execute_sql.assert_not_called()
108+
109+
110+
@pytest.mark.parametrize("sql", ["SELECT * FROM secret_payroll", "SELECT 1"])
111+
def test_explicit_skip_bypasses_extraction_and_keeps_remaining_flow(
112+
task, monkeypatch, sql
113+
):
114+
monkeypatch.setattr(llm.settings, "SQLBOT_SKIP_TABLE_CHECK", True)
115+
extract = Mock(side_effect=AssertionError("table extraction must be skipped"))
116+
monkeypatch.setattr(llm, "extract_tables_from_sql", extract)
117+
result = run(task, sql)
118+
assert result["success"] is True
119+
extract.assert_not_called()
120+
task.generate_filter.assert_called_once()
121+
task.check_save_sql.assert_called_once()
122+
task.execute_sql.assert_called_once_with(sql=sql)
123+
task.save_error.assert_not_called()
124+
125+
126+
@pytest.mark.parametrize(
127+
"sql",
128+
[
129+
"DELETE FROM sales",
130+
"SELECT pg_read_file('/etc/passwd')",
131+
"SHOW TABLES",
132+
],
133+
)
134+
def test_skip_keeps_database_execution_guards(task, monkeypatch, sql):
135+
from apps.db import db
136+
137+
monkeypatch.setattr(llm.settings, "SQLBOT_SKIP_TABLE_CHECK", True)
138+
monkeypatch.setattr(llm.settings, "SQLBOT_ALLOW_METADATA_QUERIES", False)
139+
connect = Mock(side_effect=AssertionError("database must not be contacted"))
140+
monkeypatch.setattr(db, "get_session", connect)
141+
task.execute_sql.side_effect = lambda sql: llm.LLMService.execute_sql(task, sql)
142+
result = run(task, sql)
143+
assert result["success"] is False
144+
assert "SQL can only contain read operations" in result["message"]
145+
task.execute_sql.assert_called_once_with(sql=sql)
146+
connect.assert_not_called()

‎docs/agents/security.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
1. 只把允许的表和字段元数据提供给模型;
3434
2. 解析模型返回 JSON 并提取 SQL;
3535
3. 用 `sqlglot` 提取真实表名并排除 CTE 别名;
36-
4. 对照允许表集合拒绝未授权表;
36+
4. 默认对照允许表集合拒绝未授权表;部署显式设置 `SQLBOT_SKIP_TABLE_CHECK=true` 时,跳过第 3、4 步及无法提取表名时的拒绝;
3737
5. 按用户、助手和页面嵌入场景应用行权限或动态 SQL 变换;
3838
6. 校验并保存最终 SQL;
3939
7. 执行最终 SQL 并保存结果。
@@ -43,6 +43,7 @@
4343
- 不要信任模型返回的 `tables` 字段替代真实 SQL 解析。
4444
- 行权限拼接沿用白名单逻辑运算符和 `_escape_sql_value`,不要引入新的字符串拼接入口。
4545
- 保持 SQL 行数限制和 `SQLBOT_ALLOW_METADATA_QUERIES` 默认关闭语义;不要为了调试全局开启元数据查询。
46+
- `SQLBOT_SKIP_TABLE_CHECK` 默认为 `false`,仅在启动时读取,不提供参数页面或运行时修改。开启后可能访问数据源账号可读取的未勾选表;SQL 只读、危险函数、元数据查询控制与既有权限处理不随此开关关闭。配置方式见 [`backend/README.md`](../../backend/README.md)。
4647
- 动态 SQL 生成后仍必须走同一解析、校验和保存路径。
4748
- 数据源连接配置解密只发生在服务端执行路径,不进入日志或 API 响应。
4849

0 commit comments

Comments
 (0)