分析 MCP 服务器继承高权限 Token 带来的安全风险,指出仅依赖「工具不调用写操作」而不做权限隔离的隐患及修复方案。
我的 MCP Server 的 GitHub Token 可以写代码。承诺它永远不会写的代码没有测试。
我的 MCP Server(developer-presence,就是那个让 Claude 能够查看我的 GitHub Profile 并管理我的 DEV.to 帖子的工具)目前只有三个 GitHub 工具:get_github_profile、list_repos、get_repo_stats。三个都是读数据,没有一个会创建、更新或删除任何东西。
它们背后的 GITHUB_TOKEN 并没有被限制成只读。我的 key_facts.md 里写得清清楚楚:
Token scopes needed: `repo`, `user` — add `delete_repo` if repo deletion
via API is required, add `workflow` if you'll ever push a branch that
pulls in upstream `.github/workflows/*.yml` changes
repo 是 GitHub 的全控制权限——它可以推送提交、编辑文件、修改仓库设置,除了彻底删除什么都能干。给它这个权限是因为项目的其他部分(git 写入那一侧,不是 MCP Server)需要它。MCP Server 只是继承了同一个 .env 文件,连带着用了同一个 token。
所以这个 server 发出的每一次调用都带着写权限在跑,虽然它根本不打算写任何东西。"从不打算写"和"实际上不能写"之间只隔了一个函数。
def _gh(path, method="GET", data=None):
# No GitHub tool in this server writes anything — GITHUB_TOKEN is scoped
# `repo, user` (full write access, see key_facts.md), so a stray
# method="POST"/"DELETE" here would be a real write, not a hypothetical
# one. Enforced, not just true by convention. See bugs.md 2026-07-30.
if method != "GET":
raise ValueError(f"_gh is read-only — got method={method!r}")
if data is not None:
raise ValueError("_gh is read-only — got a data payload on a GET call")
token = os.environ.get("GITHUB_TOKEN")
if not token:
raise RuntimeError("GITHUB_TOKEN not set — add it to .env next to server.py")
req = urllib.request.Request(f"https://api.github.com{path}", method=method)
req.add_header("Authorization", f"token {token}")
...
三个 GitHub 工具都通过 _gh() 路由,且没有一个传过 method= 或 data=。这就是全部的约束机制:一行 if 把"这个文件恰好只调用了 GET"变成了"这个文件除了 GET 什么都调不了"。我在 2026-07-30 加了这个守卫,就是专门为了防止未来的某个工具——create_repo、star_repo、或者以后随便什么被加进来的写操作——在悄无声息中开始使用这个 token 本身拥有、但这个 server 本不该碰的写权限。
注释里甚至专门点名了:"Enforced, not just true by convention."(强制执行,不是惯例上为真。)这行字是我自己写的,就在七天前,我信了。
这次跑 selftest 实际发现的问题
server.py 里有一个 --selftest 块,从七月底开始不断扩充——这个文件里修的每一个 bug 都在同一个 run 里有一句回归用例,这样它就不能悄悄卷土重来。目前覆盖的有: attribution-stripping 正则、list_repos 的负数 limit 处理、create_article 的分页遍历,以及 _gh()/_dev() 缺失凭证的路径。
但它没有覆盖只读守卫。我去找的时候以为它肯定在——这是安全相关的守卫,它的全部职责就是在给写权限开了门的 token 和实际写操作之间站岗——结果不在。这个文件里从来没有调用过 _gh(path, method="POST") 然后断言它会炸掉。
这意味着注释里的承诺从未被任何东西真正检验过,除了我自己去读了下面四行。以后某次编辑里把 if method != "GET": 这行检查删掉——一次合并冲突解决错了、一句"快速加个写工具"的 PR 忘了守卫的存在——selftest 还是会打印 selftest ok。这个回归只有等到某个调用方真的传了一个非 GET 方法、然后它真的被发到 GitHub 去了,才会暴露。
我验证了这不是杞人忧天,在不触网的情况下隔离复现了它:
try:
_gh("/users/x", method="POST")
print("no exception raised") # this is what a missing guard looks like
except ValueError as e:
print("guard fired:", e)
守卫在的时候:guard fired: _gh is read-only — got method='POST'。在本地把那个 if 注释掉再跑,就得到 no exception raised——请求就会带着 Authorization: token <full-scope-token> 发出去了,还是 POST。
两条断言,加在同一个 selftest 块里其他凭证路径测试旁边:
try:
_gh("/users/x", method="POST")
assert False, "_gh must reject a non-GET method, not silently send it"
except ValueError as e:
assert "read-only" in str(e), e
try:
_gh("/users/x", data={"a": 1})
assert False, "_gh must reject a data payload, not silently attach it to a GET"
except ValueError as e:
assert "read-only" in str(e), e
跑完整个块之后(stub 了 mcp 包的导入,因为这个沙盒没法干净地对着系统的 PyJWT 安装它——这是另一个烦心事):selftest ok,所有已有用例依然通过,而且这两条新加的真正去跑了注释曾经担保过的那行代码。
为什么这件事值得记下来
我在这个项目里已经写过了几篇关于缺失 except 子句和缺失分页的帖子——都是纯粹的正确性 bug。这一篇不同。这次的守卫从头到尾都是正确的;没有任何东西坏了。缺的是证明它持续正确的依据。一个最小权限的约束机制,注释声称它是"强制执行,不是惯例上为真",但零行测试覆盖——实际上,它恰恰就是注释里说的那种"惯例上为真"的东西——只是 PR 做得更好。
如果你的 MCP Server(或者任何工具调用代码)持有的凭证权限范围比代码路径实际需要的更宽——去检查一下你项目里的 key_facts.md 等效文件,因为我的那份就在那儿用大白话躺了好几周了——那个用来约束更窄行为的守卫代码,值得和实现功能的代码受到同等的测试对待。一道没有人能在 selftest 不注意的情况下打破的守卫,才算守卫。一道只有人类重读四行字才能担保的守卫,只是一条注释。