diff --git a/src/KubernetesClient/Kubernetes.ConfigInit.cs b/src/KubernetesClient/Kubernetes.ConfigInit.cs index e87e4e96..13449619 100644 --- a/src/KubernetesClient/Kubernetes.ConfigInit.cs +++ b/src/KubernetesClient/Kubernetes.ConfigInit.cs @@ -37,6 +37,7 @@ public Kubernetes(KubernetesClientConfiguration config, params DelegatingHandler SkipTlsVerify = config.SkipTlsVerify; TlsServerName = config.TlsServerName; + ServerCertificateCustomValidationCallback = config.ServerCertificateCustomValidationCallback; CreateHttpClient(handlers, config); InitializeFromConfig(config); HttpClientTimeout = config.HttpClientTimeout; @@ -72,7 +73,22 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) { if (BaseUri.Scheme == "https") { - if (config.SkipTlsVerify) + // Custom validation callback takes precedence + if (config.ServerCertificateCustomValidationCallback != null) + { +#if NET5_0_OR_GREATER + HttpClientHandler.SslOptions.RemoteCertificateValidationCallback = + (sender, certificate, chain, sslPolicyErrors) => + { + // RemoteCertificateValidationCallback doesn't provide HttpRequestMessage, so pass null + var cert = certificate as X509Certificate2 ?? new X509Certificate2(certificate); + return config.ServerCertificateCustomValidationCallback(null, cert, chain, sslPolicyErrors); + }; +#else + HttpClientHandler.ServerCertificateCustomValidationCallback = config.ServerCertificateCustomValidationCallback; +#endif + } + else if (config.SkipTlsVerify) { #if NET5_0_OR_GREATER HttpClientHandler.SslOptions.RemoteCertificateValidationCallback = @@ -128,6 +144,8 @@ private void InitializeFromConfig(KubernetesClientConfiguration config) private string TlsServerName { get; } + private Func ServerCertificateCustomValidationCallback { get; } + // NOTE: this method replicates the logic that the base ServiceClient uses except that it doesn't insert the RetryDelegatingHandler // and it does insert the WatcherDelegatingHandler. we don't want the RetryDelegatingHandler because it has a very broad definition // of what requests have failed. it considers everything outside 2xx to be failed, including 1xx (e.g. 101 Switching Protocols) and diff --git a/src/KubernetesClient/Kubernetes.WebSocket.cs b/src/KubernetesClient/Kubernetes.WebSocket.cs index aeca2970..90a08e09 100644 --- a/src/KubernetesClient/Kubernetes.WebSocket.cs +++ b/src/KubernetesClient/Kubernetes.WebSocket.cs @@ -259,12 +259,22 @@ protected async Task StreamConnectAsync(Uri uri, string webSocketSubP } } - if (this.CaCerts != null) + // Custom validation callback takes precedence + if (this.ServerCertificateCustomValidationCallback != null) + { + webSocketBuilder.SetServerCertificateCustomValidationCallback( + (sender, certificate, chain, sslPolicyErrors) => + { + // Convert to the expected signature (with HttpRequestMessage as first parameter) + var cert = certificate as X509Certificate2 ?? new X509Certificate2(certificate); + return this.ServerCertificateCustomValidationCallback(null, cert, chain, sslPolicyErrors); + }); + } + else if (this.CaCerts != null) { webSocketBuilder.ExpectServerCertificate(this.CaCerts); } - - if (this.SkipTlsVerify) + else if (this.SkipTlsVerify) { webSocketBuilder.SkipServerCertificateValidation(); } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.cs b/src/KubernetesClient/KubernetesClientConfiguration.cs index ac4a6671..876e9f0e 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.cs @@ -1,5 +1,6 @@ using k8s.Authentication; using System.Net.Http; +using System.Net.Security; using System.Security.Cryptography.X509Certificates; namespace k8s @@ -56,6 +57,34 @@ public partial class KubernetesClientConfiguration /// public bool SkipTlsVerify { get; set; } + /// + /// Gets or sets a custom server certificate validation callback. + /// This allows fine-grained control over certificate validation, such as disabling + /// revocation checks while maintaining other security validations. + /// Takes precedence over when set. + /// + /// + /// + /// The callback signature is: Func<HttpRequestMessage, X509Certificate2, X509Chain, SslPolicyErrors, bool>. + /// Note that the HttpRequestMessage parameter may be null in some contexts (e.g., WebSocket connections). + /// + /// + /// Example usage to disable revocation checking: + /// + /// + /// config.ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => + /// { + /// if (errors == SslPolicyErrors.None) + /// return true; + /// + /// // Disable revocation checking + /// chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + /// return chain.Build((X509Certificate2)cert); + /// }; + /// + /// + public Func ServerCertificateCustomValidationCallback { get; set; } + /// /// Option to override the TLS server name /// diff --git a/src/KubernetesClient/WebSocketBuilder.cs b/src/KubernetesClient/WebSocketBuilder.cs index 8acc3c5c..9c962e64 100644 --- a/src/KubernetesClient/WebSocketBuilder.cs +++ b/src/KubernetesClient/WebSocketBuilder.cs @@ -1,3 +1,4 @@ +using System.Net.Security; using System.Net.WebSockets; using System.Security.Cryptography.X509Certificates; @@ -44,6 +45,14 @@ public WebSocketBuilder ExpectServerCertificate(X509Certificate2Collection serve return this; } + public WebSocketBuilder SetServerCertificateCustomValidationCallback(RemoteCertificateValidationCallback callback) + { +#if NETSTANDARD2_1 || NET5_0_OR_GREATER + Options.RemoteCertificateValidationCallback = callback; +#endif + return this; + } + public WebSocketBuilder SkipServerCertificateValidation() { #if NETSTANDARD2_1 || NET5_0_OR_GREATER diff --git a/tests/KubernetesClient.Tests/CustomCertificateValidationTests.cs b/tests/KubernetesClient.Tests/CustomCertificateValidationTests.cs new file mode 100644 index 00000000..7b283d9d --- /dev/null +++ b/tests/KubernetesClient.Tests/CustomCertificateValidationTests.cs @@ -0,0 +1,140 @@ +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using Xunit; + +namespace k8s.Tests +{ + public class CustomCertificateValidationTests + { + [Fact] + public void CustomValidationCallbackShouldBeUsedWhenSet() + { + // Arrange + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => + { + return true; + }, + }; + + // Act + var client = new Kubernetes(config); + + // Assert - verify the callback was set + Assert.NotNull(config.ServerCertificateCustomValidationCallback); + } + + [Fact] + public void CustomValidationCallbackTakesPrecedenceOverSkipTlsVerify() + { + // Arrange + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + SkipTlsVerify = true, + ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => + { + return false; // Custom callback returns false + }, + }; + + // Act + var client = new Kubernetes(config); + + // Assert - The custom callback should be set, not the skip all validation + Assert.NotNull(config.ServerCertificateCustomValidationCallback); + Assert.True(config.SkipTlsVerify); // SkipTlsVerify should still be true in config + } + + [Fact] + public void CustomValidationCallbackCanDisableRevocationCheck() + { + // Arrange + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => + { + // Example: Disable revocation checking + if (errors == SslPolicyErrors.None) + { + return true; + } + + // Disable revocation checking + chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck; + return chain.Build((X509Certificate2)cert); + }, + }; + + // Act + var client = new Kubernetes(config); + + // Assert + Assert.NotNull(config.ServerCertificateCustomValidationCallback); + } + + [Fact] + public void CustomValidationCallbackCanPerformCustomLogic() + { + // Arrange + var allowedThumbprint = "1234567890ABCDEF"; + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + ServerCertificateCustomValidationCallback = (request, cert, chain, errors) => + { + // Example: Pin to a specific certificate thumbprint + if (cert != null && cert.Thumbprint == allowedThumbprint) + { + return true; + } + + return errors == SslPolicyErrors.None; + }, + }; + + // Act + var client = new Kubernetes(config); + + // Assert + Assert.NotNull(config.ServerCertificateCustomValidationCallback); + } + + [Fact] + public void ConfigurationWithoutCustomCallbackUsesDefaultBehavior() + { + // Arrange + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + }; + + // Act + var client = new Kubernetes(config); + + // Assert + Assert.Null(config.ServerCertificateCustomValidationCallback); + } + + [Fact] + public void SkipTlsVerifyWorksWhenNoCustomCallbackSet() + { + // Arrange + var config = new KubernetesClientConfiguration + { + Host = "https://test.example.com", + SkipTlsVerify = true, + }; + + // Act + var client = new Kubernetes(config); + + // Assert + Assert.Null(config.ServerCertificateCustomValidationCallback); + Assert.True(config.SkipTlsVerify); + } + } +}