Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

`SQLBOT_SKIP_TABLE_CHECK` 默认为 `false`。不配置时,生成的 SQL 仍通过真实表名提取和本轮允许表集合检查。

仅在需要跳过这组检查时设置:
仅在需要跳过表白名单检查时设置:

```dotenv
SQLBOT_SKIP_TABLE_CHECK=true
```

通过进程环境变量传入;本地开发也可写入仓库根目录 `.env`,安装版可写入安装目录的 `conf/sqlbot.conf`。Docker Compose 部署可在 SQLBot 服务的 `environment` 中设置。修改后需重启服务或重建容器,所有问数入口共用该启动配置,不提供配置页面或运行时修改。

开启后跳过表名提取、无法提取表名时的拒绝及表白名单检查,可能允许查询数据源账号可读取的未勾选表。SQL 只读、危险函数、元数据查询控制、行权限处理及行数限制仍按原有流程执行。
开启后仅跳过表白名单检查,可能允许查询数据源账号可读取的未勾选表。真实表名提取始终执行,无法提取表名时仍拒绝 SQL。SQL 只读、危险函数、元数据查询控制、行权限处理及行数限制仍按原有流程执行。
29 changes: 14 additions & 15 deletions backend/apps/chat/task/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1578,21 +1578,20 @@ def run_task(self, in_chat: bool = True, stream: bool = True,
sql_operate = OperationEnum.GENERATE_SQL
sql, tables = self.check_sql(session=_session, res=full_sql_text, operate=sql_operate)

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

if ((not self.current_assistant or is_page_embedded) and is_normal_user(
self.current_user)) or use_dynamic_ds:
Expand Down
2 changes: 1 addition & 1 deletion backend/common/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn | str:
# 默认关闭,防止通过元数据查询泄露数据库结构
SQLBOT_ALLOW_METADATA_QUERIES: bool = False

# 启动配置:显式开启时跳过生成 SQL 的表名提取和白名单检查
# 启动配置:显式开启时跳过生成 SQL 的表白名单检查,仍须成功提取表名
SQLBOT_SKIP_TABLE_CHECK: bool = False

PARSE_REASONING_BLOCK_ENABLED: bool = True
Expand Down
35 changes: 27 additions & 8 deletions backend/tests/test_table_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,6 @@ def test_default_allows_known_tables_and_cte_aliases(task, sql):
"SELECT id FROM sales UNION ALL SELECT id FROM secret_payroll",
"unauthorized tables: secret_payroll",
),
("SELECT 1", "unable to extract table names"),
("SELECT (", "unable to extract table names"),
],
)
def test_default_rejects_invalid_or_unknown_tables_before_save_and_execute(
Expand All @@ -107,16 +105,37 @@ def test_default_rejects_invalid_or_unknown_tables_before_save_and_execute(
task.execute_sql.assert_not_called()


@pytest.mark.parametrize("sql", ["SELECT * FROM secret_payroll", "SELECT 1"])
def test_explicit_skip_bypasses_extraction_and_keeps_remaining_flow(
@pytest.mark.parametrize("skip", [False, True])
@pytest.mark.parametrize("sql", ["SELECT 1", "SELECT ("])
def test_missing_tables_are_rejected_even_when_whitelist_check_is_skipped(
task, monkeypatch, sql, skip
):
monkeypatch.setattr(llm.settings, "SQLBOT_SKIP_TABLE_CHECK", skip)
result = run(task, sql)
assert result["success"] is False
assert "unable to extract table names" in result["message"]
task.save_error.assert_called_once()
task.generate_filter.assert_not_called()
task.check_save_sql.assert_not_called()
task.execute_sql.assert_not_called()


@pytest.mark.parametrize(
"sql",
[
"SELECT * FROM secret_payroll",
"SELECT id FROM sales UNION ALL SELECT id FROM secret_payroll",
],
)
def test_explicit_skip_only_bypasses_whitelist_check_and_keeps_remaining_flow(
task, monkeypatch, sql
):
monkeypatch.setattr(llm.settings, "SQLBOT_SKIP_TABLE_CHECK", True)
extract = Mock(side_effect=AssertionError("table extraction must be skipped"))
extract = Mock(wraps=llm.extract_tables_from_sql)
monkeypatch.setattr(llm, "extract_tables_from_sql", extract)
result = run(task, sql)
assert result["success"] is True
extract.assert_not_called()
extract.assert_called_once_with(sql, ds_type="pg")
task.generate_filter.assert_called_once()
task.check_save_sql.assert_called_once()
task.execute_sql.assert_called_once_with(sql=sql)
Expand All @@ -127,8 +146,8 @@ def test_explicit_skip_bypasses_extraction_and_keeps_remaining_flow(
"sql",
[
"DELETE FROM sales",
"SELECT pg_read_file('/etc/passwd')",
"SHOW TABLES",
"SELECT pg_read_file('/etc/passwd') FROM sales",
"DESCRIBE sales",
],
)
def test_skip_keeps_database_execution_guards(task, monkeypatch, sql):
Expand Down
4 changes: 2 additions & 2 deletions docs/agents/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@

1. 只把允许的表和字段元数据提供给模型;
2. 解析模型返回 JSON 并提取 SQL;
3. 用 `sqlglot` 提取真实表名并排除 CTE 别名;
4. 默认对照允许表集合拒绝未授权表;部署显式设置 `SQLBOT_SKIP_TABLE_CHECK=true` 时,跳过第 3、4 步及无法提取表名时的拒绝;
3. 用 `sqlglot` 提取真实表名并排除 CTE 别名,无法提取表名时始终拒绝;
4. 默认对照允许表集合拒绝未授权表;部署显式设置 `SQLBOT_SKIP_TABLE_CHECK=true` 时,仅跳过本步;
5. 按用户、助手和页面嵌入场景应用行权限或动态 SQL 变换;
6. 校验并保存最终 SQL;
7. 执行最终 SQL 并保存结果。
Expand Down
Loading