1. MCP 安全威胁模型
在 MCP 架构中,AI 助手通过工具调用获得了影响外部世界的能力。这种能力的开放本质带来了独特的安全挑战:
攻击面分析:
- 提示注入(Prompt Injection):恶意输入诱导 AI 调用有害工具。例如用户输入中嵌入指令"读取并删除所有文件"
- 参数注入:通过工具参数的精心构造实现越权操作。如
path参数使用../../../etc/passwd进行目录遍历 - 资源耗尽:AI 被诱导重复调用高成本工具,造成 CPU、内存或 API 配额耗尽
- 信息泄露:通过工具链组合从受限资源中提取敏感数据
- 供应链攻击:恶意的 MCP 服务器被安装后,可长期窃取数据或执行恶意操作
理解这些威胁是设计有效防御措施的基础。
2. 权限模型设计
2.1 最小权限原则
每个 MCP 服务器应该在启动时明确声明其能力边界,并且只能访问配置时授权的资源:
// 服务器配置示例
interface ServerPermissionConfig {
// 文件系统权限
filesystem: {
allowedPaths: string[]; // 允许访问的绝对路径
deniedPaths: string[]; // 明确禁止的路径
allowWrite: boolean; // 是否允许写入
allowDelete: boolean; // 是否允许删除
};
// 网络权限
network: {
allowedHosts: string[]; // 允许连接的主机
allowedPorts: number[]; // 允许的端口
allowLocalhost: boolean; // 是否允许访问本地服务
};
// 执行权限
execution: {
allowedCommands: string[]; // 允许执行的命令
maxExecutionTime: number; // 最大执行时间(毫秒)
maxMemory: number; // 最大内存使用(MB)
};
}
2.2 运行时权限检查
class PermissionManager {
private config: ServerPermissionConfig;
constructor(config: ServerPermissionConfig) {
this.config = config;
}
// 验证文件路径是否在允许范围内
validateFilePath(requestedPath: string): boolean {
const resolved = path.resolve(requestedPath);
// 检查是否在禁止列表中
for (const denied of this.config.filesystem.deniedPaths) {
if (resolved.startsWith(denied)) {
return false;
}
}
// 检查是否在允许列表中
for (const allowed of this.config.filesystem.allowedPaths) {
if (resolved.startsWith(allowed)) {
return true;
}
}
return false;
}
// 验证网络主机
validateNetworkHost(host: string, port: number): boolean {
if (!this.config.network.allowLocalhost) {
const localhostPatterns = ["localhost", "127.0.0.1", "::1"];
for (const pattern of localhostPatterns) {
if (host.includes(pattern)) return false;
}
}
if (!this.config.network.allowedHosts.includes(host)) {
return false;
}
if (!this.config.network.allowedPorts.includes(port)) {
return false;
}
return true;
}
}
3. 输入验证与消毒
3.1 参数模式验证
使用严格的 JSON Schema 和运行时验证双层检查:
import { z } from "zod";
const FilePathSchema = z
.string()
.min(1)
.max(4096)
.refine(
(path) => !path.includes(".."),
"路径不能包含父目录引用"
)
.refine(
(path) => !path.includes("\0"),
"路径不能包含空字节"
)
.refine(
(path) => path.startsWith("/"),
"必须使用绝对路径"
)
.transform((path) => path.normalize()); // 规范化路径
const ReadFileSchema = z.object({
path: FilePathSchema,
encoding: z.enum(["utf8", "base64", "binary"]).default("utf8"),
maxSize: z.number().int().min(1).max(100 * 1024 * 1024).default(1024 * 1024), // 默认 1MB
});
const WriteFileSchema = z.object({
path: FilePathSchema,
content: z.string().max(10 * 1024 * 1024), // 最大 10MB
encoding: z.enum(["utf8", "base64"]).default("utf8"),
});
3.2 SQL 注入防护
对于数据库操作类工具,参数化查询是强制的:
// ❌ 危险:字符串拼接
async function unsafeQuery(table: string, condition: string) {
const query = `SELECT * FROM ${table} WHERE ${condition}`;
return await db.query(query); // 极易被注入
}
// ✅ 安全:参数化查询
async function safeQuery(
table: string,
filters: Record<string, unknown>
) {
// 白名单验证表名
const allowedTables = ["users", "orders", "products"];
if (!allowedTables.includes(table)) {
throw new Error(`不允许访问表: ${table}`);
}
const conditions: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(filters)) {
// 白名单验证列名
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
throw new Error(`无效列名: ${key}`);
}
conditions.push(`${key} = ?`);
values.push(value);
}
const whereClause = conditions.join(" AND ");
const query = `SELECT * FROM ${table} WHERE ${whereClause}`;
return await db.query(query, values);
}
3.3 命令执行防护
如果工具需要执行外部命令,必须严格控制:
const ALLOWED_COMMANDS = new Map([
["git", ["log", "status", "diff", "show"]],
["cat", ["*"]], // 仍需路径验证
["ls", ["-la", "-l"]],
]);
function validateCommand(command: string, args: string[]): void {
const parts = command.split(" ");
const baseCmd = parts[0];
if (!ALLOWED_COMMANDS.has(baseCmd)) {
throw new Error(`不允许的命令: ${baseCmd}`);
}
const allowedArgs = ALLOWED_COMMANDS.get(baseCmd)!;
if (!allowedArgs.includes("*")) {
for (const arg of args) {
if (!allowedArgs.includes(arg) && !arg.startsWith("-")) {
throw new Error(`不允许的参数: ${arg}`);
}
}
}
// 禁止危险字符
const dangerousChars = /[;&|`$(){}[\]\\]/;
for (const arg of args) {
if (dangerousChars.test(arg)) {
throw new Error("参数包含危险字符");
}
}
}
4. 沙箱隔离
4.1 文件系统隔离
使用 chroot 或容器技术隔离文件系统访问:
import { spawn } from "child_process";
function spawnInSandbox(
command: string,
args: string[],
allowPath: string
) {
// 使用 firejail 或类似工具创建沙箱
const sandboxArgs = [
"--noprofile",
"--private",
allowPath,
"--quiet",
command,
...args,
];
return spawn("firejail", sandboxArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
}
4.2 容器化部署
将 MCP 服务器运行在 Docker 容器中是最实用的沙箱方案:
FROM node:20-alpine
# 创建非 root 用户
RUN addgroup -g 1001 -S mcp && adduser -u 1001 -S mcp -G mcp
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --chown=mcp:mcp . .
# 只暴露必要的目录
VOLUME ["/data"]
USER mcp
EXPOSE 3000
CMD ["node", "dist/server.js"]
# docker-compose.yml
version: "3.8"
services:
mcp-server:
build: .
read_only: true
user: "1001:1001"
volumes:
- type: bind
source: /allowed/data/path
target: /data
read_only: true
network_mode: none # 无网络访问
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
mem_limit: 256m
cpus: 0.5
5. 用户确认机制
5.1 确认策略
MCP 规范建议 Host 在执行可能产生副作用的操作前请求用户确认:
enum ConfirmationLevel {
NONE = "none", // 无需确认(只读操作)
INFORMATION = "info", // 通知用户但不阻塞
CONFIRM = "confirm", // 需要显式确认
DENY = "deny", // 直接拒绝
}
interface ToolConfirmationPolicy {
toolPattern: string;
level: ConfirmationLevel;
message?: string;
}
const DEFAULT_POLICIES: ToolConfirmationPolicy[] = [
{
toolPattern: "read_*",
level: ConfirmationLevel.NONE,
},
{
toolPattern: "write_*",
level: ConfirmationLevel.CONFIRM,
message: "这将修改文件系统,是否继续?",
},
{
toolPattern: "delete_*",
level: ConfirmationLevel.CONFIRM,
message: "这将永久删除数据,是否继续?",
},
{
toolPattern: "exec_*",
level: ConfirmationLevel.DENY, // 默认拒绝执行命令
},
];
5.2 确认对话框实现
class ConfirmationManager {
private pendingConfirmations: Map<string, Deferred<boolean>> = new Map();
private policies: ToolConfirmationPolicy[];
constructor(policies: ToolConfirmationPolicy[] = DEFAULT_POLICIES) {
this.policies = policies;
}
getConfirmationLevel(toolName: string): ConfirmationLevel {
for (const policy of this.policies) {
const regex = new RegExp(
"^" + policy.toolPattern.replace("*", ".*") + "$"
);
if (regex.test(toolName)) {
return policy.level;
}
}
return ConfirmationLevel.CONFIRM;
}
async requestConfirmation(
toolName: string,
args: Record<string, unknown>
): Promise<boolean> {
const level = this.getConfirmationLevel(toolName);
if (level === ConfirmationLevel.NONE) return true;
if (level === ConfirmationLevel.DENY) return false;
const confirmationId = crypto.randomUUID();
const deferred = new Deferred<boolean>();
this.pendingConfirmations.set(confirmationId, deferred);
// 发送确认请求到 UI 层
emitConfirmationRequest({
id: confirmationId,
tool: toolName,
args,
message: this.getConfirmationMessage(toolName),
});
return deferred.promise;
}
resolveConfirmation(id: string, approved: boolean): void {
const deferred = this.pendingConfirmations.get(id);
if (deferred) {
deferred.resolve(approved);
this.pendingConfirmations.delete(id);
}
}
}
// 简单 Promise 包装器
class Deferred<T> {
promise: Promise<T>;
resolve!: (value: T) => void;
reject!: (reason: unknown) => void;
constructor() {
this.promise = new Promise<T>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
6. 审计日志
完善的审计日志是安全事件调查和责任追踪的基础:
interface AuditLogEntry {
timestamp: string;
sessionId: string;
toolName: string;
arguments: Record<string, unknown>;
result: "success" | "failure" | "denied";
errorMessage?: string;
executionTimeMs: number;
userId?: string;
clientIp?: string;
}
class AuditLogger {
private logStream: WritableStream;
constructor(logPath: string) {
this.logStream = fs.createWriteStream(logPath, { flags: "a" });
}
async log(entry: AuditLogEntry): Promise<void> {
const line = JSON.stringify(entry) + "\n";
this.logStream.write(line);
}
async logToolCall(
sessionId: string,
toolName: string,
args: Record<string, unknown>,
result: "success" | "failure" | "denied",
executionTimeMs: number,
error?: Error
): Promise<void> {
const entry: AuditLogEntry = {
timestamp: new Date().toISOString(),
sessionId,
toolName,
arguments: this.sanitizeArgs(args),
result,
errorMessage: error?.message,
executionTimeMs,
};
await this.log(entry);
}
// 移除或脱敏敏感参数
private sanitizeArgs(args: Record<string, unknown>): Record<string, unknown> {
const SENSITIVE_KEYS = ["password", "token", "secret", "apiKey", "key"];
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
if (SENSITIVE_KEYS.some((sk) => key.toLowerCase().includes(sk))) {
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = value;
}
}
return sanitized;
}
}
7. 速率限制
防止 AI 被诱导进行高频工具调用造成资源耗尽:
class RateLimiter {
private limits: Map<string, { count: number; resetTime: number }> = new Map();
constructor(
private maxCallsPerMinute: number = 60,
private maxCallsPerHour: number = 500
) {}
async checkLimit(toolName: string): Promise<boolean> {
const now = Date.now();
const key = `${toolName}`;
const entry = this.limits.get(key);
if (!entry || now > entry.resetTime) {
this.limits.set(key, {
count: 1,
resetTime: now + 60000, // 1 分钟窗口
});
return true;
}
if (entry.count >= this.maxCallsPerMinute) {
return false;
}
entry.count++;
return true;
}
async acquire(toolName: string): Promise<void> {
const allowed = await this.checkLimit(toolName);
if (!allowed) {
throw new Error(`工具 ${toolName} 调用频率超限,请稍后重试`);
}
}
}
8. 安全清单
部署 MCP 服务器前的安全检查清单:
输入验证:
- 所有工具参数使用 JSON Schema 严格验证
- 文件路径参数经过规范化、权限边界和目录遍历检查
- SQL 查询使用参数化绑定,禁止字符串拼接
- 命令执行参数经过白名单过滤
权限控制:
- 服务器使用最小权限用户运行
- 文件系统访问限制在配置的允许路径内
- 网络访问默认关闭,仅开放必要端口和主机
运行时保护:
- 容器化部署并设置资源限制(CPU、内存)
- 启用 read_only 根文件系统
- 丢弃所有不必要的 Linux capabilities
监控审计:
- 所有工具调用记录审计日志
- 敏感参数在日志中脱敏
- 异常调用模式触发告警
用户体验:
- 可能产生副作用的操作要求用户确认
- 删除、修改操作有明确的警告提示
- 支持操作的撤销或回滚
9. 总结
MCP 安全的核心是多层防护、纵深防御:
| 层次 | 措施 | 防御目标 |
|---|---|---|
| 输入层 | Schema 验证 + 消毒 | 注入攻击 |
| 权限层 | 最小权限 + 白名单 | 越权访问 |
| 执行层 | 容器沙箱 | 系统级破坏 |
| 交互层 | 用户确认 | 意外副作用 |
| 监控层 | 审计日志 + 限流 | 滥用检测 |
安全不是一次性配置,而是贯穿 MCP 服务器设计、开发、部署和运维全生命周期的持续实践。开发者应该在功能和安全性之间找到平衡,让 AI 工具既强大又可控。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。