安全设计、巧妙之处与边界
本章讲什么: registry 多处「服务端按用户提供的域名/镜像去发请求」,这是攻击者最爱的 SSRF 入口。本章讲它怎么防,再总结巧妙之处、边界和横向对比。
1. SSRF:为什么这个项目格外危险
回忆几处「服务端主动外连」:
- HTTP 握手:去
https://<用户给的域名>/.well-known/mcp-registry-auth取公钥。 - 包归属校验:去 npm/OCI/PyPI 拉元数据。
SSRF(Server-Side Request Forgery,服务端请求伪造):攻击者让服务器替自己去访问它本不该碰的地址——尤其是内网(169.254.169.254 云元数据、Kubernetes API、内部管理面板)。HTTP 握手只要你给个域名服务器就去连,是教科书级的 SSRF 靶子。
2. 三层防护(以 HTTP 握手为例)
防护集中在 internal/api/handlers/v0/auth/http.go 和 common.go。
第 1 层:拒绝非域名 / 内网域名
IsValidDomain(common.go:385):拒 IP 字面量(net.ParseIP != nil)——「这套方法证明的是域名所有权,不是 IP 所有权」;还要求至少有一个点,挡掉 localhost、kubernetes 这种只在内网解析的单标签名。
第 2 层:自定义 dial,拉黑内网 IP + DNS pinning
核心在 safeDialContext(http.go:67)。两个精妙点:
(a) 先解析,再逐个 IP 查黑名单后才拨号。 isBlockedIP(http.go:155)覆盖面极广:
loopback (127/8, ::1) link-local (169.254/16 ← 云元数据!, fe80::/10)
RFC1918 私网 + ULA multicast / unspecified
CGNAT 100.64.0.0/10 IPv6 里隐藏的 IPv4 通道(6to4/NAT64/site-local)
注意 cgnatRange 和 blockedIPv6Prefixes(http.go:126/:139)是 Go 标准库 Is* 判不出来的范围,作者手动补上——比如 NAT64 的 IPv6 地址低 32 位嵌着任意 IPv4,可被用来打内网。
(b) 解析一次,直接拨「解析出的 IP」,把连接 pin 住。 注释(http.go:60)讲透了原因:防 DNS rebinding——一种 TOCTOU 攻击,DNS 在「预检查」时返回公网 IP 骗过校验,在「真正拨号」时返回内网 IP。这里只解析一次、对同一个 IP 做检查和拨号,杜绝两次解析不一致。
// internal/api/handlers/v0/auth/http.go:92 safeDialContext(节选)
for _, ip := range ips {
if isBlockedIP(ip.IP) { continue } // 内网/危险 IP 跳过
// 直接拨「已检查过的这个 IP」,而不是再让 transport 自己解析一次域名
conn, dialErr := d.DialContext(dialCtx, network, net.JoinHostPort(ip.IP.String(), port))
if dialErr == nil { return conn, nil }
}
SNI/Host 头仍用原域名(由 transport 从 URL 设置),所以 TLS 证书校验照常。
第 3 层:禁重定向 + 限响应大小
NewDefaultHTTPKeyFetcher(http.go:37):
CheckRedirect返回ErrUseLastResponse禁掉重定向——否则攻击者可以让公网 URL 302 跳到内网路径。- 响应体
io.LimitReader到MaxKeyResponseSize = 4096(http.go:19/:203),超了报错——防一个超大 body 把内存撑爆(DoS)。
DNS 握手则对 lookup 加 5 秒超时(dns.go:87)防慢 DNS 拖死请求。
3. 其余纵深防御
| 措施 | 防什么 | 代码 |
|---|---|---|
| 5 分钟短 JWT | 令牌泄露窗口 | internal/auth/jwt.go:69 |
| 命名空 间 denylist | 已知滥用者整段封禁 | internal/auth/blocks.go、jwt.go:91 |
| GitHub 名字正则 | 名字注入撑大权限 | github_at.go:195 |
| 4KB 发布者元数据上限 | 元数据塞垃圾 | validators.go:682 |
| OCI 注册表白名单 | 拉任意主机 | oci.go:28 |
| OCI 429 fail closed | 限流时误放行冒领 | oci.go:107(见第 3 章) |
denylist 的双形态探测(巧妙) GenerateTokenResponse(jwt.go:91)在签发前对每个黑名单命名空间探两种合成资源:
// internal/auth/jwt.go:92 (节选)
if j.HasPermission(blockedNamespace+"/test", PermissionActionPublish, claims.Permissions) ||
j.HasPermission(blockedNamespace+".test/x", PermissionActionPublish, claims.Permissions) {
return nil, fmt.Errorf("your namespace is blocked...")
}
为什么探两种?因为权限有 com.evil/*(斜杠形)和 com.evil.*(子域点形)两种通配。只探 com.evil/test 会漏掉子域形——com.evil.mailer. 的前缀不以 com.evil/test 开头。注释(jwt.go:85-90)把这个边界讲得很清楚。管理员(权限含 *)豁免。
4. 巧妙之处汇总(可借鉴)
- 借现成身份后端,不自建账号体系。 GitHub 握手直接拿 OAuth token 反查用户/org(
github_at.go:70),零密码存储。 - DNS-01 同源的所有权证明。 用「能否在你域名下放记录/文件」证明控制权(
dns.go/http.go),无需中心化审核。 - 取公钥与验签解耦。 DNS/HTTP 只差「怎么取公钥」,抽成
keyFetcher回调注入同一个CoreAuthHandler.ExchangeToken(common.go:256)。 - 双向归属绑定。 server 指向包、包声明
mcpName回指(第 3 章)。 - 分相位计时的事务 + 结构化日志。 事故驱动的可观测性改造,
runPhase(registry_service.go:139) 。 - DNS pinning 防 rebinding。 解析一次、对解析结果做检查再拨号(
http.go:67)。 - fail closed 原则。 校验不了就当不通过(OCI 429,
oci.go:107)。 - 错误信息即文档。 报错直接给「在 package.json 加这行」「记录放错位置了,应放 apex」(
npm.go:82、dns.go:101)。
5. 边界与局限(诚实)
- 它不托管代码。 只存元数据;包是否安全、是否真能跑,registry 不保证——它只验「归属」,不验「质量/安全」。
- 归属校验依赖第三方可用性。 validate 相位要联网 npm/OCI,慢或挂会拖慢发布;可用
EnableRegistryValidation=false关掉(离线/本地)。 - GitHub 私有 org 成员发布会失败,需把 org 成员身份设为 public——报错里有提示(
publish.go:95),但这是真实摩擦点。 - 版本不强制 semver。 非 semver 版本只能按发布时间排序(
versioning.go:71),「哪个是 latest」对乱写版本号的人可能反直觉。 - denylist 是手工维护的静态列表(
blocks.go当前为空),不是自动滥用检测。 - (本文未深入) 游标分页的具体编码格式、seed/importer 流程、Azure/Google KMS 签名后端只在代码中存在、本文档未逐行展开(见代码地图自行下钻)。
6. 横向对比(同 shelf 兄弟)
MCP Registry 在 ai-protocol-reference 里属于「协议的配套基础设施」,而非协议本身:
| 维度 | MCP Registry | 典型 MCP server/SDK 实现 |
|---|---|---|
| 角色 | 服务器目录/发现层 | 单个服务器/客户端运行时 |
| 核心难点 | 命名空间所有权防冒名 | 工具调用 / transport |
| 信任模型 | 域名/账号所有权证明 | 客户端与服务器直连信任 |
它和 MCP 协议本体的关系,类比:MCP 协议 ≈ HTTP,MCP Registry ≈ DNS + 证书颁发机构——协议规定「怎么对话」,registry 解决「我怎么找到正确的、没被冒名的对方」。其「域名所有权证明」思路和 ACME/Let's Encrypt 的 DNS-01/HTTP-01 挑战是同一族,可对照阅读。
7. 代码地图(本章相关)
| 主题 | 文件 | 符号 |
|---|---|---|
| SSRF 自定义 dial | internal/api/handlers/v0/auth/http.go | safeDialContext、isBlockedIP、blockedIPv6Prefixes、cgnatRange |
| 域名校验 | internal/api/handlers/v0/auth/common.go | IsValidDomain |
| HTTP fetcher 防护 | internal/api/handlers/v0/auth/http.go | NewDefaultHTTPKeyFetcher、MaxKeyResponseSize、FetchKey |
| denylist 双探测 | internal/auth/jwt.go | GenerateTokenResponse、isResourceMatch |
| 命名空间黑名单 | internal/auth/blocks.go | BlockedNamespaces |
| OCI fail closed | internal/validators/registries/oci.go | ValidateOCI、isAllowedRegistry |
| 发布者元数据限额 | internal/validators/validators.go | validatePublisherExtensions |