当大语言模型通过 Model Context Protocol(MCP)获得调用本地工具的能力时,文件系统操作往往是第一个被接入的能力。很多团队在写 MCP Server 工具时,习惯性地直接拿 Node.js 的path.join(baseDir, userInputPath)拼接路径,接着调用fs.readFile读取内容并返回给模型。这种实现只要跑在真实的生产或内网环境里,就是一个高危的沙箱逃逸漏洞。
大模型生成参数具有不可控性。当模型受到 Prompt 注入攻击,或者推理时陷入混乱,它完全可能传入../../../../etc/passwd、~/.ssh/id_rsa甚至带有 Windows 盘符的绝对路径。更隐蔽的情况是,工作区内可能存在指向系统关键目录的软链接(symlink),简单的字符串匹配在软链接面前会彻底失效。
为了让 MCP Server 能安全地跑在宿主机或受控容器内,必须在 MCP 协议层与系统文件调用之间,建立一套严密的目录白名单校验与路径归一化防护机制。
路径逃逸的三个典型隐蔽陷阱
很多开发者以为写了路径防御,但防御逻辑往往漏洞百出。常见漏洞模式主要集中在以下三个层面。
第一是字符串前缀判断漏洞。不少人会写出类似这样的代码:
// 错误示例:存在前缀混淆漏洞 const isSafe = targetPath.startsWith(allowedDir);假设allowedDir是/data/workspace,如果攻击者或者异常参数构造了/data/workspace_secret/keys.txt,上述判断依然返回true。因为没有对目录边界符进行对齐,前缀匹配直接把同名前缀的其他目录也放行了。
第二是未解析符号链接(Symlink)。操作系统的软链接可以跨越目录层级。如果在允许访问的目录内存在一个指向/或用户主目录的软链接,只做常规的path.resolve只能得到软链接本身的逻辑路径,而底层的系统调用会直接顺着软链接读取目标文件。
第三是编码混淆与空字节截断。虽然现代 Node.js 运行时已经对 null-byte 注入(\0)做了底层防御,但 URI 编码变体(如%2e%2e%2f)、双重斜杠以及相对路径嵌套,依然可能在参数解析阶段绕过过于简陋的正则过滤器。
安全路径解析的核心算法
生产级的文件路径校验不能依赖黑名单过滤,必须执行严格的“白名单目录限定 + 物理路径真实化(Canonicalization)”。
防御流程可以拆解为四步:
- 基础规范化:利用
path.resolve将用户传入的路径转为基于工作区根目录的绝对逻辑路径。 - 物理路径求真:使用
fs.promises.realpath跟踪并解析所有符号链接,获取文件在磁盘上的真实绝对物理路径。若目标文件尚不存在(例如执行写操作或创建文件),则向上解析其已存在的父级目录。 - 边界符严格对齐:验证真实物理路径是否严格落在白名单目录集合之内,白名单目录末尾必须补齐系统的路径分隔符(如 POSIX 的
/)。 - 权限与状态校验:验证最终文件是否属于常规文件(regular file),拦截试图读取设备文件(如
/dev/urandom)或命名管道的非法操作。
完整的安全 MCP Server 实现
基于@modelcontextprotocol/sdk与 TypeScript,我们可以实现一个具备生产级防御能力的本地文件管理 MCP Server。
import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import * as path from "node:path"; import * as fs from "node:fs/promises"; interface ServerConfig { allowedDirectories: string[]; } export class SafeFileSystemServer { private server: Server; private allowedRoots: string[] = []; constructor(private config: ServerConfig) { this.server = new Server( { name: "safe-filesystem-mcp", version: "1.0.0", }, { capabilities: { tools: {}, }, } ); this.setupHandlers(); } // 初始化并校验白名单目录本身必须真实存在 public async initialize(): Promise<void> { const resolvedRoots: string[] = []; for (const rawPath of this.config.allowedDirectories) { try { const canonical = await fs.realpath(path.resolve(rawPath)); // 保证末尾带有路径分隔符,避免 /data/work 误匹配 /data/work2 const normalized = canonical.endsWith(path.sep) ? canonical : canonical + path.sep; resolvedRoots.push(normalized); } catch (error) { console.error(`[Init Warning] 白名单目录无法解析,已忽略: ${rawPath}`, error); } } if (resolvedRoots.length === 0) { throw new Error("没有任何有效的白名单根目录,服务拒绝启动"); } this.allowedRoots = resolvedRoots; } // 核心路径校验守卫 private async validateAndResolvePath(userPath: string, mustExist = true): Promise<string> { if (!userPath || typeof userPath !== "string") { throw new McpError(ErrorCode.InvalidParams, "路径参数必须为非空字符串"); } // 针对相对路径,使用第一个合法根目录作为锚点进行初步解析 const baseAnchor = this.allowedRoots[0]; const preliminaryPath = path.isAbsolute(userPath) ? path.resolve(userPath) : path.resolve(baseAnchor, userPath); let realTarget: string; try { realTarget = await fs.realpath(preliminaryPath); } catch (err: unknown) { const nodeErr = err as NodeJS.ErrnoException; if (nodeErr.code === "ENOENT" && !mustExist) { // 文件不存在但属于待新建情况,追溯父级目录的真实物理路径 const parentDir = path.dirname(preliminaryPath); const realParent = await fs.realpath(parentDir); realTarget = path.join(realParent, path.basename(preliminaryPath)); } else { throw new McpError(ErrorCode.InvalidParams, `目标路径无法访问或不存在: ${userPath}`); } } // 比对白名单前缀 const isAllowed = this.allowedRoots.some((allowedPrefix) => { // 若目标是目录,补齐尾部斜杠判断;若是文件,直接检查是否以前缀开头 return realTarget.startsWith(allowedPrefix) || (realTarget + path.sep) === allowedPrefix; }); if (!isAllowed) { throw new McpError( ErrorCode.InvalidParams, `越界访问被拒绝: 目标路径不在允许的白名单作用域内` ); } return realTarget; } private setupHandlers(): void { // 声明支持的工具列表 this.server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: [ { name: "read_secure_file", description: "在受控白名单工作区内安全读取指定文本文件内容", inputSchema: { type: "object", properties: { filePath: { type: "string", description: "文件相对路径或白名单内的绝对路径", }, }, required: ["filePath"], }, }, ], }; }); // 处理工具调用分发 this.server.setRequestHandler(CallToolRequestSchema, async (request) => { if (request.params.name === "read_secure_file") { const filePathParam = String(request.params.arguments?.filePath ?? ""); const verifiedPath = await this.validateAndResolvePath(filePathParam, true); const stat = await fs.stat(verifiedPath); if (!stat.isFile()) { throw new McpError(ErrorCode.InvalidParams, "目标路径不是标准文件,拒绝读取"); } const content = await fs.readFile(verifiedPath, "utf-8"); return { content: [ { type: "text", text: content, }, ], }; } throw new McpError(ErrorCode.MethodNotFound, `未识别的工具名称: ${request.params.name}`); }); } public async start(): Promise<void> { await this.initialize(); const transport = new StdioServerTransport(); await this.server.connect(transport); console.error("[SafeFileSystemServer] Stdio 传输已建立,正在监听模型调用请求..."); } } // 启动服务示例 const server = new SafeFileSystemServer({ allowedDirectories: [ process.env.WORKSPACE_ROOT || "./safe_workspace" ], }); server.start().catch((err) => { console.error("MCP Server 启动失败:", err); process.exit(1); });拦截实测与安全边界验证
把上述代码跑起来后,可以用具体的攻击样本做针对性检验:
- 相对路径逃逸:传入
../../../../etc/passwd。path.resolve会在最开始把相对符号折叠,计算出系统的/etc/passwd,但在后续检查allowedRoots时无法命中前缀,直接抛出越界访问被拒绝,请求在文件读取发生前被熔断。 - 同名前缀仿冒:允许目录为
/project/app,传入/project/app_backup/secret.env。因为初始化时allowedRoots强制补全了末尾路径分隔符变成/project/app/,所以以/project/app_开头的路径无法通过startsWith验证。 - 软链接越界:在
/project/app/目录下通过ln -s /etc symlink_etc创建指向外部的软链接,并让模型请求symlink_etc/hosts。fs.realpath会在操作系统底层追踪这个链接并还原出/etc/hosts,随后由于前缀校验失败而被稳稳拦截。
运行环境的加固配置
代码层面的防御是第一道防线,但如果把 MCP Server 直接暴露在裸机 root 权限下运行,一旦 Node.js 运行时本身爆出漏洞,依然存在隐患。因此在部署时需要配合几项系统级原则:
- 运行用户降权:使用低权限系统账号(如
nobody或专用的mcp_runner)启动进程,禁止对工作区外的任何系统目录赋予写权限。 - 容器只读挂载:在 Docker 或 Podman 中启动时,除了明确需要写入的目录采用临时卷挂载外,其他应用文件一律使用
:ro(只读)挂载。 - 文件大小配额:在读取文件时限制最大 Buffer 尺寸(例如单次限制不超过 10MB),避免模型误读取数 GB 的日志文件直接打爆 Node.js 堆内存导致服务崩溃。
把防御收拢在底层工具层,大模型怎么推理、怎么犯错,都不会击穿系统的安全边界。这才是真正能让自动化 Agent 在生产环境中放手干活的前提。