security(mcp): flow_get_skill_reference 路径穿越——MCP 工具可按任意路径(含绝对路径)读宿主文件
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 64/100
Research direction
Start with get_skill_reference in plaita-ai/plaita_ai/flow_runner.py (around lines 186-198) and reproduce with the abs-path and ../ cases from the issue. Add a single validation at the top of the function that rejects absolute ref_name and resolves the joined path, asserting it stays under the references base directory, and raise FileNotFoundError on escape. Also check skill_name as a single segment. Done when tests in plaita-ai/tests/test_mcp_integration.py show both escape cases raising FileNotFoundError and the default flow_get_skill_reference() output is unchanged.
Written by the indexing model from the issue text.
Description
优先级 P0 · 依赖:无
基线 HEAD = 65f48f2
Summary
get_skill_reference(skill_name, ref_name) 用调用方传入的字符串直接拼路径,既不看
ref_name 是否越出 references 目录,也不在读取前做任何归一化与基线校验。两个入口
(MCP 工具、CLI 子命令)都把这个字符串当不受信输入直通。
1)拼接处 —— 库内没有路径基线校验(plaita-ai/plaita_ai/flow_runner.py:186-198):
def get_skill_reference(skill_name: str, ref_name: str) -> str:
"""Load a bundled skill reference file."""
package = "plaita_ai.skills"
path = f"{skill_name}/references/{ref_name}"
try:
return resources.files(package).joinpath(path).read_text(encoding="utf-8")
except (FileNotFoundError, OSError, TypeError):
pass
here = Path(__file__).resolve().parent / "skills" / skill_name / "references" / ref_name
if here.is_file():
return here.read_text(encoding="utf-8")
raise FileNotFoundError(f"skill reference not found: {skill_name}/{ref_name}")
两处都是「拼串后直接读」:第 195 行的 pathlib 用 / 组合时,右操作数为绝对路径会
整体替换左侧前缀,.. 段不做归一化;第 196-197 行只做 is_file() 就裸读,
没有任何 resolve() / is_relative_to() / 白名单。
2)MCP 工具入口直通 —— 无参数校验、无包装(plaita-ai/plaita_ai/mcp/server.py:215-221):
@mcp.tool()
def flow_get_skill_reference(
skill_name: str = "flow-coder",
reference: str = "codeflow-reference.md",
) -> str:
"""Return a bundled skill reference document (full @flow syntax)."""
return get_skill_reference(skill_name, reference)
3)CLI 入口同样任意串直通(plaita-ai/plaita_ai/cli/main.py:74-76 与 :189-190):
def cmd_skill(args: argparse.Namespace) -> int:
if args.reference:
text = get_skill_reference(args.skill, args.reference)
p_skill.add_argument("--skill", default="flow-coder")
p_skill.add_argument("--reference", default=None, help="e.g. codeflow-reference.md")
--reference 无 choices、无校验;调用点给出的说明文字也只是「e.g.」。
4)实测(本机实跑,Python 3.12,/tmp/plaita-audit-venv)。
$ cd /Users/jeff.jie/projects/infra4agent/plaita && PYTHONPATH=/tmp/mcpstub:plaita-ai /tmp/plaita-audit-venv/bin/python - <<'EOF'
from plaita_ai.mcp.server import flow_get_skill_reference
import os
out = flow_get_skill_reference(skill_name="flow-coder", reference="/etc/passwd")
print("ABS OK chars:", len(out), out.splitlines()[:2])
base = os.path.join(os.path.dirname(__import__("plaita_ai").__file__), "skills/flow-coder/references")
rel = os.path.relpath("/etc/hosts", base)
out2 = flow_get_skill_reference(skill_name="flow-coder", reference=rel)
print("REL OK chars:", len(out2), out2.splitlines()[:2])
print("NORMAL chars:", len(flow_get_skill_reference()))
EOF
ABS OK chars: 9344 ['##', '# User Database']
REL OK chars: 213 ['##', '# Host Database']
NORMAL chars: 10686
三行输出分别是:绝对路径 /etc/passwd(9344 字节,macOS 口令文件首两行)、相对穿越
../../../../../../../../../../etc/hosts(从 references 目录 10 层 ..,213 字节)、
以及默认调用的正常文档(10686 字节)。没有报错,读到的是宿主文件正文。
说明(复现的前提条件):本 venv 未装 mcp 包,故用 /tmp/mcpstub 里最小的
FastMCP stub 把 @mcp.tool() 还原为原函数——stub 只影响装饰器注册,不改变被调用
函数本体。这一限制同时验证了问题不依赖 MCP 协议层:缺陷在 get_skill_reference
自身,任何导入该函数的调用方都能触发。
5)反证:全仓无任何路径基线校验(is_relative_to / commonpath 在 plaita-ai 下零命中):
$ grep -rn "is_relative_to\|os.path.commonpath" --include='*.py' plaita-ai/
(零命中)
调用点普查(grep -rn "get_skill_reference" --include='*.py'):不受信入口只有
mcp/server.py:221(reference 参数直通)与 cli/main.py:76(--reference 任意串);
supervisor.py:337、agent/fot/planner.py:47/65、agent/react/tools.py:106 全部传硬编码
安全文件名(codeflow-reference.md / authoring-spec.md)。MCP 工具面也无 _guarded
类包装(grep -c "_guarded" plaita-ai/plaita_ai/mcp/server.py → 0;该装饰器只存在于
ops_mcp.py)。
6)该工具是硬依赖入口,非可选(plaita-ai/pyproject.toml:13-20):
dependencies = [
"plaita>=0.5.0",
# Console ops plane (supervisor data plane) + LLM judge/proposer calls.
"httpx>=0.24",
# 上界必须 pin:mcp 2.x 把 FastMCP 改名为 MCPServer,`plaita-ai mcp`
# 在全新安装下直接 ImportError(2026-09 盲区评审 P0-1)。
"mcp>=1.0,<2",
]
[project.scripts] 提供 plaita-ai = "plaita_ai.cli.main:main",plaita-ai mcp 即启动该
MCP 服务;仓内导航(plaita/AGENTS.md)也把「运行期经 plaita-ai MCP
flow_get_skill(...) 拉取 skill」写为正式用法。
影响
- 谁踩到:任何把
plaita-ai mcp挂给 agent 的部署——agent 侧 LLM 只要生成一个
reference="/etc/passwd"或含..的字符串(可以是被注入的提示词诱导的),宿主上
该进程可读的任意文件就会作为工具返回值回传,内容随后进入模型上下文与其日志/
会话留存。CLI 侧同理:能执行plaita-ai skill --reference ...的人可读同一批文件。 - 代价:跨边界的信息泄露(越权读),且宿主用户权限下可读即所得——
~/.ssh/、
云凭据、.env、/etc/passwd等一律无差别。读取本身不留痕、无审计日志,事后不可观测。 - 生产可达:可达,无需特殊前置条件。MCP 入口随
plaita-ai[all]/基础依赖一并安装
(mcp 是硬依赖,见上),不需要额外开关;缺陷在纯 Python 层,不需要 MCP 客户端配合。 - 不修的后果:一个面向不受信调用的 skill 文档读取工具,等价于挂在宿主机上的任意
文件读取接口,且无任何拦截或记录点。
建议
- 在
flow_runner.py收敛读取逻辑:对ref_name先PurePosixPath(ref_name)检查
is_absolute()并拒绝;再(base / ref_name).resolve()后断言
resolved.is_relative_to(base.resolve()),越界一律抛FileNotFoundError(保持现有
异常类型,调用方无需改)。两个分支(importlib.resources与 editable fallback)都要
过同一道校验——把校验放在函数入口检查ref_name更省事:一处拦住两条路径。注意
skill_name同样是拼接进路径的,需一并校验为单段名(无/、非绝对)。 - 最小修法(一行级):函数首行加
if os.path.isabs(ref_name) or ".." in PurePosixPath(ref_name).parts: raise FileNotFoundError(...),可立即挡住本单实测的两种
逃逸;但白名单/resolve()版本更稳(能覆盖符号链接与 Windows 盘符形式)。 - MCP 层护栏:
mcp/server.py的flow_get_skill_reference可加同款校验(或在
reference的 schema 上限定为枚举/正则),使工具在入参阶段就给出可读错误。 - 验收条件:
- 新增测试
plaita-ai/tests/test_mcp_integration.py(或 flow_runner 单测)断言
get_skill_reference("flow-coder", "/etc/passwd")抛FileNotFoundError;
get_skill_reference("flow-coder", "../../../../../../../../../../etc/hosts")同样抛
FileNotFoundError;flow_get_skill_reference()的默认调用仍返回正文且内容与
codeflow-reference.md逐字相同。 - 复现脚本按
PYTHONPATH=/tmp/mcpstub:plaita-ai /tmp/plaita-audit-venv/bin/python
重跑后,输出应从「读到大字节数内容」变成两行异常,且NORMAL chars保持不变。
- 新增测试
边界
- 只修
get_skill_reference的路径收敛与其两个入口,不动get_skill_text(其
skill_name直拼路径是同类相邻问题,另一处代码、另开单);不顺手补全mcp/server.py
其他工具的错误包装(转_guarded)与skill_name的枚举化,均属相邻重构。 - 本条是任意文件读,不涉及写盘/执行:不扩写成 RCE 或 jail 话题。写侧与执行侧的同类
缺口(节点级 writefile jail、code 节点沙箱白名单等)各有独立单,与此不同代码路径。 - 查重:
gh issue list -R jeffkit/plaita --state all --limit 100现有 33 条中无一条命中
flow_get_skill_reference/get_skill_reference/ MCP skill 引用路径;最近的
security 条目是 #29/#39(code 节点沙箱与 writefile jail),代码与修法均不同,不判重复。 - 当前无任何守卫挡住触发,本单为生产可达缺陷,不是「潜伏缺陷」。
- Dominant language
- Python
- Stars
- 0
- Forks
- 1
- Avg merge
- 2h 22m
- Merged PRs (30d)
- 8
Getting set up
This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from jeffkit/plaita
-
fix(console): #274 并发同 dedup_key 双 POST 各自入队——预认领与占位行非原子,落败 id 永久 queued 且占配额Possibly taken @okguitar claimed this today. Open
Difficulty 5/5 Over a week Newbie friendliness 35/100
jeffkit/plaita#280 · 1 comment ·
Maintainers usually reply within 1 day
-
Difficulty 4/5 3-5 days Newbie friendliness 45/100
Maintainers usually reply within 1 day
-
fix(console): ${redis.url} 从配置文件而非数据面 effective URL 解析——metadata 档 worker 静默连 localhost,warnings 为空Open
Difficulty 4/5 3-5 days Newbie friendliness 48/100
Maintainers usually reply within 1 day
-
Difficulty 3/5 1-2 days Newbie friendliness 55/100
Maintainers usually reply within 1 day
-
refactor(expr): 摘除运行期字符串的二次求值(#104/#130 的终章)——契约定为「配置求值、运行期数据」Possibly taken @jeffkit claimed this today. Open
Difficulty 5/5 Over a week Newbie friendliness 25/100
jeffkit/plaita#268 · 1 comment ·
Maintainers usually reply within 1 day
Similar issues
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
yunaremaia/proto-drift#22 ·
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
yunaremaia/memwatch#96 ·
-
bug good first issue
Difficulty 1/5 Under an hour Newbie friendliness 91/100
yunaremaia/ci-gate-watch#34 ·
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 85/100
yunaremaia/agent-capability-attestation#64 ·
Maintainers usually reply within 1 day
-
bug good first issue
Difficulty 1/5 Under an hour Newbie friendliness 86/100
yunaremaia/agent-call-graph#43 ·
Maintainers usually reply within 1 day