跳到主要内容

安全设计、巧妙之处与边界

本章讲什么: 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.gocommon.go

第 1 层:拒绝非域名 / 内网域名

IsValidDomain(common.go:385):拒 IP 字面量(net.ParseIP != nil)——「这套方法证明的是域名所有权,不是 IP 所有权」;还要求至少有一个点,挡掉 localhostkubernetes 这种只在内网解析的单标签名。

第 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)

注意 cgnatRangeblockedIPv6Prefixes(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.LimitReaderMaxKeyResponseSize = 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.gojwt.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. 巧妙之处汇总(可借鉴)

  1. 借现成身份后端,不自建账号体系。 GitHub 握手直接拿 OAuth token 反查用户/org(github_at.go:70),零密码存储。
  2. DNS-01 同源的所有权证明。 用「能否在你域名下放记录/文件」证明控制权(dns.go/http.go),无需中心化审核。
  3. 取公钥与验签解耦。 DNS/HTTP 只差「怎么取公钥」,抽成 keyFetcher 回调注入同一个 CoreAuthHandler.ExchangeToken(common.go:256)。
  4. 双向归属绑定。 server 指向包、包声明 mcpName 回指(第 3 章)。
  5. 分相位计时的事务 + 结构化日志。 事故驱动的可观测性改造,runPhase(registry_service.go:139)。
  6. DNS pinning 防 rebinding。 解析一次、对解析结果做检查再拨号(http.go:67)。
  7. fail closed 原则。 校验不了就当不通过(OCI 429,oci.go:107)。
  8. 错误信息即文档。 报错直接给「在 package.json 加这行」「记录放错位置了,应放 apex」(npm.go:82dns.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 自定义 dialinternal/api/handlers/v0/auth/http.gosafeDialContextisBlockedIPblockedIPv6PrefixescgnatRange
域名校验internal/api/handlers/v0/auth/common.goIsValidDomain
HTTP fetcher 防护internal/api/handlers/v0/auth/http.goNewDefaultHTTPKeyFetcherMaxKeyResponseSizeFetchKey
denylist 双探测internal/auth/jwt.goGenerateTokenResponseisResourceMatch
命名空间黑名单internal/auth/blocks.goBlockedNamespaces
OCI fail closedinternal/validators/registries/oci.goValidateOCIisAllowedRegistry
发布者元数据限额internal/validators/validators.govalidatePublisherExtensions