MCP 用戶端中繼資料(CIMD):託管、伺服器端實作與 CIDR/SSRF 安全性

本指南適用於遠端 HTTP MCP 用戶端、MCP 伺服器及 Signet 授權伺服器的負責人。內容說明如何發佈用戶端 ID 中繼資料文件、由 MCP 伺服器公開 OAuth 受保護資源中繼資料、設定 Signet、驗證產生的 access token,以及安全地擷取由攻擊者控制的中繼資料 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 資源伺服器 對未驗證的請求提出驗證要求,並驗證 access token RFC 9728 受保護資源中繼資料(PRM)
Signet/授權伺服器 解析 CIMD URL、取得使用者同意,並簽發 token OAuth 授權伺服器中繼資料與 JWKS

最常被混淆的兩份中繼資料文件是:

  • CIMD 描述 OAuth 用戶端。它的 URL 就是 client_id,並由 MCP 用戶端負責人發佈。
  • 受保護資源中繼資料(PRM)描述 MCP 資源伺服器並指向 Signet。它由 MCP 伺服器負責人發佈,通常位於 /.well-known/oauth-protected-resource。

部署範例

在撰寫程式碼之前,請先選定穩定且標準的識別碼:

用途 範例
Signet issuer 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
用戶端 callback https://client.example.com/oauth/callback

請將每個識別碼都視為必須逐位元組完全相同的值。結尾少一個或多一個斜線都會成為不同的識別碼。即使 URI scheme 比對不區分大小寫,仍應使用小寫、標準化的 https URL;也不要從不受信任的 Host 或 X-Forwarded-Host 請求標頭推導這些值。

完整的探索與授權順序如下:

sequenceDiagram participant C as MCP client participant R as MCP server participant A as Signet participant O as Client metadata origin participant B as Browser C->>R: Request without an access token R-->>C: 401 + WWW-Authenticate resource_metadata URL C->>R: GET /.well-known/oauth-protected-resource R-->>C: resource + authorization_servers C->>C: Require PRM resource exact match, then select a trusted AS C->>A: GET /.well-known/oauth-authorization-server A-->>C: endpoints + client_id_metadata_document_supported C->>C: Require metadata issuer exact match, then record issuer C->>C: Generate state, code_verifier, and S256 challenge C->>B: Open /oauth/authorize with CIMD URL as client_id B->>A: Authorization request A->>O: GET the client_id URL O-->>A: 200 metadata JSON A->>B: Login and consent A-->>B: Redirect with authorization code B-->>C: Callback with code, state, and iss C->>C: Validate state and iss before using the code C->>A: POST /oauth/token with code_verifier and resource A-->>C: Access token whose aud names the MCP resource C->>R: Authorization: Bearer access_token R-->>C: MCP response

第 1 部分:發佈用戶端 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 否 顯示名稱;留空時會使用文件的 hostname
client_uri 否 儲存為用戶端的描述資訊
redirect_uris 是 一至十筆;callback 必須與其中一筆完全相符
token_endpoint_auth_method 否 只能為空或 none;CIMD 用戶端絕不會有共用 secret
grant_types 否 若有提供,必須包含 authorization_code
scope 否 會與 Signet 的使用者安全 scope 集合取交集

重要限制:

  • CIMD 用戶端一律是公開用戶端,首次使用者授權會採用授權碼 grant 與 S256 PKCE;Signet 不會為它啟用 device 或 client-credentials grant。啟用 refresh token 時,仍可宣告並使用 refresh grant。
  • 中繼資料 URL 必須使用 HTTPS、包含 hostname,以及比 / 更明確的路徑,不得包含使用者資訊或 fragment(包括結尾的空 #),且不得含有 . 或 .. 路徑區段。
  • Signet 判斷某個值是否符合 CIMD 格式時,會以不區分大小寫的方式辨識 HTTPS scheme。但後續文件綁定仍採逐位元組完全相同比對,因此文件的 client_id 必須保留用戶端所用的拼法。建議所有位置都使用小寫、標準化的 https URL。
  • 重新導向 URI 採完全相同比對。當 STRICT_REDIRECT_URIS=true 時,正式環境的 callback 必須使用 HTTPS;loopback 開發環境的 callback 可以使用 HTTP。
  • Signet 最多接受 64 KiB。目前的 CIMD 草案建議文件小於 5 KB,這也是很好的正式環境目標。
  • 不要將 secret、bearer token 或私鑰放入文件或其 URL。文件與 client_id 都是公開資訊。
  • 草案規定用戶端識別碼 URL 不應包含查詢字串。Signet 為了相容性仍會接受,但新用戶端不應使用。也應避免別名及斜線重新導向。簡短且穩定的 URL 可避免完全相同比對與搬遷時的問題。

文件中的 scope 與 grant_types 描述用戶端可以使用的項目;它們不會提出 scope 要求、不會強制 Signet 簽發 refresh token,也不會取代授權請求中的參數。

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"

此檢查只能對您所控制的中繼資料 origin 執行,不得用於授權請求中由第三方提供的 URL;它並不是 SSRF guard。只有直接回傳 200 回應時,狀態檢查才會通過。若出現 301、302、驗證頁面或 TLS 錯誤,該用戶端便無法使用。若所有可用的 DNS 回應都是非公用位址,Signet 會封鎖連線;公用與私有位址混合的回應屬於無效且不可靠的部署,因此請確保每一筆 A 與 AAAA 回應都是公用位址。

第 2 部分:實作 MCP 資源伺服器

MCP 伺服器是 OAuth 資源伺服器。它會發佈受保護資源中繼資料、對未驗證的呼叫端提出驗證要求,並驗證 access token。它通常不會託管用戶端的 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 用戶端在信任任何端點之前,必須先驗證探索結果:

  1. 從它原本要連線的 MCP 伺服器推導並記錄預期的標準資源識別碼。
  2. 擷取 PRM,並使用簡單字串比較,要求其 resource 值與該識別碼相同。
  3. 對 authorization_servers 套用本機信任政策;不要自動將不受信任伺服器提供的任意 issuer 視為可信。
  4. 使用 RFC 8414/OIDC 探索規則擷取所選授權伺服器的中繼資料,並要求其中的 issuer 與所選授權伺服器識別碼完全相同。
  5. 啟動 CIMD 流程前,要求 client_id_metadata_document_supported: true。
  6. 儲存已驗證的 issuer,並將其與資源、state 及 PKCE verifier 一起保存,以供驗證授權回應。

在這些檢查通過前,請勿使用中繼資料中的授權或 token 端點 URL。這些檢查可防止資源伺服器冒充與授權伺服器混淆攻擊。

最簡單的單一授權伺服器政策,是建立只包含 https://auth.example.com 的完全相同允許清單。請避免 hostname suffix wildcard,並讓允許清單獨立於正在探索的 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"

若 access token 缺失、過期、格式錯誤或因其他原因無效,請使用 401。驗證成功後,操作 handler 應從 ClaimsFromContext 讀取資料,並套用其中的 scope 與本機政策。若 token 有效但缺少權限,應回傳 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 用戶端會在授權請求及 token 請求中傳送資源識別碼。如此可將產生的 access token audience 綁定至 MCP 伺服器:

resource=https://mcp.example.com

下列各處都必須使用完全相同的字串:

  • PRM 的 resource 欄位;
  • 用戶端的授權請求;
  • 用戶端的 token 請求;
  • Signet 中的 CIMD_ALLOWED_RESOURCES;以及
  • MCP 伺服器接受的 aud 值。

不要只在其中一層默默正規化結尾斜線。

2.4 驗證每一個 access token

執行任何 MCP 操作前,資源伺服器必須:

  1. 只接受來自 Authorization: Bearer 標頭的 token。
  2. 從 https://auth.example.com/.well-known/jwks.json 選取金鑰,且只允許已設定的簽章演算法;不要只信任 token 本身的 alg。
  3. 驗證簽章、iss、exp,以及存在時的 nbf。
  4. 要求 Signet 的 type claim 必須為 access;拒絕 refresh token 與 ID token。
  5. 無論 aud 編碼為字串或陣列,都必須要求其包含逐位元組完全相同的資源識別碼 https://mcp.example.com。
  6. 強制執行個別操作所需的 scope 與本機授權政策。
  7. 依 JWKS 回應標頭進行快取,並安全地處理金鑰輪替。

完整的 Go、Python 與 Node.js 驗證範例請參閱 JWT 驗證。

離線 JWT 驗證本身無法得知後續的資料庫撤銷。刪除或停用 Signet 用戶端會視情況阻止後續 grant、授權碼交換或 refresh,但先前簽發的 access JWT 在 exp 前仍具有有效的密碼學驗證結果。若需要立即撤銷 access token,請加入資源伺服器拒絕清單/狀態檢查或線上 introspection 設計,並縮短 access token 的存續時間。簽章金鑰輪替的影響範圍大得多,不應作為一般的單一用戶端撤銷機制。

第 3 部分:設定 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 私鑰提交至版本控制。

資料庫、listener/TLS、proxy、health check 與其他標準部署設定不在這份 CIMD 專用片段的範圍內;請依 Signet 的設定指南完成設定。請選擇符合撤銷設計的 access-token 存續時間;範例中的短效期限可縮小離線 JWT 在簽發後遭撤銷的風險窗口。

若瀏覽器型 MCP 用戶端會從其他來源呼叫 Signet,還必須明確設定 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 的 refresh token。請求中的 RFC 8707 resource 參數會提供 access token 的 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-bit 的隨機值。PKCE code_verifier 必須另外使用至少 256-bit 的密碼學安全亂數產生,以 RFC 7636 的 unreserved 字元集編碼(不含 padding 的 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 函式庫建立 query,不要直接串接字串。將已驗證的預期 issuer、資源識別碼、state 與 code_verifier 儲存在同一筆個別流程 session 記錄中。

每一次成功或錯誤 callback 都必須:

  1. 要求 state 與該筆個別流程記錄相符。
  2. 因為 Signet 會宣告 authorization_response_iss_parameter_supported: true,所以必須要求 iss 參數。
  3. 使用逐位元組完全相同的簡單字串比較,將解碼後的 iss 與已記錄的中繼資料 issuer 進行比較。不要改變大小寫、移除預設連接埠、正規化百分比編碼,或變更結尾斜線。
  4. 若 iss 缺失或不相符,必須先拒絕,再將任何授權碼傳送至 token 端點或顯示授權錯誤。

在此請求期間,Signet 會擷取並驗證 CIMD 文件、建立或重新整理內部的影子用戶端,並顯示同意授權畫面。由於用戶端自行宣告的 client_name 無法證明身分,同意授權畫面會以文件所在網域識別該用戶端。

3.3 交換授權碼

公開用戶端使用 client_id 與 PKCE 進行驗證,而不是使用 secret:

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 必須對應到授權請求。只將回傳的 access token 傳送給 aud 指定的資源。

此 CIMD 範例在 grant_types 中宣告 refresh_token,因為用戶端能安全地儲存 refresh token。在目前的 Signet 中,實際簽發行為由 ENABLE_REFRESH_TOKENS 控制;offline_access 可作為對使用者安全的 scope 使用,但 Signet 不會宣告它,也不要求用戶端必須提供它才會簽發 refresh token。MCP 用戶端只有在授權伺服器中繼資料宣告 offline_access 時才應要求該 scope,且絕不能假設一定會回傳 refresh token。

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。

第 4 部分:中繼資料擷取的 CIDR 與 SSRF 安全性

本節與授權伺服器負責人及任何實作 CIMD 擷取器的人員相關。用戶端負責人只需確保中繼資料 hostname 的每一個 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 unique-local 網段。

請使用 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-mapped IPv6 形式才無法繞過 loopback 檢查。

4.2 為何驗證 HTTPS URL 仍不足以確保安全

授權端點會接受受使用者影響的 client_id。如果沒有網路防護,攻擊者便能讓授權伺服器向下列目標傳送 HTTPS 請求:

  • loopback 管理服務;
  • RFC 1918 或 IPv6 ULA 私有服務;
  • Kubernetes、CGNAT 或雲端執行個體中繼資料網路;
  • link-local 位址;
  • 能夠連到內嵌 IPv4 目標的 NAT64 或 6to4 位址;或
  • 透過 split-horizon DNS 公開的內部 TLS 服務。

hostname 也可能在前期安全檢查時回傳公用位址,卻在 HTTP 用戶端再次解析時回傳私有位址。這種 DNS rebinding 競態正是為何先單獨執行 LookupIP,再使用一般 http.Get 並不安全。

4.3 Signet 封鎖位址的政策

Signet 會拒絕無效、loopback、RFC 1918/ULA 私有、link-local、interface-local multicast、所有 multicast、unspecified,以及 IPv4 limited-broadcast 位址。它也會拒絕以下需要明確檢查的前綴:

前綴 原因
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 特殊用途或供應商特定的位址。請保留網路 egress firewall 作為第二層防護,並定期將政策與 IANA 特殊用途位址登錄資料進行比較。

4.4 在建立連線時強制執行判定

安全的檢查點是在 DNS 解析完成後、呼叫 connect(2) 前一刻。Go 的 net.Dialer.Control 會收到 dialer 選定的實際候選 IP,因此能拒絕每一個 A 或 AAAA 候選位址,且不會產生檢查與使用之間的競態。

以下是僅涵蓋 transport 的藍圖,並不是完整的擷取器。它反映 Signet 的連線控制措施;正式環境程式碼還必須實作程式碼後方立即列出的每一項驗證與限制,並加入適當的 metrics、結構化日誌、測試及生命週期清理:

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
        },
    }
}

圍繞此用戶端的必要擷取行為如下:

  1. 建立請求前,先要求 CIMD URL 符合規定格式。
  2. 傳送帶有 Accept: application/json 的 GET。
  3. 拒絕所有重新導向,包括相同來源的重新導向。
  4. 只接受狀態碼 200。
  5. 透過 io.LimitReader(limit + 1) 讀取,以便在不信任 Content-Length 的情況下偵測過大的回應內容。
  6. 解析 JSON,並驗證 client_id、重新導向 URI、token 驗證方法及 grant type。
  7. 只向 OAuth 用戶端回傳一般化錯誤;將已解析 IP 與連接埠細節保留在維運日誌中,以免形成網路探測管道。
  8. 快取/限制失敗請求的速率,避免重複的授權請求將伺服器變成對外請求放大器。
  9. 對從中繼資料解除參照的每一個 URL 套用相同的防護用戶端、scheme 政策、重新導向規則、大小限制及逾時,例如未來可能出現的 logo_uri、jwks_uri 或 sector_identifier_uri。

Signet 目前只會將 client_uri 儲存為描述文字,並忽略未知成員;它不會解除參照這些 URL。如果未來的實作會擷取這些 URL,即使原始 client_id 擷取已受保護,呼叫預設的 http.Get 仍會重新開啟 SSRF 攻擊路徑。

在沒有重新設計此控制措施前,請勿將 Proxy 改為 http.ProxyFromEnvironment。使用 forward proxy 時,dialer 通常只會看見 proxy 的 IP,而不是最終 URL 的目的地。proxy 必須自行解析目標,並強制只允許公用目的地及安全的 CONNECT 連接埠,否則 CIMD 應使用直接 egress。transparent sidecar、split-horizon DNS 或 NAT 也需要 egress firewall 或 NetworkPolicy 強制執行防護。

CIMD_ALLOW_PRIVATE_NETWORKS=true 會略過 Signet 的位址防護。它只適用於中繼資料來源端在 loopback 上執行的隔離本機測試。絕不要在共用開發環境或正式環境啟用此設定。

第 5 部分:快取、更新與控制

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 process 都有自己的記憶體內 CIMD 快取,因此各 replica 可能暫時保存不同項目,而 process 重新啟動只會清除該 replica 的項目。

Signet 也會將擷取失敗或無效文件快取一分鐘,以限制重複的對外請求。這是已知的 Signet 差異:CIMD draft-00 與最新草案都規定不得快取錯誤回應及無效文件。維運人員應將此視為防止放大的取捨,而不要假設 Signet 完全符合草案。

對維運人員的影響:

  • 修改中繼資料並不是即時撤銷機制。逐步部署期間請檢查每一個 replica,因為某個 process 可能已重新整理,另一個卻仍保存較舊的正向或負向項目。
  • 成功重新整理會更新由文件控制的欄位,例如名稱、URI、重新導向 URI 與 scope。
  • 重新整理不會覆寫影子用戶端由管理員控制的狀態。將某一筆 CIMD 資料設為 inactive,即使同時發生重新整理,也會是持久的授權控制開關。但目前授權流程會先解析/擷取文件,再檢查狀態,因此 inactive 不會停止對外中繼資料流量。
  • CIMD_ENABLED 是啟動時設定,不是 hot-reload 開關。將它設為 false,只有在每一個 Signet replica 都重新啟動或重新部署後才會生效。它會停止 CIMD 解析與 /oauth/authorize 上的新授權,但本身不會使已簽發的授權碼或 refresh token 失效。
  • 在 CIMD 仍啟用時刪除影子用戶端,無法形成持久封鎖:下一次授權請求可以擷取文件,並重新建立 active 資料列。
  • 資料庫刪除/撤銷無法立即讓採離線驗證的 MCP 伺服器停止接受 access JWT。除非該伺服器也強制執行線上狀態檢查或自己的拒絕清單,否則在 exp 前仍會繼續接受該 JWT。
  • 一般用戶端的管理網址會保留 UUID client_id;CIMD 用戶端則使用數字資料庫 id(/admin/clients/<id>),因為 URL 形式的 client_id 無法放進單一路徑區段。兩種路由參照形式都可解析。

事件發生時的建議處置順序:

  1. 將受影響的影子用戶端設為 inactive,阻止該用戶端的新授權與後續 token 操作。保留 inactive 資料列,避免 CIMD 請求將其重新建立為 active。
  2. 透過管理流程撤銷該用戶端的 token 記錄。針對已簽發的 access JWT,還必須使用 MCP 伺服器的拒絕清單/狀態機制,或等待其短效期限到期。
  3. 若要停止對單一 URL 的對外擷取,請在 egress proxy/網路政策中封鎖該目的地;只設定 inactive 並不足夠。
  4. 若要全域關閉,請將 CIMD_ENABLED=false 部署至每一個 replica、重新啟動它們,並確認能力旗標已不存在。接著依需要撤銷/刪除影子用戶端;在功能仍啟用時刪除,仍可能被自動重新建立。
  5. 修正來源文件、DNS、TLS 或政策問題,從與 Signet 相同的 DNS 與 egress 環境進行測試,並在重新啟用前將每一個 replica 的快取納入考量。

驗證檢查清單

用戶端中繼資料負責人

  • [ ] 標準 HTTPS URL 穩定,且不經重新導向或驗證,直接回傳 200。
  • [ ] client_id 與該 URL 逐位元組完全相同。
  • [ ] 內容是有效 JSON,最好小於 5 KB,且一律低於 Signet 的 64 KiB 上限。
  • [ ] 有一至十筆完全相同的重新導向 URI。
  • [ ] token_endpoint_auth_method 為 none,且任何位置都沒有 secret。
  • [ ] 每一筆 A 與 AAAA 回應都可從公網路由,且 TLS 憑證鏈有效。
  • [ ] 快取標頭符合所需的更新間隔。

MCP 用戶端實作

  • [ ] PRM 的 resource 與使用者原本要連線的 MCP 伺服器完全相符。
  • [ ] 所選授權伺服器通過本機信任政策,且其中繼資料 issuer 完全相符。
  • [ ] 已驗證的 issuer、資源、state 與 PKCE verifier 綁定至同一筆流程記錄。
  • [ ] 在交換任何授權碼或顯示錯誤前,已驗證 callback state 與 RFC 9207 iss。
  • [ ] Refresh token 以機密方式儲存,且用戶端能容許沒有回傳 refresh token 的情況。

MCP 伺服器負責人

  • [ ] PRM 回傳完全相同的資源識別碼與受信任的 Signet issuer。
  • [ ] 憑證缺失/無效時,回傳 401 及 resource_metadata 驗證要求。
  • [ ] 需要跨來源存取時,瀏覽器用戶端可以讀取 WWW-Authenticate。
  • [ ] 驗證 token 的簽章、演算法、issuer、時間、type=access、完全相同的 audience 與必要權限。
  • [ ] 拒絕 refresh token、ID token,以及其他資源的 access token。

Signet 維運人員

  • [ ] 設定 CIMD_ENABLED=true 是明確的風險決策。
  • [ ] CIMD_ALLOWED_RESOURCES 只包含必要且完全相同的資源識別碼。
  • [ ] 正式環境中為 CIMD_ALLOW_PRIVATE_NETWORKS=false。
  • [ ] RS256/ES256 簽章金鑰與至少 32-byte 的隨機 SESSION_SECRET 來自 secret manager。
  • [ ] 已測試直接對外 HTTPS、DNS、proxy/sidecar 行為與 egress firewall 政策。
  • [ ] 監控擷取延遲、失敗、不同 CIMD URL 的數量,以及 component=cimd 警告。
  • [ ] 管理員了解如何停用或刪除 CIMD 影子用戶端。

端對端 Smoke Test

請在部署後,以及 DNS、proxy、金鑰或 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 視圖,並由 egress firewall 先行封鎖非公用目的地;絕對不可把請求提供的 URL 傳入此腳本。Curl 不會重現 Signet 的 dial-time IP guard,因此它只檢查直接 HTTP 狀態、界限與中繼資料格式,並不驗證 SSRF 政策;仍必須透過 Signet 執行真實流程。此腳本刻意不會自動執行使用者登入/同意授權,也不會假裝解碼 JWT 就等同於驗證 JWT。

  1. 從受限制的診斷環境檢查每一筆 A 與 AAAA 回應,再擷取固定的 CIMD URL 並停用重新導向。確認直接回傳 200、位址皆為公用、TLS 受信任、大小符合限制,以及 client_id 完全相同。不得用未受防護的 curl 擷取授權請求所提供的 URL。
  2. 擷取 MCP 伺服器的 PRM。確認其中的 resource 與 authorization_servers 值和部署計畫完全相符。
  3. 擷取 Signet 授權伺服器中繼資料。確認完全相同的 issuer、預期端點、authorization_response_iss_parameter_supported: true,以及 client_id_metadata_document_supported: true。
  4. 實際執行授權碼 + S256 PKCE 流程。驗證 callback state 與 iss、使用相同的 resource 交換授權碼,且絕不要記錄授權碼、verifier 或 token。
  5. 以密碼學方式驗證 access JWT,並確認 type=access 及完全相同的 iss 與 aud。未驗證簽章的解碼只能用於檢視,絕不能據以接受 token。
  6. 不帶 token 呼叫 MCP 伺服器,並預期收到能識別 PRM 的 401;使用有效 token 呼叫並預期成功;測試一條權限不足的路徑,並預期收到 403 驗證要求。
  7. 針對瀏覽器用戶端,在瀏覽器中執行相同流程,並確認 CORS 會在 401 與 403 回應中公開 WWW-Authenticate。

疑難排解

症狀 可能原因 檢查項目
unauthorized_client CIMD 已停用、URL 格式不被接受,或影子用戶端為 inactive 能力中繼資料、CIMD_ENABLED、HTTPS host/path、管理員設定的狀態
授權期間出現 invalid_client 擷取失敗或文件驗證失敗 Signet component=cimd 日誌、直接回傳 200、TLS、大小、JSON、完全相同的 client_id、重新導向清單
invalid_target 資源不在全域允許清單中,或有逐位元組差異 PRM resource、請求值與 CIMD_ALLOWED_RESOURCES,包括結尾斜線
invalid_scope 或缺少自訂權限 目前的 CIMD scope 限制移除了自訂 scope 僅使用對使用者安全的集合,或改用預先註冊的用戶端
inactive 用戶端仍產生擷取日誌 系統會先解析文件,再檢查狀態;inactive 只會封鎖授權,不會封鎖 egress 維持 inactive;若必須停止擷取,還需加入目的地 egress 封鎖
文件修改似乎遭到忽略,或不同 pod 的結果不一 個別 process 的正向/負向快取項目仍在有效期內 每一個 replica 的日誌、Cache-Control max-age、CIMD_CACHE_TTL、一分鐘下限;重新啟動只會清除該 process
在 loopback 可運作,但正式環境無法運作 所有可用回應都是非公用/特殊用途位址、不同 DNS view 的回應不一,或 TLS 不受信任 從 Signet pod 檢查所有 A/AAAA 回應、split-horizon DNS、憑證鏈、SSRF 日誌
全域關閉 CIMD 後 refresh 仍可運作 此旗標限制的是授權期間的解析,而不是先前簽發的憑證 停用/撤銷影子用戶端,並在 MCP 伺服器套用 access JWT 狀態政策
瀏覽器無法探索驗證要求 CORS 隱藏了 WWW-Authenticate MCP 伺服器的 Access-Control-Expose-Headers 與允許的來源
Token 有效,但 MCP 回傳 401 issuer、演算法、type 或 audience 驗證失敗 解碼後的 claim、JWKS 金鑰選擇、完全相同的資源 ID;不要記錄原始 token

參考資料