Files
ThearchyHelios 26ff8e1e06 只读数据源真的只读了:WITH … DELETE 不再从首关键字下面钻过去,连接本身也写不进去
守卫只看首关键字,而 with / pragma 都在只读白名单里。在只读的 SQLite 源上
实测:`WITH k AS (SELECT 1) DELETE FROM t` 被放行,5 行删得只剩 2 行;
`PRAGMA user_version = 7` 也写进去了。调用方只看到一个 ResourceClosedError——
pysqlite 只在 INSERT/UPDATE/DELETE 开头的语句前隐式开事务,WITH 开头的直接
自动提交,连接关闭时的回滚兜不住。Excel/CSV 上传生成的正是这种 SQLite 源。

两层一起收:

守卫:以查询开头的语句(SELECT / WITH / EXPLAIN)要把正文扫一遍,先抹掉注释
和引号里的内容,再找写关键字(DELETE、UPDATE、INTO、FOR UPDATE、EXPLAIN
ANALYZE 里的语句……),关键字后紧跟 ( 的是同名函数不算(MySQL 的 INSERT()、
TRUNCATE())。PRAGMA 改成只读白名单,`= 值` 和 `(值)` 两种赋值写法都拦。
另收一小撮有副作用的函数,其中 set_config 必须拦:PG 真库实测,它能把连接层
的只读关掉,同一连接的下一个事务就能写。函数名加引号、dblink 另开连接这两种
绕法也补上了。is_write 与只读判定共用一份判定,免得审批那道门另有一套口径。

连接层:只读源连接时就只读(engine_args)。SQLite 以 mode=ro 打开(file: URI,
路径带空格、中文、# 都测过);PostgreSQL 设会话默认事务只读;MySQL / MariaDB
设会话事务只读。Oracle 没有会话级开关,只剩守卫——README 写明请配只读账号。

真库验证(临时实例,已清理):PostgreSQL 18 与 MySQL 8.0 上,DELETE、WITH … DELETE、
PG 的数据修改 CTE、nextval、EXPLAIN ANALYZE 绕过守卫直连执行,都被数据库以
只读事务拒绝;可写源不受影响。

测试:守卫新增 33 例(藏在查询里的写、会写的 PRAGMA、同名函数与引号标识符不误杀),
去掉修复重跑是 17 例失败;test_readonly_source.py 6 例(当初那条绕过、绕过守卫
直连写、中文路径、可写源不受波及、各库的会话参数)。297 → 334 passed。

顺带查到但没在这里修的:可写源上的写操作既不审批也不生效——is_dangerous_call
全仓库没人调用,而 run_query 从不 commit,写完就被回滚。两件事必须一起修
(先接审批再让写入生效,反过来就是一个无审批写库的窗口),放在审批统一那一步。
2026-09-25 14:44:56 +08:00

169 lines
6.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""SQL 守卫。
这是数据源接入里唯一不能出错的地方:SQL 由模型生成,不是人写的。
守卫放松一分,模型某次抽风就可能打到生产库上——所以这组用例只增不减。
"""
from __future__ import annotations
import pytest
from app.data.guard import SqlRejected, check, first_verb, is_write, split_statements
def rejected(sql: str, *, readonly: bool = True) -> str | None:
try:
check(sql, readonly=readonly, source_name="测试库")
return None
except SqlRejected as e:
return str(e)
@pytest.mark.parametrize("sql", [
"SELECT * FROM orders",
" select 1 ",
"WITH t AS (SELECT 1) SELECT * FROM t",
"(SELECT a FROM b)",
"SHOW TABLES",
"DESCRIBE orders",
"EXPLAIN SELECT * FROM orders",
"/* 注释 */ SELECT 1",
"-- 行注释\nSELECT 2",
"SELECT 1;", # 尾分号不算第二条语句
])
def test_readonly_allows_queries(sql):
assert rejected(sql) is None, f"不该拒绝:{sql!r}"
@pytest.mark.parametrize("sql", [
"UPDATE orders SET x=1",
"DELETE FROM orders",
"INSERT INTO orders VALUES(1)",
"MERGE INTO a USING b ON (1=1)", # 白名单式判定才拦得住的方言私货
"CALL some_proc()",
"REPLACE INTO t VALUES(1)",
])
def test_readonly_blocks_writes(sql):
reason = rejected(sql)
assert reason and "只读" in reason
@pytest.mark.parametrize("sql", [
# 首关键字是查询,正文在写。只看首关键字的话全都能混过去——
# 第一条在 SQLite 上实测过:守卫放行,数据真的删了
"WITH k AS (SELECT 1) DELETE FROM t WHERE x < 3",
"WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x", # PG 的数据修改 CTE
"WITH k AS (SELECT 1) UPDATE t SET x = 1",
"WITH k AS (SELECT 1) INSERT INTO t SELECT * FROM k",
"SELECT * INTO backup FROM orders", # PG:建一张新表
"SELECT * FROM orders INTO OUTFILE '/tmp/o.csv'", # MySQL:写服务器上的文件
"EXPLAIN ANALYZE DELETE FROM orders", # PG:ANALYZE 会真的执行
"SELECT * FROM orders FOR UPDATE", # 行锁
"SELECT set_config('default_transaction_read_only', 'off', false)", # 关掉连接层只读
"SELECT \"set_config\"('default_transaction_read_only', 'off', false)", # 函数名加引号
"SELECT pg_catalog.set_config('default_transaction_read_only', 'off', false)",
"SELECT * FROM dblink('dbname=x', 'DELETE FROM t RETURNING *') AS r(x int)", # 另开连接
"SELECT nextval('order_seq')",
])
def test_readonly_blocks_writes_hidden_behind_a_query_verb(sql):
reason = rejected(sql)
assert reason and "只读" in reason, f"漏了:{sql!r}"
@pytest.mark.parametrize("sql", [
"PRAGMA user_version = 7",
"PRAGMA user_version(7)", # 函数写法同样是赋值
"PRAGMA query_only = 0", # 想先把只读关掉
"PRAGMA main.writable_schema = 1",
"PRAGMA optimize", # 会跑 ANALYZE,写统计表
"PRAGMA wal_checkpoint(TRUNCATE)",
])
def test_readonly_blocks_pragmas_that_write(sql):
reason = rejected(sql)
assert reason and "只读" in reason, f"漏了:{sql!r}"
@pytest.mark.parametrize("sql", [
"PRAGMA table_info(orders)",
"PRAGMA main.table_info('orders')",
"PRAGMA index_list(orders)",
"PRAGMA foreign_key_list(orders)",
"PRAGMA user_version",
"SELECT updated_at, deleted_flag, created_by FROM orders", # 标识符里带关键字不算
"SELECT 'please delete me' AS note", # 字符串里的不算
'SELECT "update" FROM t', # 引号里的标识符不算
"SELECT REPLACE(name, 'a', 'b'), INSERT('abc', 1, 1, 'x'), TRUNCATE(price, 2) FROM t",
"SHOW CREATE TABLE orders", # MySQL 看建表语句
"WITH t AS (SELECT 1 AS into_count) SELECT * FROM t",
])
def test_readonly_does_not_misfire_on_harmless_queries(sql):
"""正文扫描收紧了,误杀也得守住:同名函数、标识符、字符串都不是在写。"""
assert rejected(sql) is None, f"误杀了:{sql!r}"
def test_is_write_sees_writes_hidden_behind_a_query_verb():
"""可写源靠 is_write 决定要不要人工审批——WITH 开头的写不能被当成查询放过去。"""
assert is_write("WITH k AS (SELECT 1) DELETE FROM t")
assert is_write("SELECT * INTO backup FROM t")
assert is_write("UPDATE t SET x = 1")
assert not is_write("SELECT * FROM t")
assert not is_write("WITH k AS (SELECT 1) SELECT * FROM k")
@pytest.mark.parametrize("sql", [
"DROP TABLE orders",
"TRUNCATE TABLE orders",
"ALTER TABLE t ADD c INT",
"GRANT ALL ON db.* TO u",
"CREATE TABLE t(a INT)",
])
def test_destructive_blocked_even_on_writable_source(sql):
"""结构变更和权限操作在可写源上也不放行——不可逆的事不交给模型发起。"""
reason = rejected(sql, readonly=False)
assert reason and "任何数据源" in reason
@pytest.mark.parametrize("sql", [
"SELECT 1; DROP TABLE users",
"SELECT 1;DROP TABLE users;",
"SELECT 1 /*x*/; DELETE FROM t",
])
def test_multi_statement_blocked(sql):
"""分号拼接必须在到达驱动之前断掉,不赌驱动默认不跑第二条。"""
reason = rejected(sql)
assert reason and "一条语句" in reason
@pytest.mark.parametrize("sql", [
"SELECT ';' AS x",
"SELECT 'a;b' FROM t WHERE c = 'd;e'",
"SELECT '它说:''你好'';' AS s", # 连续两个引号是转义,不是字符串结束
])
def test_semicolon_inside_string_is_not_a_second_statement(sql):
"""误杀比漏杀更让人无从下手:字符串字面量里的分号不算语句分隔。"""
assert rejected(sql) is None, f"误杀了:{sql!r}"
def test_writable_source_allows_dml():
for sql in ("UPDATE orders SET x=1", "INSERT INTO t VALUES(1)", "DELETE FROM t WHERE id=1"):
assert rejected(sql, readonly=False) is None
@pytest.mark.parametrize("sql", ["", " ", "-- 只有注释", "!!!"])
def test_empty_and_malformed_rejected(sql):
assert rejected(sql) is not None
def test_check_returns_statement_without_trailing_semicolon():
"""调用方必须执行返回值而不是原始输入,否则守卫等于没做。"""
assert check("SELECT 1;", readonly=True) == "SELECT 1"
def test_first_verb_sees_through_comments_and_parens():
assert first_verb("/* x */ (SELECT 1)") == "select"
assert first_verb("-- c\nDROP TABLE t") == "drop"
def test_split_keeps_quoted_semicolons_together():
assert len(split_statements("SELECT ';'")) == 1
assert len(split_statements("SELECT 1; SELECT 2")) == 2