Claude Code,超越提示——加固MCP数据库工具(第四部分深度解析)
本文是 第4部分:你的第一个MCP服务器 的深度配套文章,属于《Claude Code,超越提示》系列。
第4部分 发布了一个刻意精简的MCP数据库工具——足以让Claude安全地以只读方式操作数据库,同时避免将文章变成安全论文。但评论区的反馈比任何文章都更有价值:读者们拆解了这个示例,找出了所有"足够安全用于学习"与"足够安全用于生产"之间的差距。
本文汇集了这些成果。它有意比第4部分更深入。如果你还没有构建过第一个MCP服务器,请先阅读那篇文章。如果你已经有一个服务器,并且正准备将其连接到重要的数据库,那么这篇就是安全加固指南。
贯穿全文只有一个原则。
只有通过结构性强制执行的边界才是真正的边界
以下所有内容都是同一个模式的重复:将一个声明式的规则替换为一个强制执行的规则。
提示词中说"只读,绝不写入"是声明。像 needsApproval 这样实际上从未在服务端运行的框架标志是声明。sql.strip().startswith("select") 是声明——SELECT 1; DROP TABLE users 可以轻松绕过它。即使是只读事务也是声明,因为会话可以将其重新关闭。
声明是礼貌地请求。强制执行则让危险操作变得不可能。模型不需要具有对抗性——它只是偶尔会生成一些愚蠢的内容,此时"我告诉过它不要这样做"与"它物理上无法做到"是截然不同的处境。
七层加固,每一层都将一个声明转化为强制执行。
1. 无法关闭的写入保护
最容易被忽视的微妙之处在于:工具的只读方式决定了它是一堵墙还是一个建议。
- 只读事务(
SET default_transaction_read_only = on,或psycopg的conn.read_only = True)是可逆的。同一个会话可以执行SET transaction_read_only = off然后写入。提示注入的查询正是这样做的。它只是一个标志,而标志可以被翻转。 - 只读角色——一个在任何地方都没有 INSERT / UPDATE / DELETE / DDL 权限的角色——无法写入,因为它根本不存在写入权限。没有什么可以翻转的。这就是结构性的。
采用先授权的方式设置:
-- 一个结构上无法写入的角色。
CREATE ROLE claude_readonly LOGIN PASSWORD '***';
-- 从零开始,只添加所需权限。
REVOKE ALL ON SCHEMA app FROM claude_readonly;
GRANT USAGE ON SCHEMA app TO claude_readonly;
GRANT SELECT ON app.orders_summary TO claude_readonly;
同时保留只读事务——双重保险——但要理解各自的角色:授权是墙,事务标志是保险带。同时删除 startswith("select") 检查,或者仅将其保留为更友好的错误消息。它从来都不是真正的边界。
2. 真正重要的平凡部分
大多数教程止步于"只读",却忽略了在生产环境中维持系统运行的两行关键代码:
cur.execute("SET statement_timeout = '5s'") # 终止失控查询
rows = cur.fetchmany(500) # 限制结果集大小
语句超时,因为模型最终会生成一个意外的交叉连接,试图读取数十亿行数据。行数上限,因为即使是合法查询也可能返回远超上下文——或内存——所能承受的数据量。两者都不引人注目,但区别在于"糟糕的查询是错误消息"还是"糟糕的查询是事故"。同时为角色添加连接限制(ALTER ROLE claude_readonly CONNECTION LIMIT 4),防止卡住的工具耗尽连接池。
3. 读取范围,而不仅仅是写入保护
写入保护回答了"它能损坏数据吗?"但它没有回答"它能看到不应该看到的数据吗?"一个可以访问整个模式的只读SELECT角色,会在任务只需要昨天的订单数量时,愉快地将整个 users 表交给模型。
因此,在数据库中按工具限定读取范围:
- 为每个工具分配自己的角色——每个工具一个身份,而不是共享的"只读"超级角色。
- 将其指向专门构建的只读视图,而不是基础表。视图只暴露该工作所需的列,不暴露其他内容。
CREATE VIEW app.orders_summary AS
SELECT order_id, status, created_at, total_cents -- 没有customer_email,没有address
FROM app.orders;
GRANT SELECT ON app.orders_summary TO claude_readonly;
现在"读取范围"在模式中声明式定义,并由数据库引擎强制执行。即使是一个创造性的 SELECT 也无法访问视图未暴露的列。注意你没有做什么:在工具代码中过滤结果。在代码中过滤是 startswith 错误的另一种形式——一个模型的输出必须通过的检查,而不是一堵无法穿透的墙。
4. 授权是时间点性的——使其持久化
这里有一个会自动出现的漏洞,无需任何人做错什么。GRANT SELECT ON ALL TABLES 只覆盖今天存在的表。下个月创建的表默认情况下,你的只读角色对其没有任何权限。
要精确理解这意味着什么:新表默认是写入安全的(没有授权 = 无法写入,所以墙仍然存在),但你的读取访问会静默中断——更糟糕的是,你的安全姿态现在需要手动维护。"只读,并且保持只读"悄然变成了"只读,只要有人记得在每个新对象上正确重新授权"。手动维护意味着容易被遗忘。
ALTER DEFAULT PRIVILEGES 解决了这个问题:
ALTER DEFAULT PRIVILEGES IN SCHEMA app
GRANT SELECT ON TABLES TO claude_readonly;
该模式中未来的表现在对该角色默认是可读但不可写的,无需任何人记住任何事情。
5. 验证它:从未攻击过的边界只是声明
你可以完美地设置所有这些,但仍然不知道它是否被强制执行——因为你从未尝试过破坏它。廉价而果断的检查是CI中的负控制:以工具角色连接,尝试写入,并要求返回错误。
只有当它攻击一个新对象时才有意义——否则你只是证明了今天的表有防护,这并不能说明下个月的情况:
import psycopg, pytest
def test_tool_role_cannot_write(admin_dsn, tool_dsn):
# 在授权配置完成后创建一个全新的表。
with psycopg.connect(admin_dsn, autocommit=True) as admin:
admin.execute("CREATE TABLE app.scratch_probe (id int)")
try:
with psycopg.connect(tool_dsn) as ro, ro.cursor() as cur:
with pytest.raises(psycopg.errors.InsufficientPrivilege):
cur.execute("INSERT INTO app.scratch_probe VALUES (1)")
finally:
with psycopg.connect(admin_dsn, autocommit=True) as admin:
admin.execute("DROP TABLE app.scratch_probe")
如果这个测试从绿变红,你的只读保证就退化了,你会在CI中而不是在事故中发现。这是整篇文章中杠杆率最高的事情:它将"我配置了只读"转化为"每次提交都验证只读"。
6. 工具之下:操作系统沙箱
到目前为止,所有内容都强化了数据库边界。但工具也是一个进程,任何工具中的bug——不仅仅是数据库工具——都不应该能够超出该进程被授予的权限范围。
因此,将整个MCP服务器作为其自己的非特权Linux用户运行:一个锁定的sudoers白名单(或没有),文件系统限制在自己的目录中,没有对其不使用的任何服务的环境凭据。这样,即使一个被攻破或有bug的工具也无法使用sudo,无法进入 /etc,无法触及它从未被授予权限的服务。
7. 事后:真正有用的日志记录
记录每一次调用——标准化后的查询、结果模式、行数。这是你的审计追踪,你确实需要它。
但请诚实面对它的实际使用方式,因为大多数"我们有完整审计日志"的说法都在这里悄然崩塌:你不会主动去读它。 没有人会逐行阅读原始日志。只有在出问题后才打开的日志,那叫取证——有用,但本质上是被动的。
真正可扩展的不是审查日志,而是基于日志做断言。把日志转化为告警:
- 写路径工具在预期窗口之外运行,
- 部署工具运行时没有对应的已合并PR,
- 调用量激增但与任何人类活动不匹配。
我自己的一个真实案例:如果部署工具运行时没有主分支上对应的已合并PR,就会触发告警。它曾经捕获过一次部署陈旧代码的情况——变更还没有被推送,所以配方部署了旧版本。如果靠手动阅读日志,我永远不可能发现这个问题;而断言在几秒钟内就捕获了它。"我会审查审计日志"只是一个愿望。基于审计日志的告警才是一个机制。
贯穿始终的主线
回顾这七个层面,它们本质上都是同一个动作:将声明——一个提示、一个标志、一个字符串检查、一个时间点的授权、一个未经验证的配置、一份无人阅读的日志——替换为强制执行,无论是否有人在关注,它都有效。
第四部分的最小化工具是正确的起点;你不应该让第一次部署服务器的人就面对所有这些要求。但当这个工具指向一个重要的数据库时,这就是标准:一个不能写入的角色,限定在不会过度暴露的视图上,通过默认权限保持持久性,通过阴性对照来验证,被OS沙箱所隔离,并由告警而非期望来监控。
最后,归功于该归功的人——这篇文章本质上就是一个优秀评论线程的产物。其中最精妙的部分(只读事务的可逆性、ALTER DEFAULT PRIVILEGES、新对象的阴性对照、通过视图实现读取范围)都来自读者在公开场合拆解第四部分示例代码的贡献。这就是互联网美好的一面。
本文是第四部分的配套深度解析,属于"Claude Code:超越提示"系列。关于"安全之手"的读取层面——强化代理从自身记忆中读取的内容——是一个独立的开源项目:RE-call。