包归属:双向绑定防「冒领包」
本章讲什么: 第 1 章解决了「你证明你拥有这个名字」。但还有个漏洞:就算你拥有
io.github.alice/*,你能不能把 server 指向别人写的 npm 包?这一章就是堵这个洞。
1. 要解决的小问题
registry 是 metaregistry——它存的 Packages[] 只是「指向 npm/Docker 上某个包」的指针。设想:
- alice 拥有
io.github.alice/*(合法)。 - 她发布
io.github.alice/fake,但把 package 指向npm: @microsoft/some-real-package。
名字校验通不过吗?通得过——名字是 io.github.alice/fake,alice 有权发。于是 registry 里就出现一条「alice 的 server,实际跑的是微软的包」的误导条目。这就是要堵的洞。
2. 思路:让「包」也声明它属于谁
双向绑定。 不仅 server 指向 package,package 也必须声明它属于哪个 server。registry 发布时去包仓库反查,要求两边互相指认。
你提交的 server.json npm 上的包元数据
┌───────────────────────┐ ┌───────────────────────────┐
│ name: io.github.alice/ │ ──指向──▶ │ @alice/weather-mcp │
│ weather │ │ "mcpName": │
│ packages:[npm @alice/ │ ◀──回指── │ "io.github.alice/weather"│
│ weather-mcp] │ 必须相等 │ │
└───────────────────────┘ └───────────────────────────┘
只有当包元数据里的 mcpName 正好等于你正在发布的 server 名,校验才过。微软的包里不会写 mcpName: io.github.alice/fake,所以 alice 冒领失败。
3. 分发到各包仓库
入口 ValidatePackage(internal/validators/package.go:14)按 RegistryType 分发:
// internal/validators/package.go:14 ValidatePackage(节选)
switch pkg.RegistryType {
case model.RegistryTypeNPM: return registries.ValidateNPM(ctx, pkg, serverName)
case model.RegistryTypePyPI: return registries.ValidatePyPI(ctx, pkg, serverName)
case model.RegistryTypeNuGet: return registries.ValidateNuGet(ctx, pkg, serverName)
case model.RegistryTypeOCI: return registries.ValidateOCI(ctx, pkg, serverName)
case model.RegistryTypeMCPB: return registries.ValidateMCPB(ctx, pkg, serverName)
case model.RegistryTypeCargo: return registries.ValidateCargo(ctx, pkg, serverName)
}
每种包把「归属声明」放在该生态最自然的地方:
| 包类型 | 归属声明放在哪 | 校验文件 |
|---|---|---|
| npm | package metadata 的 mcpName 字段 | registries/npm.go |
| PyPI | 包元数据(同 mcpName 思路) | registries/pypi.go |
| OCI(Docker) | 镜像 label io.modelcontextprotocol.server.name | registries/oci.go |
| NuGet / Cargo | 各自元数据 | registries/{nuget,cargo}.go |
| MCPB | 直接下载 + FileSHA256 校验完整性 | registries/mcpb.go |
这一步是可关的:由 cfg.EnableRegistryValidation 控制(默认 true,internal/config/config.go:18),离线开发可关。入口判断在 ValidatePublishRequest(internal/validators/validators.go:651)。
4. 走一遍 npm 校验
ValidateNPM(internal/validators/registries/npm.go:26)是最直白的样板:
- 要求有版本(
npm.go:40):没版本就拿不到具体版本的元数据,没法比mcpName。 - GET
registry.npmjs.org/<包名>/<版本>(npm.go:57),10 秒超时。 - 解析出
mcpName字段,逐一检查:
// internal/validators/registries/npm.go:81 归属比对(节选)
if npmResp.MCPName == "" {
return fmt.Errorf("...is missing required 'mcpName' field. Add this to your package.json: \"mcpName\": \"%s\"", ..., serverName)
}
if npmResp.MCPName != serverName {
return fmt.Errorf("NPM package ownership validation failed. Expected mcpName '%s', got '%s'", serverName, npmResp.MCPName)
}
报错信息直接告诉作者「在 package.json 里加这一行」——很友好。
5. OCI 校验里的一个安全教训:fail closed
OCI 把归属放在镜像 label。ValidateOCI(internal/validators/registries/oci.go:56)用 go-containerregistry 匿名拉取镜像 config,读 Labels["io.modelcontextprotocol.server.name"] 和 server 名比对(oci.go:135-142)。
两个值得学的点:
(1) 只信任注册表白名单(oci.go:28/isAllowedRegistry:149): docker.io、ghcr.io、quay.io、mcr.microsoft.com,加上 *.pkg.dev(Google)、*.azurecr.io(Azure)的通配。不在名单的注册表直接拒——避免去拉任意主机(也是 SSRF 收口)。
(2) 限流必须 fail closed(oci.go:107-114): 如果注册表返回 429(rate limited),绝不能当成校验通过。注释明确记录了这曾是个 bug:
// internal/validators/registries/oci.go:107 (节选)
case http.StatusTooManyRequests:
// Fail closed: 429 意味着我们没能验证镜像上的 label,而 label 是 OCI 唯一的归属凭据。
// 这里返回 nil 会让发布者把记录绑到一个他并不拥有的公共镜像上——这正是本分支以前的 bug。
return fmt.Errorf("OCI registry is currently rate-limiting validations...; please retry shortly")
通用原则:安全校验在「不确定」时必须按「不通过」处理(fail closed),而不是图省事放行(fail open)。 校验不了 ≠ 校验通过。
6. 远程 URL 去重(归属的另一面)
对于 Remotes[](远程托管的 server,没有可下载的包),没法反查包元数据,于是用另一招防冒领:同一个远程 URL 不能被两个不同名字的 server 同时声明。
validateNoDuplicateRemoteURLs(internal/service/registry_service.go:302):对每个远程 URL 用 filter 查库,若发现已被别的名字占用就拒。这是发布事务里的 validate_remote_urls 相位。