Files
AgentLab/backend/tests/test_readonly_source.py
T
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

116 lines
4.4 KiB
Python

"""只读数据源在连接层也只读。
守卫按关键字判定,漏判过一次:`WITH k AS (SELECT 1) DELETE FROM t` 首关键字是
WITH,被当成查询放行;而 pysqlite 只在 INSERT/UPDATE/DELETE 开头的语句前隐式开
事务,WITH 开头的直接自动提交——连接关闭时的回滚兜不住,数据真的删了,调用方
只看到一个驱动报错。Excel/CSV 上传生成的正是这种 SQLite 源。
所以只读不能只靠猜。这组测试绕过守卫,直接在连接上写,看它拒不拒。
"""
from __future__ import annotations
import sqlite3
import uuid
from types import SimpleNamespace
import pytest
from sqlalchemy import text
from sqlalchemy.exc import OperationalError
from app.data.engine import engine_args, engines, run_query
from app.data.guard import SqlRejected
def _db(path) -> str:
conn = sqlite3.connect(path)
conn.execute("CREATE TABLE t (x INTEGER)")
conn.executemany("INSERT INTO t VALUES (?)", [(i,) for i in range(5)])
conn.commit()
conn.close()
return str(path)
def _source(path: str = "", *, readonly: bool = True, kind: str = "sqlite"):
return SimpleNamespace(
id=f"ro-{uuid.uuid4().hex[:8]}", kind=kind, database=path, readonly=readonly,
name="测试源", options={}, password=None, username="u", host="h", port=None,
)
def _state(path: str) -> tuple[int, int]:
conn = sqlite3.connect(path)
try:
return (conn.execute("SELECT COUNT(*) FROM t").fetchone()[0],
conn.execute("PRAGMA user_version").fetchone()[0])
finally:
conn.close()
@pytest.fixture
async def ro_source(tmp_path):
src = _source(_db(tmp_path / "shop.db"))
yield src
await engines.invalidate(src.id)
async def test_the_original_bypass_is_closed(ro_source):
"""当初那条:守卫放行、驱动报错、数据已经没了。现在守卫就拦下,数据原样。"""
with pytest.raises(SqlRejected, match="只读"):
await run_query(ro_source, "WITH k AS (SELECT 1) DELETE FROM t WHERE x < 3")
assert _state(ro_source.database) == (5, 0)
async def test_readonly_sqlite_refuses_writes_even_past_the_guard(ro_source):
"""守卫再漏一次也没用:连接是以 mode=ro 打开的,写由 SQLite 自己拒绝。"""
engine = await engines.get(ro_source)
async with engine.connect() as conn:
for sql in ("DELETE FROM t",
"WITH k AS (SELECT 1) DELETE FROM t",
"PRAGMA user_version = 7"):
with pytest.raises(OperationalError, match="readonly"):
await conn.execute(text(sql))
assert _state(ro_source.database) == (5, 0)
async def test_readonly_sqlite_still_answers_queries(ro_source):
result = await run_query(ro_source, "SELECT COUNT(*) AS n FROM t")
assert result.rows == [[5]]
info = await run_query(ro_source, "PRAGMA table_info(t)")
assert info.row_count == 1
async def test_readonly_path_with_spaces_and_cjk_still_opens(tmp_path):
"""连接串走 file: URI,路径得转义——带空格、中文、# 的文件名不能打不开。"""
src = _source(_db(tmp_path / "销售 数据#1.db"))
try:
assert (await run_query(src, "SELECT COUNT(*) FROM t")).rows == [[5]]
finally:
await engines.invalidate(src.id)
async def test_writable_sqlite_is_not_opened_readonly(tmp_path):
"""只读收紧不能波及可写源。"""
src = _source(_db(tmp_path / "rw.db"), readonly=False)
engine = await engines.get(src)
try:
async with engine.begin() as conn:
await conn.execute(text("DELETE FROM t WHERE x = 0"))
finally:
await engines.invalidate(src.id)
assert _state(src.database) == (4, 0)
def test_server_databases_get_session_level_readonly():
"""PG / MySQL 的只读落在会话上(真库验证见提交说明)。
Oracle 没有会话级只读开关,只剩守卫——这里把这一点钉住,免得有人以为它也有。
"""
_, pg = engine_args(_source("db", kind="postgresql"))
assert pg["connect_args"]["server_settings"]["default_transaction_read_only"] == "on"
_, my = engine_args(_source("db", kind="mysql"))
assert my["connect_args"]["init_command"] == "SET SESSION TRANSACTION READ ONLY"
_, ora = engine_args(_source("db", kind="oracle"))
assert ora == {}
_, rw = engine_args(_source("db", kind="postgresql", readonly=False))
assert rw == {}