From b486f4d3c1e7446a78490b6389f7d9980eb293c8 Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:03:44 +0800 Subject: [PATCH 1/2] fix(tls): validate public certificate SANs --- src/reconcile/tls.rs | 59 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 8 deletions(-) diff --git a/src/reconcile/tls.rs b/src/reconcile/tls.rs index fc1f2fc..00e0bc3 100644 --- a/src/reconcile/tls.rs +++ b/src/reconcile/tls.rs @@ -520,14 +520,13 @@ async fn reconcile_cert_manager_tls( let secret_resource_version = secret.metadata.resource_version.clone(); let san_dns_names = san_validation_dns_names(&san_budget, config, &entry); - if config.require_san_match - && let Err(failure) = validate_tls_secret_san_match_with_budget( - &secret_name, - &cert_bytes, - &san_dns_names, - &mut runtime_budget, - ) - { + if let Err(failure) = validate_configured_tls_secret_san_match( + config, + &secret_name, + &cert_bytes, + &san_dns_names, + &mut runtime_budget, + ) { return tls_validation_blocked(ctx, tenant, config, failure).await; } @@ -2452,6 +2451,25 @@ fn validate_tls_secret_san_match_with_budget( } } +fn validate_configured_tls_secret_san_match( + config: &TlsConfig, + secret_name: &str, + cert_bytes: &[u8], + expected_dns_names: &[String], + runtime_budget: &mut TlsCertificateRuntimeBudget, +) -> Result<(), TlsValidationFailure> { + if !config.require_san_match { + return Ok(()); + } + + validate_tls_secret_san_match_with_budget( + secret_name, + cert_bytes, + expected_dns_names, + runtime_budget, + ) +} + #[cfg(test)] fn validate_tls_secret_san_match( secret_name: &str, @@ -3429,6 +3447,31 @@ S2+cuFyHX+xgTPNxiG9zUDrgtXds/63ePISjIADAUvsmI97k96E6jdcgB9MmWdJj ); } + #[test] + fn require_san_match_validates_public_tls_when_internode_https_is_disabled() { + let config = TlsConfig { + enable_internode_https: false, + require_san_match: true, + ..Default::default() + }; + let mut runtime_budget = TlsCertificateRuntimeBudget::default(); + runtime_budget + .charge_secret_material(CERT_WITH_PEER_SANS_PEM.len()) + .expect("test certificate should fit the runtime budget"); + + let failure = validate_configured_tls_secret_san_match( + &config, + "server-tls", + CERT_WITH_PEER_SANS_PEM, + &["s3.example.com".to_string()], + &mut runtime_budget, + ) + .expect_err("public TLS SAN mismatch must be rejected without internode HTTPS"); + + assert_eq!(failure.reason, Reason::CertificateSanMismatch); + assert!(failure.message.contains("s3.example.com")); + } + #[test] fn tls_status_records_explicit_ca_and_client_ca_resource_versions() { let config = TlsConfig { From 11b078443e81cdcdb0ae7ec6543255f7768ecfbb Mon Sep 17 00:00:00 2001 From: GatewayJ <18332154+GatewayJ@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:46:25 +0800 Subject: [PATCH 2/2] test(tls): cover public SAN reconcile path --- src/reconcile/tls.rs | 148 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 18 deletions(-) diff --git a/src/reconcile/tls.rs b/src/reconcile/tls.rs index 00e0bc3..9b04c10 100644 --- a/src/reconcile/tls.rs +++ b/src/reconcile/tls.rs @@ -2908,10 +2908,17 @@ mod tests { use crate::types::v1alpha1::tls::{ CaTrustConfig, CertManagerPrivateKeyConfig, CertManagerTlsConfig, TlsCertificateConfig, }; + use http::{Method, Request, Response, StatusCode}; use k8s_openapi::ByteString; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; - use kube::CustomResourceExt; + use kube::{Client, CustomResourceExt, client::Body}; use std::collections::BTreeMap; + use std::convert::Infallible; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use tower::service_fn; const PUBLIC_CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nMIIDCTCCAfGgAwIBAgIUD4D7ObFcJ5PEZwq2t/cmrTbzcU0wDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MTExMDA3NDQwNVoXDTI2MTEx\nMDA3NDQwNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAsnrreaQGztdaTppY7p1ExoDU7FpYjk8MalWs9xIioHTe\ndpDlZmEWak0Q80qTvc+x6GT8VD/pLYqg6B2mot8I+Uv44GUmpPD/+WDxVbjvwL2b\nfvcNGEniqKJUOy2za98WcmI8EoILwbmYy7cZslf6b3D0xuDsmovYJgtjNeziV6ie\nLQfbWWXhAipYhUwaBAdUSQS+BWPPdYFG4LEE/8+BqmYdGU7ujIFlqSU89ZMfpZS4\npVRoEy16fs5O0UkbP1l63Q0qBLrLXjWw874dV8wC2p9iuVwofpDZRGhfYFaviZHb\nMHdUBRUughU4vvTknAGwMzbrIH+eTp7aKrGKWb7ozQIDAQABo1MwUTAdBgNVHQ4E\nFgQUGSE2L3XLbuxlA1Q0iX65aVGKzl4wHwYDVR0jBBgwFoAUGSE2L3XLbuxlA1Q0\niX65aVGKzl4wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAGHwM\nSYFN1/9ZlriVaJEpSvGlfeDvN5ipXqf0s1Ykux9rsTYchn7tcA6zhWqZUimwy/jO\nI7jLfBNa3r5HT1uX3/RlMs6dMIO4h3vkSWjQ3QaGiuXh6U+erbkaeETtrw9b40ta\nDsj2rruE3Z11JV0y5fGcvXjXMFV7XsFQjNXF5TlXu4OUvfMeo9h4IbPmNQtq+g+t\nnx0ZBloqo+punQVjHjovoQUWlrOOL5ZRZl1vLqqhHfw54a9weCXY8XJNnxWN0l0C\nKzht0TgbidDlWKBsk/CMTY8zpYrfVyPhnjNCeFGFG0DzrsehCgpEiEZ6vlylei7c\nRfKUdp4DXmUZBDzeQw==\n-----END CERTIFICATE-----\n"; const CERT_WITH_PEER_SANS_PEM: &[u8] = br#"-----BEGIN CERTIFICATE----- @@ -2938,6 +2945,15 @@ S2+cuFyHX+xgTPNxiG9zUDrgtXds/63ePISjIADAUvsmI97k96E6jdcgB9MmWdJj -----END CERTIFICATE----- "#; + fn kube_response(status: StatusCode, body: Value) -> Response { + Response::builder() + .status(status) + .body(Body::from( + serde_json::to_vec(&body).expect("response should serialize"), + )) + .expect("response should build") + } + #[test] fn tenant_crd_schema_types_cert_manager_private_key() { let crd = serde_json::to_value(Tenant::crd()).expect("tenant CRD serializes to JSON"); @@ -3447,29 +3463,125 @@ S2+cuFyHX+xgTPNxiG9zUDrgtXds/63ePISjIADAUvsmI97k96E6jdcgB9MmWdJj ); } - #[test] - fn require_san_match_validates_public_tls_when_internode_https_is_disabled() { - let config = TlsConfig { + #[tokio::test] + async fn require_san_match_blocks_public_tls_during_reconcile_without_internode_https() { + let mut tenant = crate::tests::create_test_tenant(None, None); + tenant.spec.tls = Some(TlsConfig { + mode: TlsMode::CertManager, enable_internode_https: false, require_san_match: true, + cert_manager: Some(CertManagerTlsConfig { + secret_name: Some("server-tls".to_string()), + dns_names: vec!["s3.example.com".to_string()], + include_generated_dns_names: Some(false), + ..Default::default() + }), ..Default::default() - }; - let mut runtime_budget = TlsCertificateRuntimeBudget::default(); - runtime_budget - .charge_secret_material(CERT_WITH_PEER_SANS_PEM.len()) - .expect("test certificate should fit the runtime budget"); + }); - let failure = validate_configured_tls_secret_san_match( - &config, + let mut server_secret = tls_secret( "server-tls", - CERT_WITH_PEER_SANS_PEM, - &["s3.example.com".to_string()], - &mut runtime_budget, - ) - .expect_err("public TLS SAN mismatch must be rejected without internode HTTPS"); + "7", + Some(KUBERNETES_TLS_SECRET_TYPE), + true, + true, + None, + ); + server_secret.metadata.namespace = Some("default".to_string()); + server_secret + .data + .as_mut() + .expect("test TLS Secret should contain data") + .insert( + TLS_CERT_KEY.to_string(), + ByteString(CERT_WITH_PEER_SANS_PEM.to_vec()), + ); - assert_eq!(failure.reason, Reason::CertificateSanMismatch); - assert!(failure.message.contains("s3.example.com")); + let request_count = Arc::new(AtomicUsize::new(0)); + let service = service_fn({ + let request_count = Arc::clone(&request_count); + let server_secret = server_secret.clone(); + let tenant = tenant.clone(); + move |request: Request| { + let request_number = request_count.fetch_add(1, Ordering::SeqCst); + let server_secret = server_secret.clone(); + let tenant = tenant.clone(); + async move { + let response = match request_number { + 0 => { + assert_eq!(request.method(), Method::GET); + assert_eq!( + request.uri().path(), + "/api/v1/namespaces/default/secrets/server-tls" + ); + kube_response( + StatusCode::OK, + serde_json::to_value(server_secret) + .expect("Secret should serialize"), + ) + } + 1 => { + assert_eq!(request.method(), Method::PATCH); + assert_eq!( + request.uri().path(), + "/apis/rustfs.com/v1alpha1/namespaces/default/tenants/test-tenant/status" + ); + let patch: Value = serde_json::from_slice( + &request + .into_body() + .collect_bytes() + .await + .expect("status patch body should be readable"), + ) + .expect("status patch should be JSON"); + assert_eq!(patch["status"]["currentState"], "Blocked"); + assert_eq!( + patch["status"]["certificates"]["tls"]["lastErrorReason"], + Reason::CertificateSanMismatch.as_str() + ); + assert!( + patch["status"]["certificates"]["tls"]["lastErrorMessage"] + .as_str() + .is_some_and(|message| message.contains("s3.example.com")) + ); + kube_response( + StatusCode::OK, + serde_json::to_value(tenant).expect("Tenant should serialize"), + ) + } + 2 => { + assert_eq!(request.method(), Method::POST); + assert!(request.uri().path().contains("/events")); + kube_response( + StatusCode::INTERNAL_SERVER_ERROR, + serde_json::json!({ + "apiVersion": "v1", + "kind": "Status", + "status": "Failure", + "reason": "InternalError", + "code": 500 + }), + ) + } + _ => panic!("unexpected Kubernetes request: {request:?}"), + }; + Ok::<_, Infallible>(response) + } + } + }); + let ctx = Context::new(Client::new(service, "default")); + + let error = reconcile_tls(&ctx, &tenant, "default") + .await + .expect_err("public TLS SAN mismatch must block reconcile without internode HTTPS"); + + assert!(matches!( + error, + Error::TlsBlocked { reason, message } + if reason == Reason::CertificateSanMismatch.as_str() + && message.contains("s3.example.com") + )); + assert_eq!(request_count.load(Ordering::SeqCst), 3); } #[test]