工具输入零信任校验
Day 57 我们让「自己的」LLM 当客户端去调工具——很容易产生「客户端是我的,输入可信」的错觉。今天专门打破它:即便客户端是自己的模型,server 也必须零信任校验每一条入参。这是 B6 从「让它跑通」转向「让它扛得住」的安全转折,也是 B7(OAuth 2.1 + MCP 安全 + CI gate)的前置防线。承接 Day 53 写好的 Zod/JSON Schema(那时为「类型正确」
阶段: B6 · 真 MCP server (2026-07-28 spec)(Day 51-60) 标签: #zero-trust #schema-validation #mcptox #json-rpc
今日导引(由浅入深)
Day 57 我们让「自己的」LLM 当客户端去调工具——很容易产生「客户端是我的,输入可信」的错觉。今天专门打破它:即便客户端是自己的模型,server 也必须零信任校验每一条入参。这是 B6 从「让它跑通」转向「让它扛得住」的安全转折,也是 B7(OAuth 2.1 + MCP 安全 + CI gate)的前置防线。承接 Day 53 写好的 Zod/JSON Schema(那时为「类型正确」,今天为「对抗恶意」复用同一份 schema);明天(Day 59)验证迁移上网没引入行为漂移。今日「最小可判定产出」:9 条恶意输入被 -32602 全部拦截(目标 9/9)。
1. 机理精读
零信任的核心命题:信任边界不在「谁是客户端」,而在「server 收到的每个字节」。即便上游是你自己部署的 DeepSeek 客户端,模型也可能(被投毒 / 幻觉 / bug)填出越界值、缺必填字段、错误类型的入参。MCP server 处在网络边界上,必须把每条 tools/call 的 arguments 当不可信输入对待——这就是 schema-first 校验:声明 schema,运行时强制,校验失败前不让任何业务逻辑执行。
schema 校验是「最小防线」,不是「全部防线」。它能挡三类结构性攻击:(1) 越界值(amount 超出 minimum/maximum、超出 enum);(2) 缺必填(required 字段缺失);(3) 错误类型(该传 string 传了 number/array)。这三类正是 Day 58 实战要注入的 3 类恶意输入。校验失败时,server 必须返回 JSON-RPC 标准错误 -32602 INVALID_PARAMS,而不是把脏数据吞进 handler。
schema 挡得住结构,挡不住语义。这是今天必须讲清的边界:tool poisoning(工具描述投毒,MCPTox 2026)是 schema 完全无能为力的独立风险。投毒攻击不违反任何 schema——它通过合法的 description 字段植入恶意指令,诱导 LLM 客户端误调工具或泄露上下文。schema 校验的是「入参形状」,投毒攻击的是「模型决策」,两者在不同层。所以 SOTA 检查的硬告警是:仅靠 schema 就声称安全是错的——description 投毒、confused-deputy(混淆代理)等需配 OAuth + 工具来源审查(B7 处理),今天只覆盖输入层。
把 MCP 的攻击面分层,能看清今天覆盖到哪一层:
| 层 | 攻击形态 | 防御手段 | 谁覆盖 |
|---|---|---|---|
| L1 入参结构 | 越界/缺字段/错类型 | schema 校验(-32602) | 本日 Day 58 |
| L2 工具描述 | tool poisoning(投毒诱导误调) | 工具来源审查 + 描述审计 | B7 |
| L3 授权边界 | confused-deputy(混淆代理越权) | OAuth 2.1(RFC 9728/8707) | B7 |
| L4 传输 | 中间人/重放 | TLS + 资源指示符 | B7 |
今天只把 L1 钉死——这是「最小防线」三个字的精确含义:必要、但远不充分。把这四层都误以为「schema 能搞定」,就是最危险的安全错觉。
为什么把校验做在 server 而不只在 client? 因为 client 可被绕过、可被替换、可有 bug。「在客户端校验过了」不能成为「server 信任输入」的理由——这是纵深防御的基本原则。toolRegistry.ts 的设计正体现这点:call()(第 183 行)在进 handler 前强制 validate(),没有「跳过校验」的快路径。
零信任在 AML 域为何尤其关键? 因为入参直接影响合规判定与 SAR 草稿内容:
- 若
caseId是脏值(不存在的案件 / 注入字符串),handler 取数会失败或取错案,产出错误的可疑活动结论。 - 若数值参数越界(如负的金额、超大窗口天数),可能让规则引擎产出无意义的得分。
- 若类型错误(数组冒充字符串),handler 内部可能抛未捕获异常,泄露堆栈/内部结构给调用方。
schema 校验把这三类挡在 handler 之前,等于给合规逻辑加了一道「只接受形状合法的证据」的门——这与 auditTrail.ts 的「只记录可验证的事件」是同一种合规纪律。
2. 推导 / 手算 / 代码走读
Day 58 复用 Day 53 的 Zod shape / toolRegistry.ts 的 validate() 做校验。逐条走读(Read 验证自 src/agent/mcp/toolRegistry.ts):
validate(schema, value, path='$'): string[](第 112 行)——返回错误信息数组,空数组 = 通过。它是零信任的执行点。- 类型校验:第 114 行
if (!typeOk(value, schema.type))先验类型,不符立即 push 错误并return(第 116 行,type 不符则不再深入)。typeOk(第 95 行)逐类型判定,注意number要求Number.isFinite(第 100 行,挡 NaN/Infinity),integer要求Number.isInteger(第 102 行)。→ 这挡「错误类型」恶意输入。 - 枚举校验:第 118 行
if (schema.enum && !schema.enum.includes(...))push「not in enum」错误。→ 挡部分「越界值」。 - 数值边界:第 126-129 行对 number/integer 校验
minimum/maximum,越界 push「< minimum」/「> maximum」。→ 挡「越界值」恶意输入。 - 必填校验:第 135 行
for (const req of schema.required ?? [])缺字段 push「required property missing」(第 136 行)。→ 挡「缺必填」恶意输入。 - 递归:数组按
items递归(第 130-132 行),对象按properties递归(第 133-141 行),路径path一路拼接便于定位。 - 错误码绑定:
call()(第 183 行)拿到validate的非空errs后,throw new McpCallError(RPC.INVALID_PARAMS, ...)(第 187 行)。RPC.INVALID_PARAMS = -32602(第 86 行常量)。 - JSON-RPC 信封映射:
handle()(第 199 行)捕获McpCallError,折成response.error(第 211-213 行{ code: e.code, message, data: e.data }),永不抛错(第 197 行注释「所有失败折成 response.error」)。所以网络侧拿到的就是合法的-32602错误响应,而非 500。
结论:9 条恶意输入(3 工具 × 3 类)经 tools/call → handle → call → validate 路径,结构性违规全部被 -32602 拦截在 handler 之前——这是今天「9/9」的代码依据。
一条恶意输入的完整拦截轨迹(以 assessCase 缺必填 caseId 为例,逐行对应代码):
// 请求:缺 caseId(恶意「缺必填」)
{ "jsonrpc":"2.0","id":3,"method":"tools/call",
"params":{ "name":"assessCase","arguments":{} } }
handle()(第 199 行)进入tools/call分支,name="assessCase"非空,进try(第 207 行)。call("assessCase", {})(第 183 行)查到工具,调validate(inputSchema, {})(第 186 行)。validate第 135 行for (const req of schema.required)遍历到caseId,第 136 行if (!('caseId' in {}))成立 → push"$.caseId: required property missing"。errs.length > 0→ 第 187 行throw new McpCallError(RPC.INVALID_PARAMS, ...),RPC.INVALID_PARAMS = -32602。handle()第 211 行catch (e instanceof McpCallError)→ 折成error(第 212 行)。
// 响应:被 -32602 拦截,handler 从未执行
{ "jsonrpc":"2.0","id":3,
"error":{ "code":-32602,"message":"invalid params for assessCase",
"data":["$.caseId: required property missing"] } }
「错误类型」「越界值」两类恶意输入走的是 validate 的第 114 行(typeOk)/ 第 126-129 行(minimum/maximum),终点同样是 -32602。9 条恶意输入对应这 3 类各 3 条,全部在 handler 之前被截停。
3. 今日实战
- 对 3 个 AML 工具(
assessCase/draftSar/listTypologies)各构造 3 类恶意输入,共 9 条:- 越界值:如
assessCase的数值参数填超出maximum的值。 - 缺必填:删掉
required字段(如assessCase缺caseId)。 - 错误类型:该传 string 的填 number/array。
- 越界值:如
- 把 9 条各封进
{jsonrpc:'2.0',id,method:'tools/call',params:{name,arguments}},经 server 的handle()(或 HTTPtools/call)打进去。 - 断言每条返回
error.code === -32602,统计拦截数。 - 校验逻辑复用 Day 53 的 Zod shape(或
toolRegistry.ts的validate()对照基线),不另写校验。 - 接入测试套件,作为零信任回归用例。
4. 今日实测 / 产出
- 状态:待建——数字目标:9/9 恶意输入被
-32602拦截。 - 纯校验路径,本地可验,无需 key。
(按诚信纪律:「待建」逐字保留;「9/9」是目标,未臆造为已测得。)
5. 常见误区 / 陷阱
- 仅靠 schema 就声称安全:SOTA 检查硬告警。schema 挡入参结构,挡不住 description 投毒、confused-deputy——那需 OAuth + 工具来源审查(B7)。
- 把业务/校验错误塞进 200 的
result:校验类错误必须走 JSON-RPC error 通道(-32602)才能被客户端正确处理(呼应 Day 55 SOTA)。塞进result会让客户端误以为调用成功。 - 「客户端是自己的所以输入可信」:纵深防御的反面教材。client 可被绕过/替换/有 bug,server 必须独立校验。
- 只测 happy-path 不测恶意输入:零信任校验的价值正在「拒绝得对」,不测拒绝路径等于没测安全。
- 校验逻辑双写漂移:若 client 一份校验、server 另一份校验,两份易漂移;以 Day 53 的 Zod shape 作单一真相源,server 端复用,避免「client 放过、server 拦截」的不一致。
- 把 NaN/Infinity 当合法 number 放过:
typeOk(第 100 行)特意要求Number.isFinite——若自己写校验忘了这点,amount=Infinity会绕过maximum检查(任何上限都 < Infinity 永真)。
6. 学习资源(每条带 YYYY-MM)
- MCPTox benchmark(2026)—— tool poisoning / description 投毒攻击基准;schema 无法防御的语义层风险。
- MCP 规范(2026-07-28,硬复查点 07-28)——
tools/call入参校验与-32602错误语义。 - JSON-RPC 2.0 规范(2010-03,长期稳定)——
-32602 INVALID_PARAMS标准错误码定义。 - Zod v3 文档(2026,v4 已 RC)—— schema-first 运行时校验;升级前查
z.toJSONSchema行为变更(呼应 Day 53)。 - NIST CAISI AI Agent 标准(2026-02 启动)—— AI agent 工具调用安全基线方向。
SOTA检查 (2026-06 更新)
- 当前主流方案:schema-first 校验是 MCP 安全基线共识,2026-06 仍是入门级标配,无替代。
- 是否仍 SOTA:作为「输入层最小防线」是,但单靠它不构成安全。
- 过时黑名单 / AVOID:禁止仅靠 schema 就声称安全——description 投毒、confused-deputy 等需配 OAuth + 工具来源审查(B7 处理),本日只覆盖输入层。禁止把校验错误塞进 200
result,必须走-32602error 通道。 - 下次复查点:B7 接入 OAuth 2.1(RFC 9728/8707)后,复审「输入层 + 鉴权层」是否覆盖 confused-deputy;07-28 MCP 规范定稿后重验错误码字段。
衔接
- 昨天:Day 57 — DeepSeek-V3 作 MCP 客户端(暴露了「客户端可自由填参」的攻击面)。
- 今天:server 零信任校验——9 条恶意输入经
validate()被-32602拦截(目标 9/9),并讲清 schema 挡不住语义投毒的边界。 - 明天:Day 59 — 进程内 mock vs 真 server 等价回归,用对拍验证迁移上网无行为漂移。