跳到主要内容

包归属:双向绑定防「冒领包」

本章讲什么: 第 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)
}

每种包把「归属声明」放在该生态最自然的地方:

包类型归属声明放在哪校验文件
npmpackage metadata 的 mcpName 字段registries/npm.go
PyPI包元数据(同 mcpName 思路)registries/pypi.go
OCI(Docker)镜像 label io.modelcontextprotocol.server.nameregistries/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)是最直白的样板:

  1. 要求有版本(npm.go:40):没版本就拿不到具体版本的元数据,没法比 mcpName
  2. GET registry.npmjs.org/<包名>/<版本>(npm.go:57),10 秒超时。
  3. 解析出 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 相位。


下一章:版本比较的三规则、is_latest 重算与并发锁