我不太相信“建议只读”了:给查库工具补完只读守卫后,我才敢说它更安全一点

📅 2026/7/6 7:52:52
我不太相信“建议只读”了:给查库工具补完只读守卫后,我才敢说它更安全一点
我现在看到一句话会本能警觉建议使用只读账号执行查询。这句话有没有问题没问题。但它的问题也恰恰在这儿:它太对了以至于很像一句废话。如果你平时也经常要临时查数据、看表结构、跑一段 SQL、再顺手导个 Excel大概率会懂我这个感觉。你不是不会写 SQL。你也不是不知道线上库、客户库、测试库该小心。真实情况往往是:你刚从别的任务切过来产品在催你脑子里同时挂着两三个库你本来只是想“查一下”这时候“建议只读”这四个字其实和“路上注意安全”差不多。礼貌是有了。防护真不一定有。所以我后来给 Table Exceler 做只读能力时越做越不想停在“文案提醒”这一层。我想要的不是一个会劝人的工具我想要的是一个真能拦人的工具。这篇我就专门聊聊这件事为什么只读不能只停留在“建议”以及我在项目里是怎么把它做成执行规则的。先声明一下本文不是想教育谁谨慎一点。我只是被现实和自己反复教育过所以现在连忙起来的自己都不太敢信。一、我一开始也天真过没有“删除按钮”不就行了最开始我对“只读工具”的理解其实有点像刚学做权限控制时的朴素直觉界面上不放新增不放编辑不放删除不放建表看起来就很只读了。但后来我很快发现这种只读更像一种视觉气质不是一种执行约束。因为用户真正可能改库的地方不在按钮上而在 SQL 输入框里。你按钮收得再干净只要执行链路本身没有边界下面这几种东西照样能进来UPDATEordersSETstatusclosedWHEREid1001SELECT*FROMusers;DELETEFROMusers/* 看起来像注释 */INSERTINTOaudit_logVALUES(1,x)甚至更阴一点的WITHsrcAS(SELECT1)INSERTINTOdstSELECT*FROMsrc这时候你再看“只读工具”这四个字就会有一种很微妙的荒诞感。外面贴着“请勿写入”。里面实际上是畅通无阻。这不叫只读这叫许愿。二、我后来认清一个事提醒不是防护文案不是边界这算是我在这个项目里踩得比较早、但也比较值的一坑。很多时候我们会下意识把“提示”当成“控制”。比如编辑器里飘个红字说“当前只支持只读 SQL”按钮旁边写一句“请使用只读语句”状态栏显示“当前为只读模式”这些当然有用。但它们的作用更像是提醒你别闯红灯不是把车拦下来。我后面给自己下了个很硬的判断只要 SQL 最终还能落到执行层那前面的所有提示本质上都只是客服不是保安。这句话听着有点损但我自己确实是这么被说服的。尤其是数据库工具这种东西你面对的不是“用户会不会故意作恶”这么简单更多是“人在切任务、赶时间、脑子发热的时候会不会手滑”。而手滑这种事讲真程序员圈里谁敢说自己一次都没干过那多少有点嘴硬。三、所以我干的第一件事不是写提示而是写守卫Table Exceler 现在项目里真正负责兜底的核心在src/db/guard.rs。这个模块的思路很直接不是去猜用户“应该想做什么”而是明确规定“什么能过什么不能过”。我用的是比较克制但实用的一套规则。先允许一小撮明确只读的起始关键字constALLOWED_KEYWORDS:[str][SELECT,SHOW,DESCRIBE,DESC,EXPLAIN,WITH,];再明确拦掉一批只要出现就不该放行的关键字constBLOCKED_KEYWORDS:[str][INSERT,UPDATE,DELETE,DROP,ALTER,CREATE,TRUNCATE,REPLACE,MERGE,GRANT,REVOKE,RENAME,];这个做法的核心思想其实很朴素别把安全建立在“用户大概不会这么干”上而要建立在“这类语句根本过不去”上。我后来越来越觉得数据库工具和很多别的工具不一样它不能只讲“体验顺滑”它还得讲“坏事到底能不能发生”。四、真正麻烦的不是UPDATE而是那些“看起来没那么像 UPDATE”的东西如果只拦明面上的INSERT、UPDATE、DELETE那这事其实一点都不难。难的部分在边角料。也就是那些你第一眼没那么警觉但执行起来一样可能出事的写法。1. 注释混淆举个例子下面这种输入如果只是简单按空格切词很容易看漏/* hidden */UPDATEordersSETstatusx所以我现在会先去掉 SQL 注释再做判断。并且这个去注释不是无脑删字符串因为还得保住字符串字面量避免把这种正常查询误伤SELECT-- not a commentASvalue这个细节挺小但很关键。因为只读守卫最怕两件事该拦的没拦住不该拦的全拦了前者危险后者烦人。两个都占这工具基本就废了。2. 多语句这个点我一开始也犹豫过。比如下面这句从“只读”角度看好像没毛病SELECT*FROMusers;SELECT*FROMorders但我最后还是把它拦了。项目里的判断很硬只要检测到字符串字面量之外的分号后面还有内容就直接按多语句处理不放行。原因有两个。第一多语句是最容易夹带私货的通道。今天你放SELECT; SELECT明天就得面对SELECT; DELETE。第二更现实一点Table Exceler 后面的分页和导出链路会把原查询包进子查询里继续处理比如SELECT*FROM(原始SQL)AS_pageLIMIT...SELECTCOUNT(*)FROM(原始SQL)AS_count_subquery只要你允许多语句这种包装方式立刻就不干净了。所以我最后的结论是宁可少放一点也别给执行模型开侧门。这也是为什么当前实现里连SELECT 1; SELECT 2这种看起来“无害”的写法也会被拦。有人会觉得严格。但我自己的感受是数据库安全这事很多时候“严格”只是“别心软”的另一个说法。3.WITH不等于天然安全很多人看到WITH会下意识放松因为它经常跟查询绑在一起。但WITH这玩意儿真不能只看开头。比如WITHcteAS(UPDATEtSETx1)SELECT1或者WITHsrcAS(SELECT1)INSERTINTOdstSELECT*FROMsrc这俩都不该过。所以我在守卫里专门处理了WITH的情况它不只是检查第一个关键字是不是WITH还会继续找 CTE 结束的位置看后面真正落到的语句是不是允许的只读类型。这个坑如果不补前面的“只允许白名单开头”基本等于白写。五、我后来又发现守卫写一层还不够关键是不能只守正门这个认知是后面慢慢补出来的。最开始我也差点犯一个很典型的错误只在“执行查询”按钮那儿校验一次。后来我一看项目链路立刻觉得这事不对。因为同一段 SQL 在 Table Exceler 里不只会走一次它至少会经过这些路径UI 点击执行时做一次提前检查分页查询前再检查一次计数时再检查一次导出整批结果前再检查一次各数据库后端真正执行时还会再检查一次也就是说只读守卫不是一个单点动作它应该是整条执行链路的共识。现在项目里就是这么收的。UI 线程先走 explainable guard目的是给用户一个尽量像人话的反馈真正进到数据库管理器和后端实现时还会继续用guard::check_read_only(sql)?再挡一遍。这块在代码里其实很明显。查询开始前UI 会先拦一次matchcheck_read_only_explain(sql){GuardDecision::Allow{}GuardDecision::Blocked(reason){// 直接给用户可读提示不进后台执行}}但真正到数据库层我还是继续拦pubasyncfnquery_all(self,sql:str)-ResultQueryResult,DbError{guard::check_read_only(sql)?;lettotalconn.backend.count_query(sql).await?;letresultconn.backend.execute_query(sql,0,total).await?;Ok(result)}query_paginated()、count_rows()以及 MySQL / PostgreSQL / SQLite 的execute_query()、count_query()里也都保留了这层检查。为什么要这么“烦”因为我后来很相信一句话真正靠谱的边界不怕重复确认。尤其是这种会被预览、分页、导出反复复用的 SQL你只守一个入口迟早会在别的入口漏风。六、项目实际里我现在最满意的不是“能拦”而是“能说明白为什么拦”这个点挺容易被忽略但我自己做完之后还挺在意。以前很多工具一旦拦你给的提示就俩字失败。这对用户其实很不友好。你明明知道自己在保护他但他感受到的是“这工具又抽风了”。所以现在guard.rs里除了简单的check_read_only()我还单独留了一个check_read_only_explain()会返回更明确的结果命中了写关键字检测到多语句无法确认语法安全然后再把它翻成人话提示给 UI。比如当前项目里就有这种文案当前模式仅支持只读 SQL。检测到写操作关键字UPDATE已拦截以防止修改数据库。当前模式仅支持只读 SQL。检测到多语句输入已拦截。这个设计说大不大说小也不小。因为数据库工具一旦开始管你就得尽量让你知道它为什么管你。不然用户会觉得自己像被一个闷头执法的门卫拦在门口挺委屈的。七、但我也得说句实话只读守卫很重要但它还不是终局答案这里我想老老实实说项目现状不装。Table Exceler 现在已经落地得比较实的是 SQL 级别的只读守卫它已经在 UI、db manager、分页执行、导出链路、后端执行这些路径上反复检查它已经覆盖了注释混淆、多语句、WITH分支、大小写和空白变体这些常见坑src/db/guard.rs里也已经有一批对应测试在兜底但如果你问我这就够了吗我不会说够。因为从安全分层上看字符串级别的守卫解决的是“这段 SQL 看起来安不安全”它还没完全等价于“底层连接天然不具备写能力”。这也是为什么我在设计文档里一直把“数据库级只读会话”当成下一层边界来写。更坦白一点讲当前代码里 MySQL / PostgreSQL 连接层还没有彻底收口成强制只读会话SQLite 这边虽然注释和设计目标都是朝只读连接收的但实现也还值得继续收紧。也正因为项目实际是这样我反而更不敢拿“建议只读”当答案了。因为如果底层那一层还没彻底封死那上层守卫就更不能松。这不丢人。真正丢人的是明明还没封严却提前把自己宣传成“绝对安全”。我宁可老实一点现在是更安全了但我还在继续补第二层边界。八、我最后真正想明白的一句话是不要把用户的自觉当成你的产品能力这篇写到最后我最想说的其实不是“只读守卫怎么写”。而是一个更底层的判断“建议用户小心”不是能力“系统能拦下来”才更接近能力。这话听着有点硬但我做这种数据库小工具越来越信这个。尤其是高频场景下你根本不能默认人永远清醒、永远细心、永远不会复制错 SQL、永远不会切错库。你能做的是把那些最容易出事的洞先补上。别让用户每次查个数据都得顺带和自己的手速、注意力、上下文切换打一架。说到底我做 Table Exceler本来就是想把“查一下数据再导一份结果”这条链路做得更顺手一点。而我现在越来越觉得“顺手”的前提不是按钮够少也不是界面够轻。而是你知道这个工具在关键时刻会替你踩刹车。这才是我后来死磕只读守卫的原因。不是因为我保守。也不是因为我爱设限制。而是因为我太知道人一忙起来真的很需要一个不会跟你客气、但能替你兜底的系统。如果你也做过查库工具、内部后台、导数平台或者只是单纯被误操作吓过一次应该会理解我为什么现在对“建议只读”这几个字已经没有那么容易感动了。它当然有用。但只靠它真不够。免费版现在可以先体验下载链接我放文末了https://github.com/John0615/table-exceler-releases