From a92dcab3332e93e13239d30f931f009ee2e5976e Mon Sep 17 00:00:00 2001 From: youfak Date: Fri, 7 Aug 2026 20:48:09 +0800 Subject: [PATCH] fix: require single uri san for spiffe identity --- docs/api/control-plane.md | 3 +- docs/configuration/reference.md | 6 +-- docs/requirements/completion-audit.md | 6 ++- findings.md | 11 ++++++ internal/controller/worker/identity.go | 23 ++++------- internal/controller/worker/identity_test.go | 39 +++++++++++++++++++ internal/controlplane/tlsreload/identity.go | 19 +-------- .../controlplane/tlsreload/identity_test.go | 10 +++++ internal/domain/workerruntime/spiffe.go | 9 +++++ .../domain/workerruntime/validation_test.go | 17 ++++++++ progress.md | 9 ++++- task_plan.md | 5 ++- 12 files changed, 114 insertions(+), 43 deletions(-) diff --git a/docs/api/control-plane.md b/docs/api/control-plane.md index e68da05..db494aa 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 身份校验。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI +SPIFFE Worker 身份校验。客户端 X.509-SVID 叶证书必须且只能包含一个 URI SAN,且该 URI +必须是对应角色的身份。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI 不得包含用户信息、端口、查询、片段或转义路径。 `disabled` 仅接受回环控制面地址,供本地 fixture 使用。 diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 76ef5f4..b335dc9 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -300,8 +300,8 @@ controlPlane: - `gatewayTLS` 是 Gateway 的客户端证书、私钥和 Controller CA,与 `tls` 的服务端 证书和 Worker CA 分离。三项可以同时省略(未运行 Gateway),配置任一项时必须完整提供。 - `checkerTLS` 与 `gatewayTLS` 有相同字段和完整性校验,但必须使用独立的 Checker - 证书。Controller 分别验证 `.../worker/` 与 - `.../checker/` SPIFFE URI,不能跨角色复用证书。 + 证书。每个客户端 X.509-SVID 叶证书必须且只能包含一个 URI SAN;Controller 分别验证 + `.../worker/` 与 `.../checker/` SPIFFE URI,不能跨角色复用证书。 - Controller、Gateway 与 Checker 会在每个新的控制面 TLS 握手读取最新叶证书、私钥和 信任根;文件暂时不可读时继续使用最后一次有效材料。既有 gRPC 流不强制重握手,根 CA 轮换仍需先发布包含新旧根的重叠信任包,并在宽限期后滚动排空旧连接。 @@ -328,7 +328,7 @@ Routing 也不会再产生其 TARGET 任务;revision 不一致或状态不完 上游/路由启停、有效 `check` 策略和目标列表会在下一轮生效, 新启用的上游无需重启 Controller。 -`PROXY_POOL_AUTO_IDENTITY=true` 对 Checker 使用同样规则:仅接受 `checkerTLS` 中唯一的 +`PROXY_POOL_AUTO_IDENTITY=true` 对 Checker 使用同样规则:仅接受 `checkerTLS` 中唯一且仅有的 `spiffe:////checker/` URI,并在未显式设置时复用其 `checker-id` 作为实例 ID。 diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index 2ecb5e0..7a03503 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -106,8 +106,10 @@ mTLS 运行拓扑,并由配置、证书、命令装配和部署契约测试覆 启用 Checker;`development-mtls` Overlay 已为单副本 Controller、Gateway 和 Checker 提供固定开发证书、运行时身份派生、mTLS Secret 挂载、最小出站 NetworkPolicy 与渲染/配置契约验证。 Controller、Gateway 与 Checker 的叶证书和信任根已在每个新 TLS 握手时重载,文件短暂 -不一致时保留最后一次有效材料。生产环境仍必须通过独立 Overlay 为每个弹性 Worker 注入 -唯一 SPIFFE URI,且容器端到端启动仍待具备镜像网络的环境验证。 +不一致时保留最后一次有效材料。Gateway、Checker 启动派生和 Controller 授权共用同一 +SPIFFE URI 解析器:X.509-SVID 叶证书必须且只能包含一个 URI SAN,并精确匹配其角色。 +生产环境仍必须通过独立 Overlay 为每个弹性 Worker 注入唯一 SPIFFE URI,且容器端到端 +启动仍待具备镜像网络的环境验证。 运维手册已覆盖管理面/活动池恢复顺序、发布与兼容回滚、Secret 轮换、容量计算,以及 主机内核、文件描述符、NAT/conntrack 的只读基线和证据采集要求;这些文档不替代真实环境 diff --git a/findings.md b/findings.md index b21d59b..358dd59 100644 --- a/findings.md +++ b/findings.md @@ -250,3 +250,14 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两 - 生产 `docker-compose.yml` 仍把 PostgreSQL 18 命名卷挂在旧路径 `/var/lib/postgresql/data`;直接修改可能影响已有本地数据,必须配套迁移步骤后 单独处理,当前不能把生产持久化拓扑视为已验证。 + +## 控制面 mTLS 身份审计(2026-08-07) + +- X.509-SVID 叶证书承载单一工作负载身份;Gateway/Checker 启动时的自动身份派生与 + Controller 授权必须都拒绝包含多个 URI SAN 的证书,即使其中只有一个 URI 与当前角色匹配。 +- 解析边界已统一到 `workerruntime.SingleSPIFFEIdentity`:精确匹配信任域、环境、角色和 + 标识符,拒绝用户信息、端口、查询、片段、转义路径、额外 URI SAN 与跨角色复用。 +- TLS 校验可能形成多条等价验证链;授权只检查同一叶证书一次,不能将链路数误判为多个身份。 +- Windows 本地 Go 运行环境为 `CGO_ENABLED=0` 且没有 C 编译器;Docker Engine 可用,但 + `golang:1.26-bookworm` 与 `debian:bookworm-slim` 未缓存,Docker Desktop HTTPS 代理也不可用。 + 因此已完成 Go 全量、Compose 静态和 Kustomize 静态验证,容器端到端验证仍待具备镜像网络的环境。 diff --git a/internal/controller/worker/identity.go b/internal/controller/worker/identity.go index 200145f..7d90213 100644 --- a/internal/controller/worker/identity.go +++ b/internal/controller/worker/identity.go @@ -59,23 +59,14 @@ func (authorizer *SPIFFEIdentityAuthorizer) authorize(ctx context.Context, resou return ErrUnauthorizedIdentity } - identityCount := 0 - for _, chain := range tlsInfo.State.VerifiedChains { - if len(chain) == 0 || chain[0] == nil { - continue - } - for _, uri := range chain[0].URIs { - identity, matches := workerruntime.SPIFFEIdentity(uri, authorizer.trustDomain, authorizer.environment, resourceType) - if !matches { - continue - } - identityCount++ - if identityCount > 1 || identity != resourceID { - return ErrUnauthorizedIdentity - } - } + chain := tlsInfo.State.VerifiedChains[0] + if len(chain) == 0 || chain[0] == nil { + return ErrUnauthorizedIdentity } - if identityCount != 1 { + identity, matches := workerruntime.SingleSPIFFEIdentity( + chain[0].URIs, authorizer.trustDomain, authorizer.environment, resourceType, + ) + if !matches || identity != resourceID { return ErrUnauthorizedIdentity } return nil diff --git a/internal/controller/worker/identity_test.go b/internal/controller/worker/identity_test.go index 75aaf94..ada7d91 100644 --- a/internal/controller/worker/identity_test.go +++ b/internal/controller/worker/identity_test.go @@ -68,6 +68,45 @@ func TestSPIFFEIdentityAuthorizerRejectsMultipleWorkerURIs(t *testing.T) { } } +func TestSPIFFEIdentityAuthorizerRejectsMultipleURISANsAcrossRoles(t *testing.T) { + authorizer, err := NewSPIFFEIdentityAuthorizer("proxy.example", "prod") + if err != nil { + t.Fatalf("NewSPIFFEIdentityAuthorizer(): %v", err) + } + workerURI, err := url.Parse("spiffe://proxy.example/prod/worker/worker-a") + if err != nil { + t.Fatal(err) + } + checkerURI, err := url.Parse("spiffe://proxy.example/prod/checker/checker-a") + if err != nil { + t.Fatal(err) + } + ctx := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{{URIs: []*url.URL{workerURI, checkerURI}}}}}, + }}) + if err := authorizer.Authorize(ctx, "worker-a"); err == nil { + t.Fatal("Authorize() error = nil, want rejection for multiple URI SANs") + } +} + +func TestSPIFFEIdentityAuthorizerAcceptsEquivalentVerifiedChains(t *testing.T) { + authorizer, err := NewSPIFFEIdentityAuthorizer("proxy.example", "prod") + if err != nil { + t.Fatalf("NewSPIFFEIdentityAuthorizer(): %v", err) + } + uri, err := url.Parse("spiffe://proxy.example/prod/worker/worker-a") + if err != nil { + t.Fatal(err) + } + leaf := &x509.Certificate{URIs: []*url.URL{uri}} + ctx := peer.NewContext(context.Background(), &peer.Peer{AuthInfo: credentials.TLSInfo{ + State: tls.ConnectionState{VerifiedChains: [][]*x509.Certificate{{leaf}, {leaf}}}, + }}) + if err := authorizer.Authorize(ctx, "worker-a"); err != nil { + t.Fatalf("Authorize() error = %v, want nil for equivalent verified chains", err) + } +} + func TestSPIFFEIdentityAuthorizerAuthorizesCheckerOnlyForMatchingResource(t *testing.T) { authorizer, err := NewSPIFFEIdentityAuthorizer("proxy.example", "prod") if err != nil { diff --git a/internal/controlplane/tlsreload/identity.go b/internal/controlplane/tlsreload/identity.go index 3115742..57f9179 100644 --- a/internal/controlplane/tlsreload/identity.go +++ b/internal/controlplane/tlsreload/identity.go @@ -5,7 +5,6 @@ import ( "crypto/x509" "errors" "fmt" - "net/url" "proxy-pool/internal/domain/workerruntime" ) @@ -27,23 +26,9 @@ func ResolveSPIFFEIdentity(certificateFile, keyFile, trustDomain, environment, r if err != nil { return "", fmt.Errorf("%w: parse client certificate", ErrInvalidSPIFFEIdentity) } - var identity string - for _, uri := range leaf.URIs { - candidate, ok := spiffeIdentity(uri, trustDomain, environment, role) - if !ok { - continue - } - if identity != "" { - return "", ErrInvalidSPIFFEIdentity - } - identity = candidate - } - if !workerruntime.ValidIdentifier(identity) { + identity, ok := workerruntime.SingleSPIFFEIdentity(leaf.URIs, trustDomain, environment, role) + if !ok { return "", ErrInvalidSPIFFEIdentity } return identity, nil } - -func spiffeIdentity(uri *url.URL, trustDomain, environment, role string) (string, bool) { - return workerruntime.SPIFFEIdentity(uri, trustDomain, environment, role) -} diff --git a/internal/controlplane/tlsreload/identity_test.go b/internal/controlplane/tlsreload/identity_test.go index 9060f29..ac3c056 100644 --- a/internal/controlplane/tlsreload/identity_test.go +++ b/internal/controlplane/tlsreload/identity_test.go @@ -36,6 +36,16 @@ func TestResolveSPIFFEIdentityRejectsMultipleMatchingURIs(t *testing.T) { } } +func TestResolveSPIFFEIdentityRejectsMultipleURISANsAcrossRoles(t *testing.T) { + certificatePath, keyPath := writeWorkloadCertificate(t, "gateway-a", + "spiffe://proxy-pool.local/production/worker/gateway-a", + "spiffe://proxy-pool.local/production/checker/checker-a", + ) + if _, err := ResolveSPIFFEIdentity(certificatePath, keyPath, "proxy-pool.local", "production", "worker"); err == nil { + t.Fatal("ResolveSPIFFEIdentity(multiple URI SANs) error = nil") + } +} + func writeWorkloadCertificate(t *testing.T, commonName string, identityURIs ...string) (string, string) { t.Helper() directory := t.TempDir() diff --git a/internal/domain/workerruntime/spiffe.go b/internal/domain/workerruntime/spiffe.go index 1e9a7b2..713048b 100644 --- a/internal/domain/workerruntime/spiffe.go +++ b/internal/domain/workerruntime/spiffe.go @@ -20,3 +20,12 @@ func SPIFFEIdentity(uri *url.URL, trustDomain, environment, role string) (string } return segments[3], true } + +// SingleSPIFFEIdentity validates the X.509-SVID requirement that a workload +// certificate carries exactly one URI SAN before extracting its identity. +func SingleSPIFFEIdentity(uris []*url.URL, trustDomain, environment, role string) (string, bool) { + if len(uris) != 1 { + return "", false + } + return SPIFFEIdentity(uris[0], trustDomain, environment, role) +} diff --git a/internal/domain/workerruntime/validation_test.go b/internal/domain/workerruntime/validation_test.go index c851bdc..5ef8d17 100644 --- a/internal/domain/workerruntime/validation_test.go +++ b/internal/domain/workerruntime/validation_test.go @@ -36,6 +36,23 @@ func TestSPIFFEIdentityAcceptsOnlyExactWorkloadURI(t *testing.T) { } } +func TestSingleSPIFFEIdentityRejectsMultipleURISANs(t *testing.T) { + worker, err := url.Parse("spiffe://proxy.example/prod/worker/worker-a") + if err != nil { + t.Fatal(err) + } + checker, err := url.Parse("spiffe://proxy.example/prod/checker/checker-a") + if err != nil { + t.Fatal(err) + } + if _, ok := SingleSPIFFEIdentity([]*url.URL{worker, checker}, "proxy.example", "prod", "worker"); ok { + t.Fatal("SingleSPIFFEIdentity() accepted multiple URI SANs") + } + if got, ok := SingleSPIFFEIdentity([]*url.URL{worker}, "proxy.example", "prod", "worker"); !ok || got != "worker-a" { + t.Fatalf("SingleSPIFFEIdentity() = (%q, %t), want (worker-a, true)", got, ok) + } +} + 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 b565cf9..39e6413 100644 --- a/progress.md +++ b/progress.md @@ -2,12 +2,17 @@ ## 2026-08-07 +- 运行验证环境探测:Windows Go 为 `CGO_ENABLED=0`,系统没有可用 C 编译器;WSL 仅安装 + `docker-desktop` 发行版。Docker Engine 可用,但 Dockerfile 所需的 + `golang:1.26-bookworm`、`debian:bookworm-slim` 未缓存,且 Docker Desktop HTTPS 代理不可用; + 容器端到端验证尚未完成。 - 控制面 mTLS 身份新增公共 `tlsreload.ResolveSPIFFEIdentity`:Gateway 和 Checker 在 `PROXY_POOL_AUTO_IDENTITY=true` 时只接受与信任域、环境和角色精确匹配的唯一 SPIFFE URI, 并在显式 ID 不匹配时拒绝启动。Compose 与 Kubernetes 开发 mTLS Overlay 已移除手工 ID, 改由挂载证书派生;定向 Go、文档、Compose、Kustomize 与严格配置校验均通过。 -- SPIFFE URI 解析已下沉到 `workerruntime.SPIFFEIdentity`,Controller 授权和客户端 - 自动派生共用同一边界;用户信息、端口、查询、片段、转义路径和非精确角色路径均拒绝。 +- SPIFFE URI 解析已下沉到 `workerruntime.SingleSPIFFEIdentity`,Controller 授权和客户端 + 自动派生共用同一边界。X.509-SVID 叶证书必须且只能包含一个 URI SAN;用户信息、端口、查询、 + 片段、转义路径、非精确角色路径和跨角色多 URI SAN 均拒绝。 - Gateway 静态 `routing.action: direct` 已贯通严格配置、控制面协议、完整 Snapshot、 HTTP/CONNECT 转发和目标地址策略。直连请求不会申请 Proxy 容量、创建粘性绑定或上报 Proxy Outcome;`onUnavailable.action: direct` 仍保留为代理无候选时的独立回退语义。 diff --git a/task_plan.md b/task_plan.md index e32fce2..51fc45b 100644 --- a/task_plan.md +++ b/task_plan.md @@ -52,8 +52,9 @@ 收敛。Health Scheduler 现以相同 revision 合并配置与管理态启用状态,管理态停用会停止 该 Upstream 的新 BASIC/EGRESS/TARGET 任务,停用 Routing 仅停止其新 TARGET 任务。 Gateway 请求生命周期直方图、Grafana 仪表盘、指标契约检查和 p99 延迟告警已完成; - Gateway/Checker 现可通过 `PROXY_POOL_AUTO_IDENTITY=true` 从唯一角色 SPIFFE URI - 派生逻辑与缺省实例 ID;Compose 与开发 mTLS Overlay 已使用该公共路径。后续以真实容器、 + Gateway/Checker 现可通过 `PROXY_POOL_AUTO_IDENTITY=true` 从仅含一个 URI SAN 的角色 + SPIFFE X.509-SVID 派生逻辑与缺省实例 ID;Controller 授权复用同一校验。Compose 与开发 + mTLS Overlay 已使用该公共路径。后续以真实容器、 集群故障演练和代表性负载报告完成运行态验证闭环。 ## 串并行关系