mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 12:02:00 +00:00
26ff8e1e06
守卫只看首关键字,而 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,写完就被回滚。两件事必须一起修 (先接审批再让写入生效,反过来就是一个无审批写库的窗口),放在审批统一那一步。
116 lines
4.4 KiB
Python
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 == {}
|