diff --git a/docs/api/control-plane.md b/docs/api/control-plane.md index e576b48..e68da05 100644 --- a/docs/api/control-plane.md +++ b/docs/api/control-plane.md @@ -242,7 +242,8 @@ Gateway 不复用 `controlPlane.listen` 作为客户端地址。`listen` 是 Con 当 `controlPlane.tls.mode=mtls` 时,Gateway 使用 `controlPlane.gatewayTLS` 中独立的 客户端证书、私钥和 Controller CA 发起 TLS 1.3 连接;证书必须符合 Controller 的 -SPIFFE Worker 身份校验。 +SPIFFE Worker 身份校验。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI +不得包含用户信息、端口、查询、片段或转义路径。 `disabled` 仅接受回环控制面地址,供本地 fixture 使用。 ## 10. Checker 启动参数 diff --git a/internal/controller/worker/identity.go b/internal/controller/worker/identity.go index 3e49ddc..200145f 100644 --- a/internal/controller/worker/identity.go +++ b/internal/controller/worker/identity.go @@ -8,6 +8,8 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/peer" + + "proxy-pool/internal/domain/workerruntime" ) var ErrUnauthorizedIdentity = errors.New("control-plane identity is not authorized") @@ -57,22 +59,18 @@ func (authorizer *SPIFFEIdentityAuthorizer) authorize(ctx context.Context, resou return ErrUnauthorizedIdentity } - expectedPath := "/" + authorizer.environment + "/" + resourceType + "/" + resourceID identityCount := 0 for _, chain := range tlsInfo.State.VerifiedChains { if len(chain) == 0 || chain[0] == nil { continue } for _, uri := range chain[0].URIs { - if uri == nil || uri.Scheme != "spiffe" || uri.Host != authorizer.trustDomain { - continue - } - segments := strings.Split(strings.Trim(uri.Path, "/"), "/") - if len(segments) != 3 || segments[1] != resourceType { + identity, matches := workerruntime.SPIFFEIdentity(uri, authorizer.trustDomain, authorizer.environment, resourceType) + if !matches { continue } identityCount++ - if identityCount > 1 || uri.RawQuery != "" || uri.Fragment != "" || uri.Path != expectedPath { + if identityCount > 1 || identity != resourceID { return ErrUnauthorizedIdentity } } diff --git a/internal/controller/worker/identity_test.go b/internal/controller/worker/identity_test.go index b434be1..75aaf94 100644 --- a/internal/controller/worker/identity_test.go +++ b/internal/controller/worker/identity_test.go @@ -28,6 +28,8 @@ func TestSPIFFEIdentityAuthorizer(t *testing.T) { {name: "different worker", workerID: "worker-a", uri: "spiffe://proxy.example/prod/worker/worker-b", withTLS: true, wantError: true}, {name: "different environment", workerID: "worker-a", uri: "spiffe://proxy.example/staging/worker/worker-a", withTLS: true, wantError: true}, {name: "different trust domain", workerID: "worker-a", uri: "spiffe://other.example/prod/worker/worker-a", withTLS: true, wantError: true}, + {name: "uri query is rejected", workerID: "worker-a", uri: "spiffe://proxy.example/prod/worker/worker-a?scope=ignored", withTLS: true, wantError: true}, + {name: "uri user info is rejected", workerID: "worker-a", uri: "spiffe://worker@proxy.example/prod/worker/worker-a", withTLS: true, wantError: true}, {name: "missing peer tls", workerID: "worker-a", wantError: true}, } diff --git a/internal/controlplane/tlsreload/identity.go b/internal/controlplane/tlsreload/identity.go index eced2bc..3115742 100644 --- a/internal/controlplane/tlsreload/identity.go +++ b/internal/controlplane/tlsreload/identity.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "net/url" - "strings" "proxy-pool/internal/domain/workerruntime" ) @@ -46,12 +45,5 @@ func ResolveSPIFFEIdentity(certificateFile, keyFile, trustDomain, environment, r } func spiffeIdentity(uri *url.URL, trustDomain, environment, role string) (string, bool) { - if uri == nil || uri.Scheme != "spiffe" || uri.Host != trustDomain || uri.RawQuery != "" || uri.Fragment != "" { - return "", false - } - segments := strings.Split(strings.Trim(uri.Path, "/"), "/") - if len(segments) != 3 || segments[0] != environment || segments[1] != role || !workerruntime.ValidIdentifier(segments[2]) { - return "", false - } - return segments[2], true + return workerruntime.SPIFFEIdentity(uri, trustDomain, environment, role) } diff --git a/internal/domain/workerruntime/spiffe.go b/internal/domain/workerruntime/spiffe.go new file mode 100644 index 0000000..1e9a7b2 --- /dev/null +++ b/internal/domain/workerruntime/spiffe.go @@ -0,0 +1,22 @@ +package workerruntime + +import ( + "net/url" + "strings" +) + +// SPIFFEIdentity returns the ID from an exact workload SPIFFE URI SAN. The +// control plane accepts no URI decorations so certificate parsing and server +// authorization use the same identity boundary. +func SPIFFEIdentity(uri *url.URL, trustDomain, environment, role string) (string, bool) { + if uri == nil || trustDomain == "" || environment == "" || (role != "worker" && role != "checker") || + uri.Scheme != "spiffe" || uri.Host != trustDomain || uri.User != nil || uri.Port() != "" || + uri.Opaque != "" || uri.RawPath != "" || uri.RawQuery != "" || uri.ForceQuery || uri.Fragment != "" { + return "", false + } + segments := strings.Split(uri.Path, "/") + if len(segments) != 4 || segments[0] != "" || segments[1] != environment || segments[2] != role || !ValidIdentifier(segments[3]) { + return "", false + } + return segments[3], true +} diff --git a/internal/domain/workerruntime/validation_test.go b/internal/domain/workerruntime/validation_test.go index 0f30a39..c851bdc 100644 --- a/internal/domain/workerruntime/validation_test.go +++ b/internal/domain/workerruntime/validation_test.go @@ -3,10 +3,39 @@ package workerruntime import ( "crypto/sha256" "errors" + "net/url" "testing" "time" ) +func TestSPIFFEIdentityAcceptsOnlyExactWorkloadURI(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "exact worker", raw: "spiffe://proxy.example/prod/worker/worker-a", want: "worker-a"}, + {name: "checker is not worker", raw: "spiffe://proxy.example/prod/checker/checker-a"}, + {name: "query", raw: "spiffe://proxy.example/prod/worker/worker-a?scope=ignored"}, + {name: "user info", raw: "spiffe://worker@proxy.example/prod/worker/worker-a"}, + {name: "port", raw: "spiffe://proxy.example:8443/prod/worker/worker-a"}, + {name: "trailing slash", raw: "spiffe://proxy.example/prod/worker/worker-a/"}, + {name: "escaped path", raw: "spiffe://proxy.example/prod/worker/worker%2Da"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + uri, err := url.Parse(test.raw) + if err != nil { + t.Fatal(err) + } + got, ok := SPIFFEIdentity(uri, "proxy.example", "prod", "worker") + if (test.want != "") != ok || got != test.want { + t.Fatalf("SPIFFEIdentity() = (%q, %t), want (%q, %t)", got, ok, test.want, test.want != "") + } + }) + } +} + func TestNormalizeLabelsClonesAndBoundsValues(t *testing.T) { source := map[string]string{"region": "cn-north"} labels, err := NormalizeLabels(source) diff --git a/progress.md b/progress.md index b5a1f3d..b565cf9 100644 --- a/progress.md +++ b/progress.md @@ -6,6 +6,8 @@ `PROXY_POOL_AUTO_IDENTITY=true` 时只接受与信任域、环境和角色精确匹配的唯一 SPIFFE URI, 并在显式 ID 不匹配时拒绝启动。Compose 与 Kubernetes 开发 mTLS Overlay 已移除手工 ID, 改由挂载证书派生;定向 Go、文档、Compose、Kustomize 与严格配置校验均通过。 +- SPIFFE URI 解析已下沉到 `workerruntime.SPIFFEIdentity`,Controller 授权和客户端 + 自动派生共用同一边界;用户信息、端口、查询、片段、转义路径和非精确角色路径均拒绝。 - Gateway 静态 `routing.action: direct` 已贯通严格配置、控制面协议、完整 Snapshot、 HTTP/CONNECT 转发和目标地址策略。直连请求不会申请 Proxy 容量、创建粘性绑定或上报 Proxy Outcome;`onUnavailable.action: direct` 仍保留为代理无候选时的独立回退语义。