fix: unify strict spiffe identity validation

This commit is contained in:
youfak 2026-08-07 20:16:46 +08:00
parent e318ddbb54
commit 57013dd6b2
7 changed files with 63 additions and 17 deletions

View File

@ -242,7 +242,8 @@ Gateway 不复用 `controlPlane.listen` 作为客户端地址。`listen` 是 Con
`controlPlane.tls.mode=mtls`Gateway 使用 `controlPlane.gatewayTLS` 中独立的 `controlPlane.tls.mode=mtls`Gateway 使用 `controlPlane.gatewayTLS` 中独立的
客户端证书、私钥和 Controller CA 发起 TLS 1.3 连接;证书必须符合 Controller 的 客户端证书、私钥和 Controller CA 发起 TLS 1.3 连接;证书必须符合 Controller 的
SPIFFE Worker 身份校验。 SPIFFE Worker 身份校验。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI
不得包含用户信息、端口、查询、片段或转义路径。
`disabled` 仅接受回环控制面地址,供本地 fixture 使用。 `disabled` 仅接受回环控制面地址,供本地 fixture 使用。
## 10. Checker 启动参数 ## 10. Checker 启动参数

View File

@ -8,6 +8,8 @@ import (
"google.golang.org/grpc/credentials" "google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer" "google.golang.org/grpc/peer"
"proxy-pool/internal/domain/workerruntime"
) )
var ErrUnauthorizedIdentity = errors.New("control-plane identity is not authorized") var ErrUnauthorizedIdentity = errors.New("control-plane identity is not authorized")
@ -57,22 +59,18 @@ func (authorizer *SPIFFEIdentityAuthorizer) authorize(ctx context.Context, resou
return ErrUnauthorizedIdentity return ErrUnauthorizedIdentity
} }
expectedPath := "/" + authorizer.environment + "/" + resourceType + "/" + resourceID
identityCount := 0 identityCount := 0
for _, chain := range tlsInfo.State.VerifiedChains { for _, chain := range tlsInfo.State.VerifiedChains {
if len(chain) == 0 || chain[0] == nil { if len(chain) == 0 || chain[0] == nil {
continue continue
} }
for _, uri := range chain[0].URIs { for _, uri := range chain[0].URIs {
if uri == nil || uri.Scheme != "spiffe" || uri.Host != authorizer.trustDomain { identity, matches := workerruntime.SPIFFEIdentity(uri, authorizer.trustDomain, authorizer.environment, resourceType)
continue if !matches {
}
segments := strings.Split(strings.Trim(uri.Path, "/"), "/")
if len(segments) != 3 || segments[1] != resourceType {
continue continue
} }
identityCount++ identityCount++
if identityCount > 1 || uri.RawQuery != "" || uri.Fragment != "" || uri.Path != expectedPath { if identityCount > 1 || identity != resourceID {
return ErrUnauthorizedIdentity return ErrUnauthorizedIdentity
} }
} }

View File

@ -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 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 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: "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}, {name: "missing peer tls", workerID: "worker-a", wantError: true},
} }

View File

@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
"strings"
"proxy-pool/internal/domain/workerruntime" "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) { 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 workerruntime.SPIFFEIdentity(uri, trustDomain, environment, role)
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
} }

View File

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

View File

@ -3,10 +3,39 @@ package workerruntime
import ( import (
"crypto/sha256" "crypto/sha256"
"errors" "errors"
"net/url"
"testing" "testing"
"time" "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) { func TestNormalizeLabelsClonesAndBoundsValues(t *testing.T) {
source := map[string]string{"region": "cn-north"} source := map[string]string{"region": "cn-north"}
labels, err := NormalizeLabels(source) labels, err := NormalizeLabels(source)

View File

@ -6,6 +6,8 @@
`PROXY_POOL_AUTO_IDENTITY=true` 时只接受与信任域、环境和角色精确匹配的唯一 SPIFFE URI `PROXY_POOL_AUTO_IDENTITY=true` 时只接受与信任域、环境和角色精确匹配的唯一 SPIFFE URI
并在显式 ID 不匹配时拒绝启动。Compose 与 Kubernetes 开发 mTLS Overlay 已移除手工 ID 并在显式 ID 不匹配时拒绝启动。Compose 与 Kubernetes 开发 mTLS Overlay 已移除手工 ID
改由挂载证书派生;定向 Go、文档、Compose、Kustomize 与严格配置校验均通过。 改由挂载证书派生;定向 Go、文档、Compose、Kustomize 与严格配置校验均通过。
- SPIFFE URI 解析已下沉到 `workerruntime.SPIFFEIdentity`Controller 授权和客户端
自动派生共用同一边界;用户信息、端口、查询、片段、转义路径和非精确角色路径均拒绝。
- Gateway 静态 `routing.action: direct` 已贯通严格配置、控制面协议、完整 Snapshot、 - Gateway 静态 `routing.action: direct` 已贯通严格配置、控制面协议、完整 Snapshot、
HTTP/CONNECT 转发和目标地址策略。直连请求不会申请 Proxy 容量、创建粘性绑定或上报 HTTP/CONNECT 转发和目标地址策略。直连请求不会申请 Proxy 容量、创建粘性绑定或上报
Proxy Outcome`onUnavailable.action: direct` 仍保留为代理无候选时的独立回退语义。 Proxy Outcome`onUnavailable.action: direct` 仍保留为代理无候选时的独立回退语义。