fix: require single uri san for spiffe identity

This commit is contained in:
youfak 2026-08-07 20:48:09 +08:00
parent 57013dd6b2
commit a92dcab333
12 changed files with 114 additions and 43 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 身份校验。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI SPIFFE Worker 身份校验。客户端 X.509-SVID 叶证书必须且只能包含一个 URI SAN且该 URI
必须是对应角色的身份。Gateway 自动解析和 Controller 授权共用严格 URI 规则:身份 URI
不得包含用户信息、端口、查询、片段或转义路径。 不得包含用户信息、端口、查询、片段或转义路径。
`disabled` 仅接受回环控制面地址,供本地 fixture 使用。 `disabled` 仅接受回环控制面地址,供本地 fixture 使用。

View File

@ -300,8 +300,8 @@ controlPlane:
- `gatewayTLS` 是 Gateway 的客户端证书、私钥和 Controller CA`tls` 的服务端 - `gatewayTLS` 是 Gateway 的客户端证书、私钥和 Controller CA`tls` 的服务端
证书和 Worker CA 分离。三项可以同时省略(未运行 Gateway配置任一项时必须完整提供。 证书和 Worker CA 分离。三项可以同时省略(未运行 Gateway配置任一项时必须完整提供。
- `checkerTLS``gatewayTLS` 有相同字段和完整性校验,但必须使用独立的 Checker - `checkerTLS``gatewayTLS` 有相同字段和完整性校验,但必须使用独立的 Checker
证书。Controller 分别验证 `.../worker/<worker-id>` 证书。每个客户端 X.509-SVID 叶证书必须且只能包含一个 URI SANController 分别验证
`.../checker/<checker-id>` SPIFFE URI不能跨角色复用证书。 `.../worker/<worker-id>` 与 `.../checker/<checker-id>` SPIFFE URI不能跨角色复用证书。
- Controller、Gateway 与 Checker 会在每个新的控制面 TLS 握手读取最新叶证书、私钥和 - Controller、Gateway 与 Checker 会在每个新的控制面 TLS 握手读取最新叶证书、私钥和
信任根;文件暂时不可读时继续使用最后一次有效材料。既有 gRPC 流不强制重握手,根 CA 信任根;文件暂时不可读时继续使用最后一次有效材料。既有 gRPC 流不强制重握手,根 CA
轮换仍需先发布包含新旧根的重叠信任包,并在宽限期后滚动排空旧连接。 轮换仍需先发布包含新旧根的重叠信任包,并在宽限期后滚动排空旧连接。
@ -328,7 +328,7 @@ Routing 也不会再产生其 TARGET 任务revision 不一致或状态不完
上游/路由启停、有效 `check` 策略和目标列表会在下一轮生效, 上游/路由启停、有效 `check` 策略和目标列表会在下一轮生效,
新启用的上游无需重启 Controller。 新启用的上游无需重启 Controller。
`PROXY_POOL_AUTO_IDENTITY=true` 对 Checker 使用同样规则:仅接受 `checkerTLS` 中唯一的 `PROXY_POOL_AUTO_IDENTITY=true` 对 Checker 使用同样规则:仅接受 `checkerTLS` 中唯一且仅有
`spiffe://<trust-domain>/<environment>/checker/<checker-id>` URI并在未显式设置时复用其 `spiffe://<trust-domain>/<environment>/checker/<checker-id>` URI并在未显式设置时复用其
`checker-id` 作为实例 ID。 `checker-id` 作为实例 ID。

View File

@ -106,8 +106,10 @@ mTLS 运行拓扑,并由配置、证书、命令装配和部署契约测试覆
启用 Checker`development-mtls` Overlay 已为单副本 Controller、Gateway 和 Checker 启用 Checker`development-mtls` Overlay 已为单副本 Controller、Gateway 和 Checker
提供固定开发证书、运行时身份派生、mTLS Secret 挂载、最小出站 NetworkPolicy 与渲染/配置契约验证。 提供固定开发证书、运行时身份派生、mTLS Secret 挂载、最小出站 NetworkPolicy 与渲染/配置契约验证。
Controller、Gateway 与 Checker 的叶证书和信任根已在每个新 TLS 握手时重载,文件短暂 Controller、Gateway 与 Checker 的叶证书和信任根已在每个新 TLS 握手时重载,文件短暂
不一致时保留最后一次有效材料。生产环境仍必须通过独立 Overlay 为每个弹性 Worker 注入 不一致时保留最后一次有效材料。Gateway、Checker 启动派生和 Controller 授权共用同一
唯一 SPIFFE URI且容器端到端启动仍待具备镜像网络的环境验证。 SPIFFE URI 解析器X.509-SVID 叶证书必须且只能包含一个 URI SAN并精确匹配其角色。
生产环境仍必须通过独立 Overlay 为每个弹性 Worker 注入唯一 SPIFFE URI且容器端到端
启动仍待具备镜像网络的环境验证。
运维手册已覆盖管理面/活动池恢复顺序、发布与兼容回滚、Secret 轮换、容量计算,以及 运维手册已覆盖管理面/活动池恢复顺序、发布与兼容回滚、Secret 轮换、容量计算,以及
主机内核、文件描述符、NAT/conntrack 的只读基线和证据采集要求;这些文档不替代真实环境 主机内核、文件描述符、NAT/conntrack 的只读基线和证据采集要求;这些文档不替代真实环境

View File

@ -250,3 +250,14 @@ Routing 自上而下匹配,首条命中停止;支持 Gateway 与 Extract 两
- 生产 `docker-compose.yml` 仍把 PostgreSQL 18 命名卷挂在旧路径 - 生产 `docker-compose.yml` 仍把 PostgreSQL 18 命名卷挂在旧路径
`/var/lib/postgresql/data`;直接修改可能影响已有本地数据,必须配套迁移步骤后 `/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 静态验证,容器端到端验证仍待具备镜像网络的环境。

View File

@ -59,23 +59,14 @@ func (authorizer *SPIFFEIdentityAuthorizer) authorize(ctx context.Context, resou
return ErrUnauthorizedIdentity return ErrUnauthorizedIdentity
} }
identityCount := 0 chain := tlsInfo.State.VerifiedChains[0]
for _, chain := range tlsInfo.State.VerifiedChains {
if len(chain) == 0 || chain[0] == nil { 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 return ErrUnauthorizedIdentity
} }
} identity, matches := workerruntime.SingleSPIFFEIdentity(
} chain[0].URIs, authorizer.trustDomain, authorizer.environment, resourceType,
if identityCount != 1 { )
if !matches || identity != resourceID {
return ErrUnauthorizedIdentity return ErrUnauthorizedIdentity
} }
return nil return nil

View File

@ -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) { func TestSPIFFEIdentityAuthorizerAuthorizesCheckerOnlyForMatchingResource(t *testing.T) {
authorizer, err := NewSPIFFEIdentityAuthorizer("proxy.example", "prod") authorizer, err := NewSPIFFEIdentityAuthorizer("proxy.example", "prod")
if err != nil { if err != nil {

View File

@ -5,7 +5,6 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net/url"
"proxy-pool/internal/domain/workerruntime" "proxy-pool/internal/domain/workerruntime"
) )
@ -27,23 +26,9 @@ func ResolveSPIFFEIdentity(certificateFile, keyFile, trustDomain, environment, r
if err != nil { if err != nil {
return "", fmt.Errorf("%w: parse client certificate", ErrInvalidSPIFFEIdentity) return "", fmt.Errorf("%w: parse client certificate", ErrInvalidSPIFFEIdentity)
} }
var identity string identity, ok := workerruntime.SingleSPIFFEIdentity(leaf.URIs, trustDomain, environment, role)
for _, uri := range leaf.URIs {
candidate, ok := spiffeIdentity(uri, trustDomain, environment, role)
if !ok { if !ok {
continue
}
if identity != "" {
return "", ErrInvalidSPIFFEIdentity
}
identity = candidate
}
if !workerruntime.ValidIdentifier(identity) {
return "", ErrInvalidSPIFFEIdentity return "", ErrInvalidSPIFFEIdentity
} }
return identity, nil return identity, nil
} }
func spiffeIdentity(uri *url.URL, trustDomain, environment, role string) (string, bool) {
return workerruntime.SPIFFEIdentity(uri, trustDomain, environment, role)
}

View File

@ -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) { func writeWorkloadCertificate(t *testing.T, commonName string, identityURIs ...string) (string, string) {
t.Helper() t.Helper()
directory := t.TempDir() directory := t.TempDir()

View File

@ -20,3 +20,12 @@ func SPIFFEIdentity(uri *url.URL, trustDomain, environment, role string) (string
} }
return segments[3], true 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)
}

View File

@ -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) { 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

@ -2,12 +2,17 @@
## 2026-08-07 ## 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 在 - 控制面 mTLS 身份新增公共 `tlsreload.ResolveSPIFFEIdentity`Gateway 和 Checker 在
`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 授权和客户端 - SPIFFE URI 解析已下沉到 `workerruntime.SingleSPIFFEIdentity`Controller 授权和客户端
自动派生共用同一边界;用户信息、端口、查询、片段、转义路径和非精确角色路径均拒绝。 自动派生共用同一边界。X.509-SVID 叶证书必须且只能包含一个 URI SAN用户信息、端口、查询、
片段、转义路径、非精确角色路径和跨角色多 URI SAN 均拒绝。
- 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` 仍保留为代理无候选时的独立回退语义。

View File

@ -52,8 +52,9 @@
收敛。Health Scheduler 现以相同 revision 合并配置与管理态启用状态,管理态停用会停止 收敛。Health Scheduler 现以相同 revision 合并配置与管理态启用状态,管理态停用会停止
该 Upstream 的新 BASIC/EGRESS/TARGET 任务,停用 Routing 仅停止其新 TARGET 任务。 该 Upstream 的新 BASIC/EGRESS/TARGET 任务,停用 Routing 仅停止其新 TARGET 任务。
Gateway 请求生命周期直方图、Grafana 仪表盘、指标契约检查和 p99 延迟告警已完成; Gateway 请求生命周期直方图、Grafana 仪表盘、指标契约检查和 p99 延迟告警已完成;
Gateway/Checker 现可通过 `PROXY_POOL_AUTO_IDENTITY=true` 从唯一角色 SPIFFE URI Gateway/Checker 现可通过 `PROXY_POOL_AUTO_IDENTITY=true` 从仅含一个 URI SAN 的角色
派生逻辑与缺省实例 IDCompose 与开发 mTLS Overlay 已使用该公共路径。后续以真实容器、 SPIFFE X.509-SVID 派生逻辑与缺省实例 IDController 授权复用同一校验。Compose 与开发
mTLS Overlay 已使用该公共路径。后续以真实容器、
集群故障演练和代表性负载报告完成运行态验证闭环。 集群故障演练和代表性负载报告完成运行态验证闭环。
## 串并行关系 ## 串并行关系