Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

security(mcp): flow_get_skill_reference 路径穿越——MCP 工具可按任意路径(含绝对路径)读宿主文件

Closed Beginner friendly
#54 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
64/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
python
Domain
cli, security

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 文档读取工具,等价于挂在宿主机上的任意
    文件读取接口,且无任何拦截或记录点。

建议

  1. 在 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 同样是拼接进路径的,需一并校验为单段名(无 /、非绝对)。
  2. 最小修法(一行级):函数首行加 if os.path.isabs(ref_name) or ".." in PurePosixPath(ref_name).parts: raise FileNotFoundError(...),可立即挡住本单实测的两种
    逃逸;但白名单/resolve() 版本更稳(能覆盖符号链接与 Windows 盘符形式)。
  3. MCP 层护栏:mcp/server.py 的 flow_get_skill_reference 可加同款校验(或在
    reference 的 schema 上限定为枚举/正则),使工具在入参阶段就给出可读错误。
  4. 验收条件:
    • 新增测试 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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from jeffkit/plaita

All issues in jeffkit/plaita

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.