fix: require single uri san for spiffe identity
This commit is contained in:
parent
57013dd6b2
commit
a92dcab333
@ -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 使用。
|
||||
|
||||
|
||||
@ -300,8 +300,8 @@ controlPlane:
|
||||
- `gatewayTLS` 是 Gateway 的客户端证书、私钥和 Controller CA,与 `tls` 的服务端
|
||||
证书和 Worker CA 分离。三项可以同时省略(未运行 Gateway),配置任一项时必须完整提供。
|
||||
- `checkerTLS` 与 `gatewayTLS` 有相同字段和完整性校验,但必须使用独立的 Checker
|
||||
证书。Controller 分别验证 `.../worker/<worker-id>` 与
|
||||
`.../checker/<checker-id>` SPIFFE URI,不能跨角色复用证书。
|
||||
证书。每个客户端 X.509-SVID 叶证书必须且只能包含一个 URI SAN;Controller 分别验证
|
||||
`.../worker/<worker-id>` 与 `.../checker/<checker-id>` 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://<trust-domain>/<environment>/checker/<checker-id>` URI,并在未显式设置时复用其
|
||||
`checker-id` 作为实例 ID。
|
||||
|
||||
|
||||
@ -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 的只读基线和证据采集要求;这些文档不替代真实环境
|
||||
|
||||
11
findings.md
11
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 静态验证,容器端到端验证仍待具备镜像网络的环境。
|
||||
|
||||
@ -59,23 +59,14 @@ func (authorizer *SPIFFEIdentityAuthorizer) authorize(ctx context.Context, resou
|
||||
return ErrUnauthorizedIdentity
|
||||
}
|
||||
|
||||
identityCount := 0
|
||||
for _, chain := range tlsInfo.State.VerifiedChains {
|
||||
chain := tlsInfo.State.VerifiedChains[0]
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if identityCount != 1 {
|
||||
identity, matches := workerruntime.SingleSPIFFEIdentity(
|
||||
chain[0].URIs, authorizer.trustDomain, authorizer.environment, resourceType,
|
||||
)
|
||||
if !matches || identity != resourceID {
|
||||
return ErrUnauthorizedIdentity
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
identity, ok := workerruntime.SingleSPIFFEIdentity(leaf.URIs, trustDomain, environment, role)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if identity != "" {
|
||||
return "", ErrInvalidSPIFFEIdentity
|
||||
}
|
||||
identity = candidate
|
||||
}
|
||||
if !workerruntime.ValidIdentifier(identity) {
|
||||
return "", ErrInvalidSPIFFEIdentity
|
||||
}
|
||||
return identity, nil
|
||||
}
|
||||
|
||||
func spiffeIdentity(uri *url.URL, trustDomain, environment, role string) (string, bool) {
|
||||
return workerruntime.SPIFFEIdentity(uri, trustDomain, environment, role)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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` 仍保留为代理无候选时的独立回退语义。
|
||||
|
||||
@ -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 已使用该公共路径。后续以真实容器、
|
||||
集群故障演练和代表性负载报告完成运行态验证闭环。
|
||||
|
||||
## 串并行关系
|
||||
|
||||
Loading…
Reference in New Issue
Block a user