MCP 客户端元数据(CIMD):托管、服务器端实现与 CIDR/SSRF 安全
本指南面向远程 HTTP MCP 客户端、MCP 服务器及 Signet 授权服务器的所有者。本文介绍如何发布客户端 ID 元数据文档、从 MCP 服务器公开 OAuth 受保护资源元数据、配置 Signet、验证最终取得的访问令牌,以及如何安全抓取由攻击者控制的元数据 URL。
CIMD 不是 CIDR。 CIMD 是 _Client ID Metadata Document(客户端 ID 元数据文档)_:一份公开的 HTTPS JSON 文档,其 URL 同时也是 OAuth
client_id。CIDR 是10.0.0.0/8这类 IP 前缀表示法,只会出现在本指南的 SSRF 防护章节中。MCP 所有者不需要提交 CIDR 网段来注册客户端。
MCP 2026-07-28 授权规范引用的是 CIMD draft-00。Signet 实现的是该机制一个刻意受限的配置集,并非完全遵循持续演进中的最新 CIMD 草案每个修订版本。本页记录 Signet 的实际行为,并标明会影响部署的已知差异。
架构与所有权
此流程包含四个角色。同一组织可以运营多个角色,但各角色负责的文档仍然彼此独立。
| 角色 | 职责 | 负责的文档 |
|---|---|---|
| MCP 客户端 | 发起授权流程、生成 PKCE,并调用 MCP 服务器 | 客户端所有者托管 CIMD JSON |
| 客户端元数据源站 | 通过公网 HTTPS 直接提供 CIMD URL | https://client.example.com/oauth/client.json |
| MCP 服务器 / OAuth 资源服务器 | 对未认证请求发起质询并验证访问令牌 | RFC 9728 受保护资源元数据(PRM) |
| Signet / 授权服务器 | 解析 CIMD URL、取得用户同意并签发令牌 | OAuth 授权服务器元数据与 JWKS |
最常被混淆的两份元数据文档是:
- CIMD 描述 OAuth 客户端。它的 URL 就是
client_id,由 MCP 客户端所有者发布。
- 受保护资源元数据(PRM)描述 MCP 资源服务器并指向 Signet。它由 MCP 服务器所有者发布,通常位于
/.well-known/oauth-protected-resource。
部署示例
请先选定稳定且规范的标识符,再开始编写代码:
| 用途 | 示例 |
|---|---|
| Signet 签发者 | https://auth.example.com |
| MCP 资源标识符 | https://mcp.example.com |
| MCP 受保护资源元数据 | https://mcp.example.com/.well-known/oauth-protected-resource |
CIMD URL 与 OAuth client_id |
https://client.example.com/oauth/client.json |
| 客户端回调地址 | https://client.example.com/oauth/callback |
请将每个标识符都视为逐字节精确值。结尾少一个或多一个斜线都会成为不同的标识符。即使 URI scheme 的匹配不区分大小写,也应使用小写、规范的 https URL;同时,不要从不受信任的 Host 或 X-Forwarded-Host 请求头推导这些值。
完整的发现与授权顺序如下:
第一部分:发布客户端 ID 元数据文档
1.1 创建 JSON 文档
针对上述部署示例,请在 https://client.example.com/oauth/client.json 发布以下精确内容:
{
"client_id": "https://client.example.com/oauth/client.json",
"client_name": "Acme MCP Client",
"client_uri": "https://client.example.com",
"redirect_uris": ["https://client.example.com/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"scope": "openid profile email offline_access"
}
Signet 会读取以下字段,并忽略未知的 JSON 成员:
| 字段 | 必填 | Signet 行为 |
|---|---|---|
client_id |
是 | 必须与 Signet 抓取的 URL 逐字节完全相等 |
client_name |
否 | 显示名称;为空时使用文档的主机名 |
client_uri |
否 | 存储为客户端的描述信息 |
redirect_uris |
是 | 一至十项;回调地址必须与其中一项精确匹配 |
token_endpoint_auth_method |
否 | 只能为空或 none;CIMD 客户端永远没有共享密钥 |
grant_types |
否 | 如果存在,必须包含 authorization_code |
scope |
否 | 与 Signet 的用户安全 scope 集合取交集 |
重要限制:
- CIMD 客户端始终是公开客户端;初次用户授权采用带 S256 PKCE 的授权码流程。Signet 不会为其启用设备授权或客户端凭证授权。启用刷新令牌后,客户端仍可声明并使用刷新令牌授权。
- 元数据 URL 必须使用 HTTPS、包含主机名和比
/更具体的路径,不得包含用户信息或 fragment(包括结尾为空的#),且不得包含.或..路径段。
- Signet 在判断一个值是否具有 CIMD 格式时,会以不区分大小写的方式识别 HTTPS scheme。之后的文档绑定仍使用逐字节精确比较,因此文档的
client_id必须保留客户端所用 URL 的拼写。建议所有位置都使用小写、规范的httpsURL。
- 重定向 URI 使用精确匹配。启用
STRICT_REDIRECT_URIS=true时,生产环境回调必须使用 HTTPS;回环地址上的开发环境回调可以使用 HTTP。
- Signet 最多接受 64 KiB。当前 CIMD 草案建议将文档控制在 5 KB 以下,这也是良好的生产环境目标。
- 不要在文档或其 URL 中放入密钥、Bearer 令牌或私钥。文档与
client_id都是公开信息。
- 草案规定客户端标识符 URL 不应包含查询字符串。Signet 为兼容性仍接受查询字符串,但新客户端不应使用。还应避免别名和斜线重定向。简短、稳定的 URL 可避免精确匹配与迁移问题。
文档的 scope 与 grant_types 描述客户端可以使用的能力;它们不会发起 scope 请求、强制 Signet 签发刷新令牌,也不能取代授权请求中的参数。
1.2 使用 Go 实现元数据源站
元数据端点必须直接返回状态码 200 与文档内容。它不得要求请求认证,也不得重定向到其他 URL。
package main
import (
"log"
"net/http"
"time"
)
const clientMetadata = `{
"client_id":"https://client.example.com/oauth/client.json",
"client_name":"Acme MCP Client",
"client_uri":"https://client.example.com",
"redirect_uris":["https://client.example.com/oauth/callback"],
"token_endpoint_auth_method":"none",
"grant_types":["authorization_code","refresh_token"],
"scope":"openid profile email offline_access"
}`
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/oauth/client.json", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Keep the canonical client_id in configuration or source. Do not build
// it from r.Host, Forwarded, or X-Forwarded-* headers.
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(clientMetadata))
})
server := &http.Server{
Addr: ":443",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
// In production, use a trusted public certificate. If a reverse proxy
// terminates TLS instead, listen on its private upstream port with plain
// HTTP and keep the externally visible canonical URL unchanged.
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
}
Signet 通过服务器到服务器的方式抓取响应,因此不需要 CORS。仅在另有浏览器使用场景时才添加 CORS。
1.3 使用 Nginx 提供静态文档
对于静态源站,应避免对此精确位置应用通用的尾斜线或 www 重定向:
server {
listen 443 ssl;
server_name client.example.com;
root /srv/cimd;
ssl_certificate /etc/letsencrypt/live/client.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/client.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location = /oauth/client.json {
default_type application/json;
add_header Cache-Control "public, max-age=300" always;
add_header X-Content-Type-Options "nosniff" always;
try_files /client.json =404;
}
}
使用之前,请验证公网端点:
set -euo pipefail
body="$(mktemp)"
trap 'rm -f "$body"' EXIT
status="$(curl --proto '=https' --noproxy '*' --silent --show-error --max-redirs 0 \
--connect-timeout 5 --max-time 10 --max-filesize 65536 \
--output "$body" --write-out '%{http_code}' \
https://client.example.com/oauth/client.json)"
test "$status" = "200"
test "$(wc -c < "$body")" -le 65536
jq -e '.client_id == "https://client.example.com/oauth/client.json"' "$body"
此检查只能针对您所控制的元数据源站执行,不得用于授权请求中由第三方提供的 URL;它并不是 SSRF 防护。状态检查只有在直接收到 200 响应时才会通过。301、302、认证页面或 TLS 错误都会导致客户端不可用。如果所有可用的 DNS 解析结果都是非公网地址,Signet 会阻止连接;公网与私有地址混用属于无效且不可靠的部署,因此每一条 A 与 AAAA 记录都必须指向公网地址。
第二部分:实现 MCP 资源服务器
MCP 服务器就是 OAuth 资源服务器。它会发布受保护资源元数据、对未认证的调用方发起质询,并验证访问令牌。通常它不会托管客户端的 CIMD 文档。
2.1 发布 RFC 9728 受保护资源元数据
针对上述部署示例,返回:
{
"resource": "https://mcp.example.com",
"authorization_servers": ["https://auth.example.com"],
"bearer_methods_supported": ["header"],
"resource_name": "Acme MCP Server"
}
MCP 客户端必须先验证发现结果,才能信任任何端点:
- 根据用户原本要联系的 MCP 服务器推导并记录预期的规范资源标识符。
- 抓取 PRM,并使用简单字符串比较,要求其
resource值与该标识符相等。
- 对
authorization_servers应用本地信任策略;不要自动信任由不受信任服务器提供的任意签发者。
- 使用 RFC 8414/OIDC 发现规则抓取所选授权服务器的元数据,并要求其
issuer与所选授权服务器标识符精确相等。
- 开始 CIMD 流程前,要求
client_id_metadata_document_supported: true。
- 将经过验证的签发者与资源、
state及 PKCE verifier 一同保存,以便验证授权响应。
这些检查通过之前,不要使用元数据中的授权端点或令牌端点 URL。这些检查可以防止资源服务器冒充与授权服务器混淆攻击。
一种简单的单 AS 策略,是让精确允许列表只包含 https://auth.example.com。不要使用主机名后缀通配符,并确保允许列表独立于正在发现的 MCP 服务器所提供的值。规范 MCP 资源也是预先配置的部署标识符;不要在客户端自行发明针对路径、端口或斜线的规范化算法。
下面是一个最小化的 Go 实现:
package mcp
import (
"context"
"encoding/json"
"net/http"
"strings"
)
const (
resourceID = "https://mcp.example.com"
resourceMetadata = resourceID + "/.well-known/oauth-protected-resource"
signetIssuer = "https://auth.example.com"
)
type VerifiedClaims struct {
Subject string
ClientID string
Scopes map[string]bool
}
type TokenVerifier interface {
VerifyAccessToken(ctx context.Context, rawToken string) (*VerifiedClaims, error)
}
type verifiedClaimsKey struct{}
func ClaimsFromContext(ctx context.Context) (*VerifiedClaims, bool) {
claims, ok := ctx.Value(verifiedClaimsKey{}).(*VerifiedClaims)
return claims, ok
}
func ProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_ = json.NewEncoder(w).Encode(map[string]any{
"resource": resourceID,
"authorization_servers": []string{signetIssuer},
"bearer_methods_supported": []string{"header"},
"resource_name": "Acme MCP Server",
})
}
func bearerToken(r *http.Request) string {
fields := strings.Fields(r.Header.Get("Authorization"))
if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") {
return ""
}
return fields[1]
}
func RequireAccessToken(verifier TokenVerifier, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
w.Header().Set(
"WWW-Authenticate",
`Bearer resource_metadata="`+resourceMetadata+`"`,
)
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
// Verify signature and claims as described in section 2.4 before
// allowing the request to reach the MCP handler.
claims, err := verifier.VerifyAccessToken(r.Context(), token)
if err != nil {
w.Header().Set(
"WWW-Authenticate",
`Bearer error="invalid_token", resource_metadata="`+
resourceMetadata+`"`,
)
http.Error(w, "invalid bearer token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), verifiedClaimsKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
请将 ProtectedResourceMetadata 注册到 GET /.well-known/oauth-protected-resource。如果资源标识符包含路径,请遵循 RFC 9728 的 well-known URI 构造规则,不要直接拼接字符串。
2.2 返回 OAuth 质询
不带凭证的 MCP 请求必须收到 HTTP 401 与发现指针:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
访问令牌缺失、过期、格式错误或因其他原因无效时,请使用 401。验证成功后,操作处理程序应读取 ClaimsFromContext,并应用其中的 scope 与本地策略。有效令牌权限不足时应返回 403,而不是 401:
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="REQUIRED_SCOPE", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource"
切勿将内部验证细节放入响应正文。请在一次质询中返回该操作需要的所有 scope,以免客户端陷入反复提升权限的循环。
如果基于浏览器的 MCP 客户端需要跨源读取质询,请在 MCP 服务器上公开该响应头:
Access-Control-Expose-Headers: WWW-Authenticate
只允许必要的来源、方法与请求头。Signet 的 CORS 配置只影响 Signet 端点,不会配置 MCP 服务器上的 CORS。
2.3 保持资源指示器一致
MCP 客户端会在授权请求与令牌请求中都发送资源标识符。这会将最终访问令牌的 audience 绑定到 MCP 服务器:
resource=https://mcp.example.com
以下位置必须使用完全相同的字符串:
- PRM 的
resource字段;
- 客户端的授权请求;
- 客户端的令牌请求;
- Signet 中的
CIMD_ALLOWED_RESOURCES;以及
- MCP 服务器接受的
aud值。
不要只在其中某一层静默规范化尾斜线。
2.4 验证每个访问令牌
执行任何 MCP 操作之前,资源服务器必须:
- 只从
Authorization: Bearer请求头接受令牌。
- 从
https://auth.example.com/.well-known/jwks.json选择密钥,并且只允许已配置的签名算法;不要只相信令牌自身的alg。
- 验证签名、
iss、exp,以及存在时的nbf。
- 要求 Signet 的
type声明为access;拒绝刷新令牌与 ID token。
- 要求
aud包含逐字节精确的资源标识符https://mcp.example.com,无论aud被编码为字符串还是数组。
- 执行针对具体操作的 scope 与本地授权策略。
- 根据 JWKS 响应头缓存结果,并安全处理密钥轮换。
完整的 Go、Python 与 Node.js 验证示例请参阅 JWT 验证。
离线 JWT 验证本身无法感知之后发生的数据库撤销。删除或停用 Signet 客户端会视情况阻止后续授权、授权码交换或刷新,但之前签发的访问 JWT 在 exp 之前仍然能通过密码学验证。如果业务要求立即撤销访问令牌,请在资源服务器增加拒绝列表/状态检查或在线 introspection 设计,并缩短访问令牌有效期。签名密钥轮换的影响范围远大得多,不应作为常规的单客户端撤销机制。
第三部分:配置 Signet 并运行流程
3.1 显式启用 CIMD
以下只是与 CIMD 相关的生产配置片段,并非完整的 Signet 部署文件。RS256 还需要私有签名密钥,而且每个生产部署都必须使用唯一的 session secret:
ENVIRONMENT=production
BASE_URL=https://auth.example.com
JWT_SIGNING_ALGORITHM=RS256
JWT_PRIVATE_KEY_PATH=/run/secrets/signet-jwt-private.pem
SESSION_SECRET=session-secret-change-in-production
JWT_AUDIENCE=
JWT_EXPIRATION=15m
STRICT_REDIRECT_URIS=true
ENABLE_REFRESH_TOKENS=true # Optional; disable if refresh is not needed
CIMD_ENABLED=true
CIMD_ALLOWED_RESOURCES=https://mcp.example.com
CIMD_FETCH_TIMEOUT=5s
CIMD_CACHE_TTL=5m
CIMD_ALLOW_PRIVATE_NETWORKS=false
生产环境验证会主动拒绝示例中的 SESSION_SECRET 占位值。启动之前,请使用 openssl rand -hex 32 生成一个值,并通过部署系统的 secret manager 注入。不要将 session secret 或 JWT 私钥提交到版本库。
数据库、监听器/TLS、代理、健康检查及其他标准部署设置不在这段 CIMD 专用配置片段的范围内;请依照 Signet 的配置指南补齐。访问令牌有效期应与撤销设计相匹配;示例中的短有效期可以缩短离线 JWT 在签发后遭撤销但仍可使用的时间窗口。
对于从其他来源调用 Signet 的浏览器型 MCP 客户端,还需要显式配置 Signet 的 CORS 允许列表:
CORS_ENABLED=true
CORS_ALLOWED_ORIGINS=https://client.example.com
配置行为:
| 设置 | 含义 |
|---|---|
CIMD_ENABLED |
选择性加入的全局开关;默认为 false |
CIMD_ALLOWED_RESOURCES |
以逗号分隔、精确匹配的全局允许列表;为空时拒绝所有非空的 CIMD 资源请求 |
CIMD_FETCH_TIMEOUT |
元数据抓取的总超时时间;默认为 5s |
CIMD_CACHE_TTL |
成功文档的缓存上限;默认为 5m,下限为一分钟 |
CIMD_ALLOW_PRIVATE_NETWORKS |
停用地址防护;仅可用于隔离的开发环境,切勿用于生产环境 |
请将 JWT_AUDIENCE 留空,或将其设为只代表授权服务器的标识符。不要将它设为 MCP 资源标识符:携带该默认 audience 的刷新令牌绝不能被资源服务器接受。请求中的 RFC 8707 resource 参数负责提供访问令牌的 audience。
如有多个 MCP 服务器,请使用逗号分隔并列出精确的资源标识符:
CIMD_ALLOWED_RESOURCES=https://mcp.example.com,https://reports.example.com
启用后,Signet 会在其授权服务器元数据中声明该能力:
curl --fail --silent \
https://auth.example.com/.well-known/oauth-authorization-server \
| jq '.client_id_metadata_document_supported'
结果必须为 true。字段不存在或值为 false 时,客户端必须使用其他受支持的注册机制。
3.2 构建授权请求
客户端使用密码学安全随机数,为全新的 state 生成至少 128 位随机值。PKCE code_verifier 必须独立使用至少 256 位密码学安全随机数生成,以 RFC 7636 的 unreserved 字符集编码(无填充的 base64url 很方便),并保持 43–128 个字符。两者都不得复用。将它们保存在一条短期、单次使用的流程记录中,派生 S256 code_challenge,然后打开与下列内容等效的 URL:
https://auth.example.com/oauth/authorize?
response_type=code&
client_id=https%3A%2F%2Fclient.example.com%2Foauth%2Fclient.json&
redirect_uri=https%3A%2F%2Fclient.example.com%2Foauth%2Fcallback&
scope=openid%20profile%20email&
resource=https%3A%2F%2Fmcp.example.com&
state=RANDOM_STATE&
code_challenge=BASE64URL_SHA256_VERIFIER&
code_challenge_method=S256
请使用 URL 库构造查询参数,不要直接拼接字符串。将经过验证的预期签发者、资源标识符、state 与 code_verifier 保存在同一条流程会话记录中。
每次成功回调以及错误回调都必须执行以下检查:
- 要求
state与该流程记录相符。
- 由于 Signet 会声明
authorization_response_iss_parameter_supported: true,因此必须要求响应包含iss参数。
- 使用逐字节精确的简单字符串比较,将解码后的
iss与记录的元数据签发者比较。不要忽略大小写、移除默认端口、规范化百分号编码,也不要改变尾斜线。
- 在把授权码发送到任何令牌端点或显示授权错误之前,拒绝缺少或不匹配的
iss。
在此请求期间,Signet 会抓取并验证 CIMD 文档、创建或刷新内部影子客户端,并显示同意页面。由于自行声明的 client_name 并不能证明身份,同意页面会使用客户端文档的域名来标识客户端。
3.3 交换授权码
公开客户端使用 client_id 与 PKCE 认证,而不是使用密钥:
curl --request POST https://auth.example.com/oauth/token \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=https://client.example.com/oauth/client.json' \
--data-urlencode 'redirect_uri=https://client.example.com/oauth/callback' \
--data-urlencode 'code=AUTHORIZATION_CODE' \
--data-urlencode 'code_verifier=ORIGINAL_CODE_VERIFIER' \
--data-urlencode 'resource=https://mcp.example.com'
redirect_uri、client_id、code_verifier 与 resource 必须和授权请求相对应。只将返回的访问令牌发送给 aud 中指定的资源。
示例 CIMD 在 grant_types 中声明了 refresh_token,因为该客户端能够安全存储刷新令牌。在当前 Signet 中,是否实际签发刷新令牌由 ENABLE_REFRESH_TOKENS 控制;offline_access 会被接受为用户安全 scope,但 Signet 既不会发布它,也不要求必须请求它才签发刷新令牌。MCP 客户端仅应在授权服务器元数据声明支持时请求 offline_access,并且绝不能假定响应一定会包含刷新令牌。
3.4 了解当前的 scope 限制
Signet 当前将 CIMD 客户端限制在以下用户安全集合中:
email profile openid offline_access
如果文档省略 scope,则整个集合都可使用,但仍须取得用户同意。如果文档声明了 scope,Signet 只保留它与上述集合的交集。mcp:tools、read 或 write 等未知或自定义 scope 会被丢弃。
因此,请勿在 PRM 中发布自定义 MCP scope 后就假设它们能与当前版本的 CIMD 客户端协同工作。如果 MCP 服务器需要自定义 scope,请使用管理员已批准 scope 策略的预注册客户端,直到 Signet 明确支持自定义 CIMD scope。
第四部分:元数据抓取的 CIDR 与 SSRF 安全
本节适用于授权服务器所有者,以及任何实现 CIMD 抓取程序的人。客户端所有者只需确保元数据主机名的每个 DNS 地址都能从公网访问。
4.1 一分钟了解 CIDR
CIDR 使用 address/prefix-length(地址/前缀长度)表示 IP 前缀。/ 后的数字是固定前导位的数量:
0.0.0.0/8涵盖0.0.0.0到0.255.255.255;只阻止0.0.0.0并不等价。
10.0.0.0/8涵盖第一个八位组为 10 的 RFC 1918 网段。
100.64.0.0/10涵盖100.64.0.0到100.127.255.255。
::1/128是单一 IPv6 地址;fc00::/7是 IPv6 唯一本地地址范围。
请使用 Go net/netip 这类 IP 地址库。切勿通过字符串前缀比较实现 CIDR 匹配。
prefix := netip.MustParsePrefix("100.64.0.0/10").Masked()
blocked := prefix.Contains(netip.MustParseAddr("100.100.100.200")) // true
应用 IPv4 规则之前,始终调用 Addr.Unmap(),确保 ::ffff:127.0.0.1 这类映射为 IPv4 的 IPv6 形式无法绕过回环地址检查。
4.2 为什么仅验证 HTTPS URL 还不够
授权端点会接受受用户影响的 client_id。如果没有网络防护,攻击者可让授权服务器向以下目标发送 HTTPS 请求:
- 回环地址上的管理服务;
- RFC 1918 或 IPv6 ULA 私有服务;
- Kubernetes、CGNAT 或云实例元数据网络;
- 链路本地地址;
- 可抵达内嵌 IPv4 目标的 NAT64 或 6to4 地址;或者
- 由分割视图 DNS 暴露的内部 TLS 服务。
主机名也可能在早期安全检查时返回公网地址,而在 HTTP 客户端再次解析时返回私有地址。正因为存在这种 DNS rebinding 竞态,先单独调用 LookupIP 再执行普通 http.Get 并不安全。
4.3 Signet 的地址阻止策略
Signet 会拒绝无效地址、回环地址、RFC 1918/ULA 私有地址、链路本地地址、接口本地多播地址、所有多播地址、未指定地址,以及 IPv4 有限广播地址。它还会拒绝以下需要显式检查的前缀:
| 前缀 | 原因 |
|---|---|
0.0.0.0/8 |
可能在本地路由的“本网络”地址范围 |
100.64.0.0/10 |
共享/CGNAT 空间;部分基础设施元数据服务也会使用 |
192.0.0.0/24 |
IETF 协议分配地址 |
198.18.0.0/15 |
有时会在内部路由的网络基准测试空间 |
240.0.0.0/4 |
保留/未来使用的 IPv4 空间 |
2002::/16 |
内嵌 IPv4 目的地址的 6to4 |
64:ff9b::/96 |
NAT64 well-known 前缀 |
这是一份应用程序拒绝列表,并不保证能识别每一个 IANA 特殊用途地址或供应商特定地址。请保留网络出口防火墙作为第二层防护,并定期将策略与 IANA 特殊用途地址注册表进行比较。
4.4 在连接时执行决策
安全的检查时点是在 DNS 解析完成之后、调用 connect(2) 之前。Go 的 net.Dialer.Control 会收到拨号器所选择的具体候选 IP,因此可以拒绝每一个 A 或 AAAA 候选地址,且不会产生检查与使用之间的竞态。
以下只是传输层蓝图,并非完整的抓取程序。它反映了 Signet 的连接控制;生产代码还必须实现代码后方列出的所有验证与限制,并补充适合服务的指标、结构化日志、测试及生命周期清理:
package cimd
import (
"crypto/tls"
"errors"
"net"
"net/http"
"net/netip"
"slices"
"syscall"
"time"
)
var blockedPrefixes = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"),
netip.MustParsePrefix("100.64.0.0/10"),
netip.MustParsePrefix("192.0.0.0/24"),
netip.MustParsePrefix("198.18.0.0/15"),
netip.MustParsePrefix("240.0.0.0/4"),
netip.MustParsePrefix("2002::/16"),
netip.MustParsePrefix("64:ff9b::/96"),
}
func disallowedIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() ||
ip.IsUnspecified() ||
ip == netip.AddrFrom4([4]byte{255, 255, 255, 255}) {
return true
}
return slices.ContainsFunc(blockedPrefixes, func(p netip.Prefix) bool {
return p.Contains(ip)
})
}
func guardDial(_, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return errors.New("refused malformed dial address")
}
ip, err := netip.ParseAddr(host)
if err != nil || disallowedIP(ip) {
return errors.New("non-public destination refused")
}
return nil
}
func metadataClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{
Timeout: 10 * time.Second,
Control: guardDial,
}
transport := &http.Transport{
Proxy: nil,
DialContext: dialer.DialContext,
TLSHandshakeTimeout: 10 * time.Second,
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
ForceAttemptHTTP2: true,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
围绕该客户端的必要抓取行为包括:
- 创建请求前,先要求 CIMD URL 符合规定格式。
- 发送带有
Accept: application/json的GET请求。
- 拒绝所有重定向,包括同源重定向。
- 只接受状态码 200。
- 通过
io.LimitReader(limit + 1)读取响应,以便不信任Content-Length也能检测超大正文。
- 解析 JSON,并验证
client_id、重定向 URI、令牌端点认证方法与授权类型。
- 只向 OAuth 客户端返回通用错误;将解析后的 IP 和端口细节保留在运维日志中,以免形成网络探测入口。
- 缓存失败结果或对失败请求限速,避免反复的授权请求把服务器变成出站请求放大器。
- 对从元数据中解除引用的每一个 URL(例如未来的
logo_uri、jwks_uri或sector_identifier_uri)应用同一个受防护客户端、scheme 策略、重定向规则、大小限制与超时时间。
Signet 当前只会将 client_uri 存储为描述文字,并忽略未知成员;它不会解除引用这些 URL。如果未来的实现开始抓取这些 URL,调用默认的 http.Get 会重新打开 SSRF 攻击路径,即使最初的 client_id 抓取已经受到保护。
如果没有重新设计此控制,请勿将 Proxy 改为 http.ProxyFromEnvironment。使用正向代理时,拨号器通常只能看到代理的 IP,而不是最终 URL 的目的地址。代理必须自行解析目标,并强制只允许公网目的地址与安全的 CONNECT 端口;否则 CIMD 应使用直接出站连接。透明 sidecar、分割视图 DNS 或 NAT 同样需要出口防火墙或 NetworkPolicy 强制保护。
CIMD_ALLOW_PRIVATE_NETWORKS=true 会绕过 Signet 的地址防护。它仅供元数据源站运行在回环地址上的隔离本地测试使用。切勿在共享开发环境或生产环境中启用。
第五部分:缓存、更新与应急控制
Signet 会按以下时长缓存有效文档:
max(1 minute, min(CIMD_CACHE_TTL, response Cache-Control max-age))
如果 max-age 不存在或格式错误,则使用 CIMD_CACHE_TTL。当前 Signet 只解析 max-age;no-cache 与 no-store 不会停用这层应用程序缓存。每个 Signet 进程都有自己的内存 CIMD 缓存,因此各副本可能暂时持有不同条目,重启进程只会清除该副本的条目。
Signet 也会将抓取失败或无效文档缓存一分钟,以限制重复的出站请求。这是 Signet 的一项已知偏差:CIMD draft-00 与最新草案都规定不得缓存错误响应和无效文档。运维人员应将此行为视为防止请求放大的权衡,不能假定 Signet 完全遵循草案。
对运维人员的影响:
- 修改元数据不能立即撤销客户端。发布变更期间,应检查每个副本,因为某个进程可能已经刷新,而另一个进程仍持有较旧的成功或失败缓存条目。
- 成功刷新会更新文档控制的字段,例如名称、URI、重定向 URI 与 scope。
- 刷新不会覆盖影子客户端中由管理员控制的状态。将某个 CIMD 记录设为
inactive是持久有效的授权控制开关,即使同时发生刷新也一样。但授权流程目前会先解析/抓取文档,再检查状态,因此inactive并不会停止出站元数据流量。
CIMD_ENABLED是启动时配置,不是热重载开关。将它设为false后,必须重启或重新部署每个 Signet 副本才会生效。它会停止 CIMD 解析与/oauth/authorize上的新授权,但不会自行使已经签发的授权码或刷新令牌失效。
- 在 CIMD 仍启用时删除影子客户端并不能持久阻止该客户端:下一次授权请求可能重新抓取文档,并创建新的活动记录。
- 对使用离线验证的 MCP 服务器而言,删除数据库记录或执行撤销无法立即使访问 JWT 失效。除非服务器同时执行在线状态检查或维护自己的拒绝列表,否则它仍会接受该 JWT 直到
exp。
- 一般客户端的管理网址会保留 UUID
client_id;CIMD 客户端则使用数字数据库 id(/admin/clients/<id>),因为 URL 形式的client_id无法放进单一路径段。两种路由引用形式都可解析。
推荐的事件响应顺序:
- 将受影响的影子客户端设为
inactive,阻止该客户端的新授权与后续令牌操作。保留这条非活动记录,避免 CIMD 请求将其重新创建为活动状态。
- 通过管理工作流撤销该客户端的令牌记录。对于已经签发的访问 JWT,还要使用 MCP 服务器的拒绝列表/状态机制,或等待这些短期令牌过期。
- 如需停止针对某个 URL 的出站抓取,请在出口代理/网络策略中阻止该目的地址;仅设置非活动状态并不足够。
- 如需全局关闭,请向每个副本部署
CIMD_ENABLED=false、重启副本,并确认能力标志已经消失。之后再按需撤销/删除影子客户端;在功能仍启用时删除,会允许其自动重建。
- 修复源站文档、DNS、TLS 或策略问题,从与 Signet 相同的 DNS 与出口环境进行测试,并在重新启用前考虑每个副本的缓存状态。
验证清单
客户端元数据所有者
- [ ] 规范 HTTPS URL 稳定可用,无需认证或重定向即可直接返回 200。
- [ ]
client_id与该 URL 逐字节完全相等。
- [ ] 正文是有效 JSON,最好小于 5 KB,且始终低于 Signet 的 64 KiB 上限。
- [ ] 包含一至十个精确的重定向 URI。
- [ ]
token_endpoint_auth_method为none,且任何位置都不包含密钥。
- [ ] 每条 A 与 AAAA 记录都可从公网路由,TLS 证书链有效。
- [ ] 缓存响应头符合所需的更新周期。
MCP 客户端实现
- [ ] PRM 的
resource与用户原本要联系的 MCP 服务器精确匹配。
- [ ] 所选授权服务器通过本地信任策略,而且其元数据
issuer精确匹配。
- [ ] 经过验证的签发者、资源、
state与 PKCE verifier 被绑定到同一条流程记录。
- [ ] 在任何授权码交换或错误显示之前,已验证回调中的
state与 RFC 9207iss。
- [ ] 刷新令牌会被保密存储,而且客户端能够处理响应中没有刷新令牌的情况。
MCP 服务器所有者
- [ ] PRM 返回精确的资源标识符与可信的 Signet 签发者。
- [ ] 凭证缺失或无效时返回 401,并携带
resource_metadata质询。
- [ ] 需要跨源访问时,浏览器客户端可以读取
WWW-Authenticate。
- [ ] 令牌会验证签名、算法、签发者、时间、
type=access、精确 audience 与所需权限。
- [ ] 刷新令牌、ID token,以及发给其他资源的访问令牌都会被拒绝。
Signet 运维人员
- [ ]
CIMD_ENABLED=true是明确作出的风险决策。
- [ ]
CIMD_ALLOWED_RESOURCES只包含必要且精确的资源标识符。
- [ ] 生产环境中设置
CIMD_ALLOW_PRIVATE_NETWORKS=false。
- [ ] RS256/ES256 签名密钥与至少 32 字节的随机
SESSION_SECRET均来自 secret manager。
- [ ] 已测试直接出站 HTTPS、DNS、代理/sidecar 行为与出口防火墙策略。
- [ ] 已监控抓取延迟、失败、唯一 CIMD URL 数量,以及
component=cimd警告。
- [ ] 管理员知道如何停用或删除 CIMD 影子客户端。
端到端冒烟测试
请在部署后,以及 DNS、代理、密钥或 CIMD 策略变更后执行以下测试:
set -euo pipefail
RESOURCE='https://mcp.example.com'
ISSUER='https://auth.example.com'
CLIENT_ID='https://client.example.com/oauth/client.json'
REDIRECT_URI='https://client.example.com/oauth/callback'
PRM_URL='https://mcp.example.com/.well-known/oauth-protected-resource'
AS_METADATA_URL='https://auth.example.com/.well-known/oauth-authorization-server'
AUTHORIZATION_ENDPOINT='https://auth.example.com/oauth/authorize'
TOKEN_ENDPOINT='https://auth.example.com/oauth/token'
JWKS_URI='https://auth.example.com/.well-known/jwks.json'
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
fetch_200() {
local url="$1"
local output="$2"
local status
status="$(curl --proto '=https' --noproxy '*' --silent --show-error --max-redirs 0 \
--connect-timeout 5 --max-time 10 --max-filesize 65536 \
--output "$output" --write-out '%{http_code}' "$url")"
test "$status" = '200'
test "$(wc -c < "$output")" -le 65536
}
fetch_200 "$CLIENT_ID" "$tmpdir/cimd.json"
fetch_200 "$PRM_URL" "$tmpdir/prm.json"
fetch_200 "$AS_METADATA_URL" "$tmpdir/as.json"
jq -e --arg id "$CLIENT_ID" --arg redirect "$REDIRECT_URI" \
'.client_id == $id and
(.redirect_uris | type == "array" and length >= 1 and length <= 10 and
index($redirect) != null and all(.[]; type == "string")) and
(.token_endpoint_auth_method == "none") and
(.grant_types | type == "array" and index("authorization_code") != null)' \
"$tmpdir/cimd.json"
jq -e --arg r "$RESOURCE" --arg i "$ISSUER" \
'.resource == $r and
(.authorization_servers | type == "array" and . == [$i]) and
(.bearer_methods_supported | type == "array" and index("header") != null)' \
"$tmpdir/prm.json"
jq -e --arg i "$ISSUER" --arg auth "$AUTHORIZATION_ENDPOINT" \
--arg token "$TOKEN_ENDPOINT" --arg jwks "$JWKS_URI" \
'.issuer == $i and
.authorization_endpoint == $auth and
.token_endpoint == $token and
.jwks_uri == $jwks and
.authorization_response_iss_parameter_supported == true and
.client_id_metadata_document_supported == true' \
"$tmpdir/as.json"
此 preflight 只能使用由运维人员固定并审核过的部署值。请在低权限诊断任务中运行,使用与 Signet 相同的 DNS 视图,并由出口防火墙预先阻止非公网目标;绝不能把请求提供的 URL 传入脚本。Curl 不会重现 Signet 的连接时 IP 防护,因此它只检查直接 HTTP 状态、边界和元数据格式,并不验证 SSRF 策略;仍必须通过 Signet 执行真实流程。脚本刻意不会自动完成用户登录/同意,也不会假装仅解码 JWT 就等于完成验证。
- 从受限诊断环境检查每一条 A 与 AAAA 记录,再抓取固定的 CIMD URL 并停用重定向。确认直接返回 200、地址均为公网、TLS 受信任、大小符合限制,而且
client_id精确匹配。不得使用不受保护的 curl 抓取授权请求所提供的 URL。
- 抓取 MCP 服务器的 PRM。确认其
resource与authorization_servers值和部署计划完全一致。
- 抓取 Signet 授权服务器元数据。确认
issuer精确匹配、端点符合预期,并包含authorization_response_iss_parameter_supported: true与client_id_metadata_document_supported: true。
- 执行真实的授权码 + S256 PKCE 流程。验证回调中的
state与iss,使用相同的resource交换授权码,并且绝不记录授权码、verifier 或令牌。
- 以密码学方式验证访问 JWT,并确认
type=access以及精确的iss与aud。不验证签名的解码只能用于检查,绝不能用于接受令牌。
- 不带令牌调用 MCP 服务器,预期收到支持 PRM 的 401;使用有效令牌调用,预期成功;再测试一条权限不足路径,预期收到 403 质询。
- 对于浏览器客户端,请在浏览器中执行相同流程,并确认 CORS 会在 401 与 403 响应中公开
WWW-Authenticate。
故障排查
| 现象 | 可能原因 | 检查项 |
|---|---|---|
unauthorized_client |
CIMD 已停用、URL 格式不被接受,或影子客户端处于非活动状态 | 能力元数据、CIMD_ENABLED、HTTPS 主机/路径、管理员设置的状态 |
授权期间出现 invalid_client |
抓取失败或文档验证失败 | Signet component=cimd 日志、直接 200、TLS、大小、JSON、精确 client_id、重定向列表 |
invalid_target |
资源不在全局允许列表中,或存在逐字节差异 | PRM resource、请求值与 CIMD_ALLOWED_RESOURCES,包括尾斜线 |
invalid_scope 或缺少自定义权限 |
当前 CIMD scope 限制移除了自定义 scope | 仅使用用户安全集合,或改用预注册客户端 |
| 非活动客户端仍然产生抓取日志 | 状态会在文档解析后才检查;非活动状态阻止授权,但不阻止出站流量 | 保持非活动状态;如果必须停止抓取,再添加目的地址出口阻止规则 |
| 文档修改似乎未生效,或不同 pod 的结果不同 | 每个进程的成功/失败缓存条目仍然有效 | 每个副本的日志、Cache-Control max-age、CIMD_CACHE_TTL、一分钟下限;重启只清除该进程的缓存 |
| 在回环地址可用,但在生产环境不可用 | 所有可用解析结果均为非公网/特殊用途地址、不同 DNS 视图的结果不同,或 TLS 不受信任 | 从 Signet pod 检查所有 A/AAAA 记录、分割视图 DNS、证书链、SSRF 日志 |
| 全局关闭 CIMD 后,刷新仍然有效 | 该开关控制授权时解析,不控制之前签发的凭证 | 停用/撤销影子客户端,并在 MCP 服务器应用访问 JWT 状态策略 |
| 浏览器无法发现质询 | CORS 隐藏了 WWW-Authenticate |
MCP 服务器的 Access-Control-Expose-Headers 与允许的来源 |
| 令牌有效但 MCP 返回 401 | 签发者、算法、type 或 audience 验证失败 |
解码后的声明、JWKS 选择、精确资源 ID;不要记录原始令牌 |