别再把 MCP Roots 当安全沙箱:一个符号链接就能逃逸目录
我最近重新检查了一个 MCP 文件服务器实现。
客户端已经让用户选择了工作区,Server 也拿到了 Roots。表面上看,模型只能操作这个目录,边界似乎已经够清楚。
真正做路径测试时,问题马上出来了:
javascript
workspace/
├── docs/
├── drafts/
└── latest-log -> /Users/me/.ssh/
请求读取的参数只有:
lua
latest-log/config
输入里没有 ../,也不是绝对路径,但符号链接解析后,真实目标已经离开工作区。
这也是 MCP 文件服务器最容易被误解的地方:
Roots 是客户端声明的候选边界,不是 Server 已经完成的安全沙箱。
如果 Server 只检查原始字符串,或者只判断客户端是否提供了 Root,这次读取仍可能被放行。
一个安全请求应该经过哪些检查
更准确的边界关系应该是:
arduino
用户选择可访问目录
↓
Client 通过 Roots 声明候选边界
↓
拒绝绝对路径
↓
resolve / realpath 解析 .. 与符号链接
↓
校验真实路径仍属于授权 Root
↓
检查敏感目录、文件类型、大小和读写策略
↓
Resource / Tool 权限控制
↓
低权限用户或容器形成最后物理边界
Roots 能告诉 Server 当前工作区在哪里,但它不会自动替你完成这些事:
- 拒绝绝对路径;
- 消解
..; - 解析符号链接;
- 阻止读取
.ssh、.env和凭据目录; - 限制文件大小和返回长度;
- 限制写入只能进入草稿目录;
- 记录谁调用了哪个 Tool、操作了什么路径。
这些仍然是 Server 的职责。
为什么字符串前缀判断不可靠
不少实现会写成这样:
python
from pathlib import Path
ROOT = Path("./workspace").resolve()
def unsafe_path(user_path: str) -> Path:
target = ROOT / user_path
if not str(target).startswith(str(ROOT)):
raise ValueError("path escaped")
return target
这段代码至少有两个问题。
第一,拼接后没有先解析真实路径。符号链接可能把目标带到 Root 外部。
第二,字符串前缀不是目录归属判断。例如 /workspace-safe 也可能通过 /workspace 的前缀检查。
我更倾向于把路径校验收敛成一个独立函数,所有 Resource 和 Tool 都必须走同一入口:
python
from pathlib import Path
class PathRejected(ValueError):
pass
def resolve_inside(root: Path, relative_path: str, *, must_exist: bool) -> Path:
candidate = Path(relative_path)
if candidate.is_absolute():
raise PathRejected("absolute paths are not allowed")
target = (root / candidate).resolve(strict=must_exist)
try:
target.relative_to(root)
except ValueError as exc:
raise PathRejected("path escapes the allowed root") from exc
return target
关键不在代码有多长,而在检查顺序:
bash
只接受相对路径
→ resolve / realpath
→ relative_to 判断真实目录归属
→ 检查敏感目录、类型和大小
→ 再执行读写
不要看起来安全,要用攻击路径验证
先准备一个正常文件和一个指向 Root 外部的符号链接:
bash
mkdir -p sandbox/docs
printf "ok" > sandbox/docs/readme.md
ln -s ~/.ssh sandbox/latest-log
然后跑四条路径:
python
ROOT = Path("./sandbox").resolve()
cases = [
"docs/readme.md",
"../.ssh/config",
"/etc/passwd",
"latest-log/config",
]
for relative_path in cases:
try:
target = resolve_inside(ROOT, relative_path, must_exist=False)
print(f"ALLOWED {relative_path} -> {target}")
except PathRejected as exc:
print(f"REJECTED {relative_path} -> {exc}")
预期结果应该是:
| 输入 | 结果 | 原因 |
|---|---|---|
docs/readme.md |
ALLOWED | 真实路径仍在 Root 内 |
../.ssh/config |
REJECTED | 规范化后越过 Root |
/etc/passwd |
REJECTED | 绝对路径 |
latest-log/config |
REJECTED | 符号链接解析后指向 Root 外部 |
如果第四条仍然被放行,说明实现只检查了字符串,没有检查真实路径。
还要注意一个更隐蔽的问题:resolve() 与真正打开文件之间存在时间窗口。高风险、多用户或存在恶意本地进程的场景,仍要考虑 TOCTOU 竞态。
更稳的做法是继续使用目录文件描述符、openat/dir_fd、O_NOFOLLOW 或容器只读挂载,把校验和打开尽量绑定在同一权限边界内。
Resource、Tool、Prompt 不要混成一个万能接口
另一个常见问题,是为了省事注册三个能力过大的 Tool:
scss
read_any_file(path)
write_any_file(path, content)
run_command(command)
它们开发起来很快,但模型一旦生成错误参数,影响范围也是最大的。
更稳的拆法是:
arduino
Resource
读取可标识、相对稳定的上下文
例如 README、配置快照、日志片段
Tool
执行查询、计算或有副作用的动作
例如搜索 Markdown、写入草稿、生成补丁
Prompt
用户主动选择的任务模板
例如"检查当前配置"或"总结指定日志"
Root
Client 告知 Server 的文件系统候选边界
对应到接口,可以收窄成:
scss
search_markdown(query, limit)
read_text(relative_path, max_chars)
write_draft(relative_path, content)
propose_patch(relative_path, patch)
写正式内容、删除文件、移动目录和执行命令,不应该因为"已经接入 MCP"就默认开放。
stdio 还有一个非常容易踩的坑
stdio 模式下,stdout 是协议通道。
下面两行普通日志都可能让客户端报 -32700 Parse error:
bash
print("server started")
arduino
console.log("database connected")
业务日志应该显式写入 stderr 或独立日志文件。
不要为了拦截第三方输出,直接覆盖 process.stdout.write,也不要粗暴执行 sys.stdout = sys.stderr。MCP SDK 自己也需要 stdout 返回合法消息,这种兜底可能把正常协议响应一起破坏。
本地 stdio 和远程 Streamable HTTP 怎么选
当前 MCP 标准传输可以这样理解:
| 传输 | 适合场景 | 重点风险 |
|---|---|---|
| stdio | 本地 IDE、桌面客户端、单用户进程 | stdout 污染、PATH、工作目录、子进程权限 |
| Streamable HTTP | 跨机器、团队共享、云端服务 | 认证、Origin、Session ID、代理、超时、审计 |
远程部署不是把本地 Server 监听到 0.0.0.0 就结束了。至少还要处理:
- Origin 校验;
- 身份认证和授权范围;
- Session ID;
- 协议版本;
- 超时、限流和反向代理;
- 审计日志和密钥管理。
如果团队连身份、限流、密钥管理和审计都没有准备好,传统 REST API 往往比直接把 MCP 暴露到公网更稳。
上线前检查这 10 项
- 只接受相对路径。
- 使用
resolve或realpath消解..和符号链接。 - 用真实目录归属判断,不使用字符串前缀判断。
- 默认只读,写入、删除和移动拆成独立 Tool。
- 写入只允许进入
drafts/或临时目录。 - 限制扩展名、文件大小、返回字符数和目录列表数量。
- 明确拦截
.ssh、.gnupg、.aws、.env、私钥和凭据目录。 - 审计 Tool、相对路径、操作者、结果、耗时和拒绝原因。
- 错误响应不暴露服务器绝对路径和完整堆栈。
- 使用低权限系统用户或容器运行 Server。
最后可以把整个问题压缩成一句话:
Client 负责告诉 Server"用户允许操作哪里",Server 负责保证"每一次操作都没有离开那里"。
完整版本还包含 FastMCP 文件网关代码、Resources/Tools/Prompts/Roots 的边界、Streamable HTTP 部署要求和上线验收清单: