From 53692ede62f7428d31708bad05f920d92b2596ec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:51:02 +0000 Subject: [PATCH 01/12] Initial plan From 8525843e19d327edf7488a4d74c5bf3b330de964 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:00:59 +0000 Subject: [PATCH 02/12] Add application-controlled exec credential plugin policies Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- README.md | 29 +++ .../KubernetesClient.Aot.csproj | 2 + ...ubernetesClientConfiguration.ConfigFile.cs | 100 ++++++-- .../KubernetesClient.Classic.csproj | 2 + .../Authentication/ExecTokenProvider.cs | 9 +- .../ExecCredentialPluginDeniedException.cs | 23 ++ .../ExecCredentialPluginPolicy.cs | 106 ++++++++ ...ubernetesClientConfiguration.ConfigFile.cs | 100 ++++++-- .../ExecCredentialPluginPolicyTests.cs | 235 ++++++++++++++++++ .../KubernetesClientConfigurationTests.cs | 41 +++ 10 files changed, 608 insertions(+), 39 deletions(-) create mode 100644 src/KubernetesClient/Exceptions/ExecCredentialPluginDeniedException.cs create mode 100644 src/KubernetesClient/ExecCredentialPluginPolicy.cs create mode 100644 tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs diff --git a/README.md b/README.md index ab022676b..09398ef6c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,35 @@ var config = KubernetesClientConfiguration.InClusterConfig() var client = new Kubernetes(config); ``` +### Exec credential plugin security + +Kubeconfig `exec` plugins start local processes, including while loading configuration. +Only load trusted kubeconfigs with the default `AllowAll` policy. For untrusted input, +applications can deny exec plugins or explicitly allow approved executables: + +```c# +var config = KubernetesClientConfiguration.BuildConfigFromConfigFile( + kubeConfigPath, execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + +var config = KubernetesClientConfiguration.BuildConfigFromConfigFile( + kubeConfigPath, + execCredentialPluginPolicy: ExecCredentialPluginPolicy.Allowlist( + "kubelogin", @"C:\Program Files\Contoso\trusted-plugin.exe")); +``` + +The same policy parameter is available on default, stream, async, and pre-loaded-object +builders. Policies are supplied by the application, not read from kubeconfig, and +apply to both initial execution and token refresh. Denials throw +`k8s.Exceptions.ExecCredentialPluginDeniedException` before the process starts. +An empty allowlist denies everything; null or invalid allowlist entries are rejected. + +Basenames allow that name in **any directory**, including `PATH` searches. Absolute +paths match exactly (case-insensitively on Windows, case-sensitively elsewhere), +without path normalization or symlink resolution. Prefer absolute paths to executables +in application-controlled directories. The policy does not validate arguments, +environment variables, or executable contents, and allowing an interpreter or other +general-purpose launcher can permit arbitrary code execution. It is not a sandbox. + ### Listing Objects ```c# var namespaces = client.CoreV1.ListNamespace(); diff --git a/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj b/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj index 3db7e6139..f44812dff 100644 --- a/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj +++ b/src/KubernetesClient.Aot/KubernetesClient.Aot.csproj @@ -74,6 +74,7 @@ + @@ -96,6 +97,7 @@ + diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index a2301b464..a7afa76f6 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -10,6 +10,8 @@ namespace k8s { public partial class KubernetesClientConfiguration { + private ExecCredentialPluginPolicy execCredentialPluginPolicy = ExecCredentialPluginPolicy.AllowAll; + /// /// kubeconfig Default Location /// @@ -36,6 +38,10 @@ public partial class KubernetesClientConfiguration /// public static event EventHandler ExecStdError; + /// + public static KubernetesClientConfiguration BuildDefaultConfig() + => BuildDefaultConfig(ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from default locations /// If the KUBECONFIG environment variable is set, then that will be used. @@ -48,7 +54,8 @@ public partial class KubernetesClientConfiguration /// merges the files, where first occurrence wins. See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files. /// /// Instance of the class - public static KubernetesClientConfiguration BuildDefaultConfig() + /// Application policy for exec plugins. Null allows all plugins. + public static KubernetesClientConfiguration BuildDefaultConfig(ExecCredentialPluginPolicy execCredentialPluginPolicy) { var kubeconfig = Environment.GetEnvironmentVariable(KubeConfigEnvironmentVariable); if (kubeconfig != null) @@ -56,12 +63,12 @@ public static KubernetesClientConfiguration BuildDefaultConfig() var configList = kubeconfig.Split(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':') .Select((s) => new FileInfo(s.Trim('"'))); var k8sConfig = LoadKubeConfig(configList.ToArray()); - return BuildConfigFromConfigObject(k8sConfig); + return BuildConfigFromConfigObject(k8sConfig, execCredentialPluginPolicy: execCredentialPluginPolicy); } if (File.Exists(KubeConfigDefaultLocation)) { - return BuildConfigFromConfigFile(KubeConfigDefaultLocation); + return BuildConfigFromConfigFile(KubeConfigDefaultLocation, execCredentialPluginPolicy: execCredentialPluginPolicy); } if (IsInCluster()) @@ -77,6 +84,11 @@ public static KubernetesClientConfiguration BuildDefaultConfig() return config; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + string kubeconfigPath, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFile(kubeconfigPath, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -85,15 +97,22 @@ public static KubernetesClientConfiguration BuildDefaultConfig() /// kube api server endpoint /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( string kubeconfigPath = null, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { return BuildConfigFromConfigFile(new FileInfo(kubeconfigPath ?? KubeConfigDefaultLocation), currentContext, - masterUrl, useRelativePaths); + masterUrl, useRelativePaths, execCredentialPluginPolicy); } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + FileInfo kubeconfig, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFile(kubeconfig, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -102,15 +121,22 @@ public static KubernetesClientConfiguration BuildConfigFromConfigFile( /// override the kube api server endpoint, set null if do not want to override /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( FileInfo kubeconfig, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { - return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths).GetAwaiter() + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths, execCredentialPluginPolicy).GetAwaiter() .GetResult(); } + /// + public static Task BuildConfigFromConfigFileAsync( + FileInfo kubeconfig, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -119,10 +145,12 @@ public static KubernetesClientConfiguration BuildConfigFromConfigFile( /// override the kube api server endpoint, set null if do not want to override /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static async Task BuildConfigFromConfigFileAsync( FileInfo kubeconfig, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { if (kubeconfig == null) { @@ -130,35 +158,49 @@ public static async Task BuildConfigFromConfigFil } var k8SConfig = await LoadKubeConfigAsync(kubeconfig, useRelativePaths).ConfigureAwait(false); - var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); return k8SConfiguration; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + Stream kubeconfig, string currentContext, string masterUrl) + => BuildConfigFromConfigFile(kubeconfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// /// Stream of the kubeconfig, cannot be null /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( Stream kubeconfig, - string currentContext = null, string masterUrl = null) + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { - return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl).GetAwaiter().GetResult(); + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, execCredentialPluginPolicy).GetAwaiter().GetResult(); } + /// + public static Task BuildConfigFromConfigFileAsync( + Stream kubeconfig, string currentContext, string masterUrl) + => BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// /// Stream of the kubeconfig, cannot be null /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static async Task BuildConfigFromConfigFileAsync( Stream kubeconfig, - string currentContext = null, string masterUrl = null) + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { if (kubeconfig == null) { @@ -173,33 +215,43 @@ public static async Task BuildConfigFromConfigFil kubeconfig.Position = 0; var k8SConfig = await KubernetesYaml.LoadFromStreamAsync(kubeconfig).ConfigureAwait(false); - var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); return k8SConfiguration; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigObject( + K8SConfiguration k8SConfig, string currentContext, string masterUrl) + => BuildConfigFromConfigObject(k8SConfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of from pre-loaded config object. /// /// A , for example loaded from /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigObject( K8SConfiguration k8SConfig, - string currentContext = null, string masterUrl = null) - => GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) + => GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); private static KubernetesClientConfiguration GetKubernetesClientConfiguration( string currentContext, - string masterUrl, K8SConfiguration k8SConfig) + string masterUrl, K8SConfiguration k8SConfig, ExecCredentialPluginPolicy execCredentialPluginPolicy) { if (k8SConfig == null) { throw new ArgumentNullException(nameof(k8SConfig)); } - var k8SConfiguration = new KubernetesClientConfiguration(); + var k8SConfiguration = new KubernetesClientConfiguration + { + execCredentialPluginPolicy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll, + }; currentContext = currentContext ?? k8SConfig.CurrentContext; // only init context if context is set @@ -416,7 +468,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) throw new KubeConfigException("External command execution missing ApiVersion key"); } - var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution, execCredentialPluginPolicy); AccessToken = response.Status.Token; // When reading ClientCertificateData from a config file it will be base64 encoded, and code later in the system (see CertUtils.GeneratePfx) // expects ClientCertificateData and ClientCertificateKeyData to be base64 encoded because of this. However the string returned by external @@ -429,7 +481,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) // TODO: support client certificates here too. if (AccessToken != null) { - TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution); + TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution, execCredentialPluginPolicy); } } @@ -493,6 +545,15 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev /// The token, client certificate data, and the client key data received from the external command execution /// public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + => ExecuteExternalCommand(config, ExecCredentialPluginPolicy.AllowAll); + + /// + /// Executes a credential plugin only if permitted by the application policy. + /// + /// The external command execution configuration. + /// Application policy. Null allows all plugins. + /// The credentials returned by the plugin. + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ExecCredentialPluginPolicy execCredentialPluginPolicy) { if (config == null) { @@ -502,6 +563,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); + (execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll).Validate(process.StartInfo.FileName); try { process.Start(); diff --git a/src/KubernetesClient.Classic/KubernetesClient.Classic.csproj b/src/KubernetesClient.Classic/KubernetesClient.Classic.csproj index 902dc41dd..918bb4345 100644 --- a/src/KubernetesClient.Classic/KubernetesClient.Classic.csproj +++ b/src/KubernetesClient.Classic/KubernetesClient.Classic.csproj @@ -88,6 +88,7 @@ + @@ -110,6 +111,7 @@ + diff --git a/src/KubernetesClient/Authentication/ExecTokenProvider.cs b/src/KubernetesClient/Authentication/ExecTokenProvider.cs index 26bc9b961..dc74f1e0d 100644 --- a/src/KubernetesClient/Authentication/ExecTokenProvider.cs +++ b/src/KubernetesClient/Authentication/ExecTokenProvider.cs @@ -6,11 +6,18 @@ namespace k8s.Authentication public class ExecTokenProvider : ITokenProvider { private readonly ExternalExecution exec; + private readonly ExecCredentialPluginPolicy policy; private ExecCredentialResponse response; public ExecTokenProvider(ExternalExecution exec) + : this(exec, ExecCredentialPluginPolicy.AllowAll) + { + } + + public ExecTokenProvider(ExternalExecution exec, ExecCredentialPluginPolicy policy) { this.exec = exec; + this.policy = policy ?? ExecCredentialPluginPolicy.AllowAll; } private bool NeedsRefresh() @@ -41,7 +48,7 @@ public async Task GetAuthenticationHeaderAsync(Cancel private async Task RefreshToken() { response = - await Task.Run(() => KubernetesClientConfiguration.ExecuteExternalCommand(this.exec)).ConfigureAwait(false); + await Task.Run(() => KubernetesClientConfiguration.ExecuteExternalCommand(this.exec, policy)).ConfigureAwait(false); } } } diff --git a/src/KubernetesClient/Exceptions/ExecCredentialPluginDeniedException.cs b/src/KubernetesClient/Exceptions/ExecCredentialPluginDeniedException.cs new file mode 100644 index 000000000..87852f7e3 --- /dev/null +++ b/src/KubernetesClient/Exceptions/ExecCredentialPluginDeniedException.cs @@ -0,0 +1,23 @@ +namespace k8s.Exceptions +{ + /// + /// An exec credential plugin was denied by the application policy before starting a process. + /// + public class ExecCredentialPluginDeniedException : KubeConfigException + { + /// + /// Initializes a new instance of the class. + /// + /// The denied executable. + public ExecCredentialPluginDeniedException(string command) + : base($"Exec credential plugin '{command}' was denied by the application policy.") + { + Command = command; + } + + /// + /// Gets the denied executable. + /// + public string Command { get; } + } +} diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs new file mode 100644 index 000000000..399e4cda6 --- /dev/null +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -0,0 +1,106 @@ +using k8s.Exceptions; +using System.Runtime.InteropServices; + +namespace k8s +{ + /// + /// Application-controlled policy for kubeconfig exec credential plugins. + /// + public sealed class ExecCredentialPluginPolicy + { + private static readonly StringComparison CommandComparison = + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + private readonly bool allowAll; + private readonly string[] commands; + + private ExecCredentialPluginPolicy(bool allowAll, string[] commands) + { + this.allowAll = allowAll; + this.commands = commands; + } + + /// + /// Allows any exec plugin. This is the default for compatibility. + /// + public static ExecCredentialPluginPolicy AllowAll { get; } = new ExecCredentialPluginPolicy(true, Array.Empty()); + + /// + /// Prevents all exec plugins from running. + /// + public static ExecCredentialPluginPolicy DenyAll { get; } = new ExecCredentialPluginPolicy(false, Array.Empty()); + + /// + /// Allows command basenames or exact, absolute executable paths. An empty list denies all commands. + /// + /// + /// Basenames allow that executable name in any directory, including directories searched through PATH. + /// Absolute paths use exact textual matching, without resolving symlinks or normalizing paths. + /// Matching is case-insensitive on Windows and case-sensitive elsewhere. + /// This policy does not restrict plugin arguments or environment variables. + /// + /// Approved basenames or absolute paths. Relative paths and invalid entries are rejected. + /// An immutable allowlist policy. + public static ExecCredentialPluginPolicy Allowlist(params string[] commands) + { + if (commands == null) + { + throw new ArgumentNullException(nameof(commands)); + } + + var copy = (string[])commands.Clone(); + foreach (var command in copy) + { + if (string.IsNullOrWhiteSpace(command) || command == "." || command == ".." || + command.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || command.IndexOfAny(new[] { '*', '?' }) >= 0 || + (!IsBasename(command) && !IsAbsolutePath(command))) + { + throw new ArgumentException("Exec allowlist entries must be command basenames or absolute executable paths.", nameof(commands)); + } + } + + return new ExecCredentialPluginPolicy(false, copy); + } + + internal bool Allows(string command) + { + if (allowAll) + { + return true; + } + + if (string.IsNullOrWhiteSpace(command)) + { + return false; + } + + return commands.Any(entry => string.Equals( + entry, IsBasename(entry) ? Path.GetFileName(command) : command, CommandComparison)); + } + + internal void Validate(string command) + { + if (!Allows(command)) + { + throw new ExecCredentialPluginDeniedException(command); + } + } + + private static bool IsBasename(string command) + { + return command.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':' }) < 0; + } + + private static bool IsAbsolutePath(string command) + { + if (!Path.IsPathRooted(command)) + { + return false; + } + + var root = Path.GetPathRoot(command); + return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + (root.Length > 1 && root[root.Length - 1] == Path.DirectorySeparatorChar); + } + } +} diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index ca9e206bd..12ce81895 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -10,6 +10,8 @@ namespace k8s { public partial class KubernetesClientConfiguration { + private ExecCredentialPluginPolicy execCredentialPluginPolicy = ExecCredentialPluginPolicy.AllowAll; + /// /// kubeconfig Default Location /// @@ -36,6 +38,10 @@ public partial class KubernetesClientConfiguration /// public static event EventHandler ExecStdError; + /// + public static KubernetesClientConfiguration BuildDefaultConfig() + => BuildDefaultConfig(ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from default locations /// If the KUBECONFIG environment variable is set, then that will be used. @@ -48,7 +54,8 @@ public partial class KubernetesClientConfiguration /// merges the files, where first occurrence wins. See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files. /// /// Instance of the class - public static KubernetesClientConfiguration BuildDefaultConfig() + /// Application policy for exec plugins. Null allows all plugins. + public static KubernetesClientConfiguration BuildDefaultConfig(ExecCredentialPluginPolicy execCredentialPluginPolicy) { var kubeconfig = Environment.GetEnvironmentVariable(KubeConfigEnvironmentVariable); if (kubeconfig != null) @@ -56,12 +63,12 @@ public static KubernetesClientConfiguration BuildDefaultConfig() var configList = kubeconfig.Split(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ';' : ':') .Select((s) => new FileInfo(s.Trim('"'))); var k8sConfig = LoadKubeConfig(configList.ToArray()); - return BuildConfigFromConfigObject(k8sConfig); + return BuildConfigFromConfigObject(k8sConfig, execCredentialPluginPolicy: execCredentialPluginPolicy); } if (File.Exists(KubeConfigDefaultLocation)) { - return BuildConfigFromConfigFile(KubeConfigDefaultLocation); + return BuildConfigFromConfigFile(KubeConfigDefaultLocation, execCredentialPluginPolicy: execCredentialPluginPolicy); } if (IsInCluster()) @@ -77,6 +84,11 @@ public static KubernetesClientConfiguration BuildDefaultConfig() return config; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + string kubeconfigPath, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFile(kubeconfigPath, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -85,15 +97,22 @@ public static KubernetesClientConfiguration BuildDefaultConfig() /// kube api server endpoint /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( string kubeconfigPath = null, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { return BuildConfigFromConfigFile(new FileInfo(kubeconfigPath ?? KubeConfigDefaultLocation), currentContext, - masterUrl, useRelativePaths); + masterUrl, useRelativePaths, execCredentialPluginPolicy); } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + FileInfo kubeconfig, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFile(kubeconfig, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -102,15 +121,22 @@ public static KubernetesClientConfiguration BuildConfigFromConfigFile( /// override the kube api server endpoint, set null if do not want to override /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( FileInfo kubeconfig, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { - return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths).GetAwaiter() + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths, execCredentialPluginPolicy).GetAwaiter() .GetResult(); } + /// + public static Task BuildConfigFromConfigFileAsync( + FileInfo kubeconfig, string currentContext, string masterUrl, bool useRelativePaths) + => BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, useRelativePaths, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// @@ -119,10 +145,12 @@ public static KubernetesClientConfiguration BuildConfigFromConfigFile( /// override the kube api server endpoint, set null if do not want to override /// When , the paths in the kubeconfig file will be considered to be relative to the directory in which the kubeconfig /// file is located. When , the paths will be considered to be relative to the current working directory. + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static async Task BuildConfigFromConfigFileAsync( FileInfo kubeconfig, - string currentContext = null, string masterUrl = null, bool useRelativePaths = true) + string currentContext = null, string masterUrl = null, bool useRelativePaths = true, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { if (kubeconfig == null) { @@ -130,35 +158,49 @@ public static async Task BuildConfigFromConfigFil } var k8SConfig = await LoadKubeConfigAsync(kubeconfig, useRelativePaths).ConfigureAwait(false); - var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); return k8SConfiguration; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigFile( + Stream kubeconfig, string currentContext, string masterUrl) + => BuildConfigFromConfigFile(kubeconfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// /// Stream of the kubeconfig, cannot be null /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigFile( Stream kubeconfig, - string currentContext = null, string masterUrl = null) + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { - return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl).GetAwaiter().GetResult(); + return BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, execCredentialPluginPolicy).GetAwaiter().GetResult(); } + /// + public static Task BuildConfigFromConfigFileAsync( + Stream kubeconfig, string currentContext, string masterUrl) + => BuildConfigFromConfigFileAsync(kubeconfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of the from config file /// /// Stream of the kubeconfig, cannot be null /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static async Task BuildConfigFromConfigFileAsync( Stream kubeconfig, - string currentContext = null, string masterUrl = null) + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) { if (kubeconfig == null) { @@ -173,33 +215,43 @@ public static async Task BuildConfigFromConfigFil kubeconfig.Position = 0; var k8SConfig = await KubernetesYaml.LoadFromStreamAsync(kubeconfig).ConfigureAwait(false); - var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + var k8SConfiguration = GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); return k8SConfiguration; } + /// + public static KubernetesClientConfiguration BuildConfigFromConfigObject( + K8SConfiguration k8SConfig, string currentContext, string masterUrl) + => BuildConfigFromConfigObject(k8SConfig, currentContext, masterUrl, ExecCredentialPluginPolicy.AllowAll); + /// /// Initializes a new instance of from pre-loaded config object. /// /// A , for example loaded from /// Override the current context in config, set null if do not want to override /// Override the Kubernetes API server endpoint, set null if do not want to override + /// Application policy for exec plugins. Null allows all plugins. /// Instance of the class public static KubernetesClientConfiguration BuildConfigFromConfigObject( K8SConfiguration k8SConfig, - string currentContext = null, string masterUrl = null) - => GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig); + string currentContext = null, string masterUrl = null, + ExecCredentialPluginPolicy execCredentialPluginPolicy = null) + => GetKubernetesClientConfiguration(currentContext, masterUrl, k8SConfig, execCredentialPluginPolicy); private static KubernetesClientConfiguration GetKubernetesClientConfiguration( string currentContext, - string masterUrl, K8SConfiguration k8SConfig) + string masterUrl, K8SConfiguration k8SConfig, ExecCredentialPluginPolicy execCredentialPluginPolicy) { if (k8SConfig == null) { throw new ArgumentNullException(nameof(k8SConfig)); } - var k8SConfiguration = new KubernetesClientConfiguration(); + var k8SConfiguration = new KubernetesClientConfiguration + { + execCredentialPluginPolicy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll, + }; currentContext = currentContext ?? k8SConfig.CurrentContext; // only init context if context is set @@ -426,7 +478,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) throw new KubeConfigException("External command execution missing ApiVersion key"); } - var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution); + var response = ExecuteExternalCommand(userDetails.UserCredentials.ExternalExecution, execCredentialPluginPolicy); AccessToken = response.Status.Token; // When reading ClientCertificateData from a config file it will be base64 encoded, and code later in the system (see CertUtils.GeneratePfx) // expects ClientCertificateData and ClientCertificateKeyData to be base64 encoded because of this. However the string returned by external @@ -439,7 +491,7 @@ private void SetUserDetails(K8SConfiguration k8SConfig, Context activeContext) // TODO: support client certificates here too. if (AccessToken != null) { - TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution); + TokenProvider = new ExecTokenProvider(userDetails.UserCredentials.ExternalExecution, execCredentialPluginPolicy); } } @@ -510,6 +562,15 @@ public static Process CreateRunnableExternalProcess(ExternalExecution config, Ev /// The token, client certificate data, and the client key data received from the external command execution /// public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config) + => ExecuteExternalCommand(config, ExecCredentialPluginPolicy.AllowAll); + + /// + /// Executes a credential plugin only if permitted by the application policy. + /// + /// The external command execution configuration. + /// Application policy. Null allows all plugins. + /// The credentials returned by the plugin. + public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution config, ExecCredentialPluginPolicy execCredentialPluginPolicy) { if (config == null) { @@ -519,6 +580,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); + (execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll).Validate(process.StartInfo.FileName); try { process.Start(); diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs new file mode 100644 index 000000000..0dce74209 --- /dev/null +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -0,0 +1,235 @@ +using k8s.Authentication; +using k8s.Exceptions; +using k8s.KubeConfigModels; +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace k8s.Tests +{ + public class ExecCredentialPluginPolicyTests + { + [Fact] + public void AllowlistMatchesBasenamesAndExactPaths() + { + var path = Path.Combine(Path.GetTempPath(), "trusted-plugin"); + var otherPath = Path.Combine(Path.GetTempPath(), "other", "trusted-plugin"); + var basenamePolicy = ExecCredentialPluginPolicy.Allowlist("trusted-plugin"); + Assert.True(basenamePolicy.Allows("trusted-plugin")); + Assert.True(basenamePolicy.Allows(path)); + Assert.True(basenamePolicy.Allows(otherPath)); + Assert.False(basenamePolicy.Allows("untrusted-plugin")); + Assert.False(basenamePolicy.Allows("trusted-plugin-other")); + + var pathPolicy = ExecCredentialPluginPolicy.Allowlist(path); + Assert.True(pathPolicy.Allows(path)); + Assert.False(pathPolicy.Allows("trusted-plugin")); + Assert.False(pathPolicy.Allows(otherPath)); + Assert.False(pathPolicy.Allows(Path.Combine(Path.GetDirectoryName(path), ".", "trusted-plugin"))); + Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), pathPolicy.Allows(path.ToUpperInvariant())); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("plugins/trusted-plugin")] + [InlineData("*")] + [InlineData("trusted?plugin")] + [InlineData("trusted\0plugin")] + public void InvalidAllowlistEntriesAreRejected(string entry) + { + Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist("trusted-plugin", entry)); + } + + [Fact] + public void EmptyAndNullAllowlistsFailClosed() + { + Assert.False(ExecCredentialPluginPolicy.Allowlist().Allows("trusted-plugin")); + Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist(null)); + } + + [Fact] + public void AllowlistCopiesApplicationInput() + { + var entries = new[] { "trusted-plugin" }; + var policy = ExecCredentialPluginPolicy.Allowlist(entries); + entries[0] = "untrusted-plugin"; + Assert.True(policy.Allows("trusted-plugin")); + Assert.False(policy.Allows("untrusted-plugin")); + } + + [Theory] + [InlineData("deny")] + [InlineData("empty")] + [InlineData("unmatched")] + public async Task DeniedPluginNeverStarts(string mode) + { + using var plugin = new TestPlugin(); + var policy = mode == "deny" ? ExecCredentialPluginPolicy.DenyAll : + mode == "empty" ? ExecCredentialPluginPolicy.Allowlist() : + ExecCredentialPluginPolicy.Allowlist("another-plugin"); + + var exception = Assert.Throws(() => + KubernetesClientConfiguration.ExecuteExternalCommand(plugin.Exec, policy)); + Assert.Equal(plugin.Exec.Command, exception.Command); + Assert.Contains("denied by the application policy", exception.Message); + Assert.False(File.Exists(plugin.Marker)); + + var provider = new ExecTokenProvider(plugin.Exec, policy); + await Assert.ThrowsAsync(() => + provider.GetAuthenticationHeaderAsync(CancellationToken.None)); + Assert.False(File.Exists(plugin.Marker)); + } + + [Theory] + [InlineData("default")] + [InlineData("all")] + [InlineData("basename")] + [InlineData("path")] + public async Task AllowedPluginRunsAndRefreshes(string mode) + { + using var plugin = new TestPlugin(); + var policy = mode == "default" ? null : + mode == "all" ? ExecCredentialPluginPolicy.AllowAll : + mode == "basename" ? ExecCredentialPluginPolicy.Allowlist(Path.GetFileName(plugin.Exec.Command)) : + ExecCredentialPluginPolicy.Allowlist(plugin.Exec.Command); + var kubeconfig = plugin.LoadConfig(); + var config = mode == "default" ? KubernetesClientConfiguration.BuildConfigFromConfigObject(kubeconfig) : + KubernetesClientConfiguration.BuildConfigFromConfigObject(kubeconfig, execCredentialPluginPolicy: policy); + Assert.Equal("test-token", config.AccessToken); + Assert.True(File.Exists(plugin.Marker)); + File.Delete(plugin.Marker); + var header = await config.TokenProvider.GetAuthenticationHeaderAsync(CancellationToken.None); + Assert.Equal("test-token", header.Parameter); + Assert.True(File.Exists(plugin.Marker)); + } + + [Fact] + public async Task RefreshRetainsPolicyAndChecksTheCurrentCommand() + { + using var plugin = new TestPlugin(); + var kubeconfig = plugin.LoadConfig(); + var config = KubernetesClientConfiguration.BuildConfigFromConfigObject( + kubeconfig, + execCredentialPluginPolicy: ExecCredentialPluginPolicy.Allowlist(plugin.Exec.Command)); + File.Delete(plugin.Marker); + foreach (var user in kubeconfig.Users) + { + user.UserCredentials.ExternalExecution.Command = "unapproved-plugin"; + } + + await Assert.ThrowsAsync(() => + config.TokenProvider.GetAuthenticationHeaderAsync(CancellationToken.None)); + Assert.False(File.Exists(plugin.Marker)); + } + + [Theory] + [InlineData("path")] + [InlineData("file")] + [InlineData("async-file")] + [InlineData("stream")] + [InlineData("async-stream")] + [InlineData("object")] + public async Task ConfigBuildersEnforcePolicy(string builder) + { + using var plugin = new TestPlugin(); + using var stream = File.OpenRead(plugin.KubeconfigPath); + await Assert.ThrowsAsync(async () => + { + switch (builder) + { + case "path": + KubernetesClientConfiguration.BuildConfigFromConfigFile( + plugin.KubeconfigPath, + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + break; + case "file": + KubernetesClientConfiguration.BuildConfigFromConfigFile( + new FileInfo(plugin.KubeconfigPath), + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + break; + case "async-file": + await KubernetesClientConfiguration.BuildConfigFromConfigFileAsync( + new FileInfo(plugin.KubeconfigPath), + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll).ConfigureAwait(false); + break; + case "stream": + KubernetesClientConfiguration.BuildConfigFromConfigFile( + stream, + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + break; + case "async-stream": + await KubernetesClientConfiguration.BuildConfigFromConfigFileAsync( + stream, + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll).ConfigureAwait(false); + break; + case "object": + KubernetesClientConfiguration.BuildConfigFromConfigObject( + plugin.LoadConfig(), + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + break; + } + }); + Assert.False(File.Exists(plugin.Marker)); + } + + [Fact] + public void DenyAllDoesNotAffectNonExecCredentials() + { + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile( + new FileInfo("assets/kubeconfig.yml"), "queen-anne-context", + execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); + Assert.Equal("black-token", config.AccessToken); + } + + private sealed class TestPlugin : IDisposable + { + private readonly string directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + + public TestPlugin() + { + Directory.CreateDirectory(directory); + Marker = Path.Combine(directory, "started"); + var windows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var script = Path.Combine(directory, windows ? "plugin.cmd" : "plugin.sh"); + var response = "{\"apiVersion\":\"client.authentication.k8s.io/v1beta1\",\"kind\":\"ExecCredential\",\"status\":{\"token\":\"test-token\"}}"; + var scriptContent = windows ? + $"@echo off\r\necho started > \"{Marker}\"\r\necho {response}\r\n" : + $"printf started > '{Marker}'\nprintf '%s\\n' '{response}'\n"; + File.WriteAllText(script, scriptContent); + Exec = new ExternalExecution + { + ApiVersion = "client.authentication.k8s.io/v1beta1", + Command = windows ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), "cmd.exe") : "/bin/sh", + Arguments = windows ? new List { "/c", $"\"{script}\"" } : new List { $"\"{script}\"" }, + }; + KubeconfigPath = Path.Combine(directory, "config"); + File.WriteAllText(KubeconfigPath, KubernetesYaml.Serialize(new K8SConfiguration + { + CurrentContext = "test", + Contexts = new[] { new Context { Name = "test", ContextDetails = new ContextDetails { Cluster = "test", User = "test" } } }, + Clusters = new[] { new Cluster { Name = "test", ClusterEndpoint = new ClusterEndpoint { Server = "https://localhost", SkipTlsVerify = true } } }, + Users = new[] { new User { Name = "test", UserCredentials = new UserCredentials { ExternalExecution = Exec } } }, + Preferences = new Dictionary { { "execCredentialPluginPolicy", "AllowAll" } }, + })); + } + + public ExternalExecution Exec { get; } + + public string Marker { get; } + + public string KubeconfigPath { get; } + + public K8SConfiguration LoadConfig() => KubernetesClientConfiguration.LoadKubeConfig(KubeconfigPath); + + public void Dispose() => Directory.Delete(directory, true); + } + } +} diff --git a/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs b/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs index 397c4f431..f31205e60 100644 --- a/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs +++ b/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs @@ -530,6 +530,47 @@ public void LoadKubeConfigFromEnvironmentVariable() Assert.NotNull(cfg); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DefaultConfigEnforcesExecPolicy(bool multipleConfigs) + { + var kubeconfig = KubernetesClientConfiguration.LoadKubeConfig("assets/kubeconfig.yml"); + kubeconfig.CurrentContext = "queen-anne-context"; + foreach (var cluster in kubeconfig.Clusters) + { + cluster.ClusterEndpoint.SkipTlsVerify = true; + } + + foreach (var user in kubeconfig.Users) + { + user.UserCredentials.ExternalExecution = new ExternalExecution + { + Command = "policy-denied-command", + ApiVersion = "client.authentication.k8s.io/v1beta1", + }; + } + + var path = Path.GetTempFileName(); + var environmentVariable = "KUBECONFIG_DefaultConfigEnforcesExecPolicy"; + var previousVariable = KubernetesClientConfiguration.KubeConfigEnvironmentVariable; + try + { + File.WriteAllText(path, KubernetesYaml.Serialize(kubeconfig)); + Environment.SetEnvironmentVariable(environmentVariable, multipleConfigs ? + string.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ";" : ":", path, path) : path); + KubernetesClientConfiguration.KubeConfigEnvironmentVariable = environmentVariable; + Assert.Throws(() => + KubernetesClientConfiguration.BuildDefaultConfig(ExecCredentialPluginPolicy.DenyAll)); + } + finally + { + KubernetesClientConfiguration.KubeConfigEnvironmentVariable = previousVariable; + Environment.SetEnvironmentVariable(environmentVariable, null); + File.Delete(path); + } + } + [Fact] public void LoadKubeConfigFromEnvironmentVariableMultipleConfigs() { From b270f4fc5fab8c02be6a6654d8cf8d3b81f79c24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:22:19 +0000 Subject: [PATCH 03/12] Harden exec allowlists against quoted commands and environment injection Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- README.md | 7 +- ...ubernetesClientConfiguration.ConfigFile.cs | 4 +- .../ExecCredentialPluginPolicy.cs | 20 ++-- ...ubernetesClientConfiguration.ConfigFile.cs | 4 +- .../ExecCredentialPluginPolicyTests.cs | 92 +++++++++++++++++-- .../KubernetesClientConfigurationTests.cs | 5 +- 6 files changed, 112 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 09398ef6c..deaaa8514 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,11 @@ An empty allowlist denies everything; null or invalid allowlist entries are reje Basenames allow that name in **any directory**, including `PATH` searches. Absolute paths match exactly (case-insensitively on Windows, case-sensitively elsewhere), without path normalization or symlink resolution. Prefer absolute paths to executables -in application-controlled directories. The policy does not validate arguments, -environment variables, or executable contents, and allowing an interpreter or other +in application-controlled directories. The policy does not validate arguments +or executable contents. Allowlist mode rejects all kubeconfig-supplied environment +variables, which could otherwise alter command resolution or inject code through +loader settings. Trusted environment settings can be supplied by the application +through its own process environment. Allowing an interpreter or other general-purpose launcher can permit arbitrary code execution. It is not a sandbox. ### Listing Objects diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index a7afa76f6..de2c8526f 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -560,10 +560,12 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co throw new ArgumentNullException(nameof(config)); } + var policy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll; + policy.Validate(config.Command, config); var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); - (execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll).Validate(process.StartInfo.FileName); + policy.Validate(process.StartInfo.FileName, config); try { process.Start(); diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index 399e4cda6..c456bd997 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -1,4 +1,5 @@ using k8s.Exceptions; +using k8s.KubeConfigModels; using System.Runtime.InteropServices; namespace k8s @@ -37,7 +38,7 @@ private ExecCredentialPluginPolicy(bool allowAll, string[] commands) /// Basenames allow that executable name in any directory, including directories searched through PATH. /// Absolute paths use exact textual matching, without resolving symlinks or normalizing paths. /// Matching is case-insensitive on Windows and case-sensitive elsewhere. - /// This policy does not restrict plugin arguments or environment variables. + /// Kubeconfig-supplied environment variables are rejected. Plugin arguments are not restricted. /// /// Approved basenames or absolute paths. Relative paths and invalid entries are rejected. /// An immutable allowlist policy. @@ -52,7 +53,7 @@ public static ExecCredentialPluginPolicy Allowlist(params string[] commands) foreach (var command in copy) { if (string.IsNullOrWhiteSpace(command) || command == "." || command == ".." || - command.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || command.IndexOfAny(new[] { '*', '?' }) >= 0 || + HasInvalidCommandCharacters(command) || command.IndexOfAny(new[] { '*', '?' }) >= 0 || (!IsBasename(command) && !IsAbsolutePath(command))) { throw new ArgumentException("Exec allowlist entries must be command basenames or absolute executable paths.", nameof(commands)); @@ -69,7 +70,7 @@ internal bool Allows(string command) return true; } - if (string.IsNullOrWhiteSpace(command)) + if (string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) { return false; } @@ -78,9 +79,9 @@ internal bool Allows(string command) entry, IsBasename(entry) ? Path.GetFileName(command) : command, CommandComparison)); } - internal void Validate(string command) + internal void Validate(string command, ExternalExecution execution) { - if (!Allows(command)) + if (!Allows(command) || (!allowAll && execution.EnvironmentVariables?.Count > 0)) { throw new ExecCredentialPluginDeniedException(command); } @@ -91,6 +92,12 @@ private static bool IsBasename(string command) return command.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':' }) < 0; } + private static bool HasInvalidCommandCharacters(string command) + { + return command.IndexOfAny(Path.GetInvalidPathChars()) >= 0 || + command.IndexOfAny(new[] { '"', '\0', '\r', '\n' }) >= 0; + } + private static bool IsAbsolutePath(string command) { if (!Path.IsPathRooted(command)) @@ -100,7 +107,8 @@ private static bool IsAbsolutePath(string command) var root = Path.GetPathRoot(command); return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || - (root.Length > 1 && root[root.Length - 1] == Path.DirectorySeparatorChar); + root.StartsWith(@"\\", StringComparison.Ordinal) || root.StartsWith("//", StringComparison.Ordinal) || + (root.Length >= 3 && root[1] == ':' && (root[2] == '\\' || root[2] == '/')); } } } diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index 12ce81895..38e31b647 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -577,10 +577,12 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co throw new ArgumentNullException(nameof(config)); } + var policy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll; + policy.Validate(config.Command, config); var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); - (execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll).Validate(process.StartInfo.FileName); + policy.Validate(process.StartInfo.FileName, config); try { process.Start(); diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs index 0dce74209..d6cab926f 100644 --- a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -42,6 +42,9 @@ public void AllowlistMatchesBasenamesAndExactPaths() [InlineData("plugins/trusted-plugin")] [InlineData("*")] [InlineData("trusted?plugin")] + [InlineData("trusted\"plugin")] + [InlineData("trusted\rplugin")] + [InlineData("trusted\nplugin")] [InlineData("trusted\0plugin")] public void InvalidAllowlistEntriesAreRejected(string entry) { @@ -65,6 +68,74 @@ public void AllowlistCopiesApplicationInput() Assert.False(policy.Allows("untrusted-plugin")); } + [Fact] + public void EmbeddedQuotesCannotBypassBasenamePolicy() + { + using var plugin = new TestPlugin(); + plugin.Exec.Command += $"\" \"{Path.Combine(Path.GetTempPath(), "unused", "trusted-plugin")}"; + var policy = ExecCredentialPluginPolicy.Allowlist("trusted-plugin"); + Assert.False(policy.Allows(plugin.Exec.Command)); + Assert.Throws(() => + KubernetesClientConfiguration.ExecuteExternalCommand(plugin.Exec, policy)); + Assert.False(File.Exists(plugin.Marker)); + } + + [Fact] + public void WindowsAbsolutePathsIncludeUncAndForwardSlashes() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + foreach (var path in new[] { @"\\server\share\trusted-plugin.exe", "C:/plugins/trusted-plugin.exe" }) + { + var policy = ExecCredentialPluginPolicy.Allowlist(path); + Assert.True(policy.Allows(path)); + Assert.False(policy.Allows("trusted-plugin.exe")); + } + + Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist(@"C:trusted-plugin.exe")); + Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist(@"\plugins\trusted-plugin.exe")); + } + + [Theory] + [InlineData("PATH")] + [InlineData("LD_PRELOAD")] + [InlineData("DYLD_INSERT_LIBRARIES")] + [InlineData("PLUGIN_SETTING")] + public async Task AllowlistRejectsKubeconfigEnvironmentOverrides(string name) + { + using var plugin = new TestPlugin(); + plugin.Exec.EnvironmentVariables = new List> + { + new Dictionary { { "name", name }, { "value", "untrusted-value" } }, + }; + var policy = ExecCredentialPluginPolicy.Allowlist(plugin.Exec.Command); + Assert.Throws(() => + KubernetesClientConfiguration.ExecuteExternalCommand(plugin.Exec, policy)); + plugin.WriteConfig(); + Assert.Throws(() => + KubernetesClientConfiguration.BuildConfigFromConfigObject(plugin.LoadConfig(), execCredentialPluginPolicy: policy)); + var provider = new ExecTokenProvider(plugin.Exec, policy); + await Assert.ThrowsAsync(() => + provider.GetAuthenticationHeaderAsync(CancellationToken.None)); + Assert.False(File.Exists(plugin.Marker)); + } + + [Fact] + public void AllowAllPreservesKubeconfigEnvironmentOverrides() + { + using var plugin = new TestPlugin(); + plugin.Exec.EnvironmentVariables = new List> + { + new Dictionary { { "name", "PLUGIN_SETTING" }, { "value", "trusted-value" } }, + }; + var response = KubernetesClientConfiguration.ExecuteExternalCommand(plugin.Exec); + Assert.Equal("test-token", response.Status.Token); + Assert.True(File.Exists(plugin.Marker)); + } + [Theory] [InlineData("deny")] [InlineData("empty")] @@ -211,14 +282,7 @@ public TestPlugin() Arguments = windows ? new List { "/c", $"\"{script}\"" } : new List { $"\"{script}\"" }, }; KubeconfigPath = Path.Combine(directory, "config"); - File.WriteAllText(KubeconfigPath, KubernetesYaml.Serialize(new K8SConfiguration - { - CurrentContext = "test", - Contexts = new[] { new Context { Name = "test", ContextDetails = new ContextDetails { Cluster = "test", User = "test" } } }, - Clusters = new[] { new Cluster { Name = "test", ClusterEndpoint = new ClusterEndpoint { Server = "https://localhost", SkipTlsVerify = true } } }, - Users = new[] { new User { Name = "test", UserCredentials = new UserCredentials { ExternalExecution = Exec } } }, - Preferences = new Dictionary { { "execCredentialPluginPolicy", "AllowAll" } }, - })); + WriteConfig(); } public ExternalExecution Exec { get; } @@ -229,6 +293,18 @@ public TestPlugin() public K8SConfiguration LoadConfig() => KubernetesClientConfiguration.LoadKubeConfig(KubeconfigPath); + public void WriteConfig() + { + File.WriteAllText(KubeconfigPath, KubernetesYaml.Serialize(new K8SConfiguration + { + CurrentContext = "test", + Contexts = new[] { new Context { Name = "test", ContextDetails = new ContextDetails { Cluster = "test", User = "test" } } }, + Clusters = new[] { new Cluster { Name = "test", ClusterEndpoint = new ClusterEndpoint { Server = "https://localhost", SkipTlsVerify = true } } }, + Users = new[] { new User { Name = "test", UserCredentials = new UserCredentials { ExternalExecution = Exec } } }, + Preferences = new Dictionary { { "execCredentialPluginPolicy", "AllowAll" } }, + })); + } + public void Dispose() => Directory.Delete(directory, true); } } diff --git a/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs b/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs index f31205e60..10c29b2ca 100644 --- a/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs +++ b/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs @@ -557,8 +557,9 @@ public void DefaultConfigEnforcesExecPolicy(bool multipleConfigs) try { File.WriteAllText(path, KubernetesYaml.Serialize(kubeconfig)); - Environment.SetEnvironmentVariable(environmentVariable, multipleConfigs ? - string.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ";" : ":", path, path) : path); + var paths = multipleConfigs ? + string.Join(RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ";" : ":", path, path) : path; + Environment.SetEnvironmentVariable(environmentVariable, paths); KubernetesClientConfiguration.KubeConfigEnvironmentVariable = environmentVariable; Assert.Throws(() => KubernetesClientConfiguration.BuildDefaultConfig(ExecCredentialPluginPolicy.DenyAll)); From 36876d068d651f55340b3674b0e6ecbfbbb427be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:03:22 +0000 Subject: [PATCH 04/12] Document exec policy defense-in-depth limits Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index deaaa8514..b76fb2c52 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,10 @@ Kubeconfig `exec` plugins start local processes, including while loading configu Only load trusted kubeconfigs with the default `AllowAll` policy. For untrusted input, applications can deny exec plugins or explicitly allow approved executables: +This policy is a defense-in-depth measure, not a hard security boundary. Loading a +kubeconfig from an untrusted source is inherently dangerous; handle that risk as part +of your application's overall security model, and do not rely on this policy alone. + ```c# var config = KubernetesClientConfiguration.BuildConfigFromConfigFile( kubeConfigPath, execCredentialPluginPolicy: ExecCredentialPluginPolicy.DenyAll); From 6b8c21d1ad6684fcf3c38947812195a10ad137d2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 16 Oct 2026 23:47:17 +0000 Subject: [PATCH 05/12] Match exec allowlist to kuberc command resolution Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- README.md | 21 +-- ...ubernetesClientConfiguration.ConfigFile.cs | 2 +- .../ExecCredentialPluginPolicy.cs | 144 ++++++++++++++++-- ...ubernetesClientConfiguration.ConfigFile.cs | 2 +- .../ExecCredentialPluginPolicyTests.cs | 60 +++++--- 5 files changed, 187 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index b76fb2c52..ed902fe66 100644 --- a/README.md +++ b/README.md @@ -75,16 +75,17 @@ The same policy parameter is available on default, stream, async, and pre-loaded builders. Policies are supplied by the application, not read from kubeconfig, and apply to both initial execution and token refresh. Denials throw `k8s.Exceptions.ExecCredentialPluginDeniedException` before the process starts. -An empty allowlist denies everything; null or invalid allowlist entries are rejected. - -Basenames allow that name in **any directory**, including `PATH` searches. Absolute -paths match exactly (case-insensitively on Windows, case-sensitively elsewhere), -without path normalization or symlink resolution. Prefer absolute paths to executables -in application-controlled directories. The policy does not validate arguments -or executable contents. Allowlist mode rejects all kubeconfig-supplied environment -variables, which could otherwise alter command resolution or inject code through -loader settings. Trusted environment settings can be supplied by the application -through its own process environment. Allowing an interpreter or other +An empty or null allowlist is invalid, as are invalid entries. + +An entry matches when its command text exactly matches the kubeconfig command or +when both resolve to the same executable using the application's `PATH`. Thus a +basename does not allow a same-named executable in an arbitrary directory. Path +resolution does not follow symlinks or support shell globs. Prefer absolute paths to +executables in application-controlled directories. The policy does not validate +arguments or executable contents. Allowlist mode rejects all kubeconfig-supplied +environment variables, which could otherwise alter command resolution or inject code +through loader settings. Trusted environment settings can be supplied by the +application through its own process environment. Allowing an interpreter or other general-purpose launcher can permit arbitrary code execution. It is not a sandbox. ### Listing Objects diff --git a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs index de2c8526f..b913adcf0 100644 --- a/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient.Aot/KubernetesClientConfiguration.ConfigFile.cs @@ -565,7 +565,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); - policy.Validate(process.StartInfo.FileName, config); + process.StartInfo.FileName = policy.Validate(process.StartInfo.FileName, config); try { process.Start(); diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index c456bd997..2cf706821 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -9,9 +9,6 @@ namespace k8s /// public sealed class ExecCredentialPluginPolicy { - private static readonly StringComparison CommandComparison = - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - private readonly bool allowAll; private readonly string[] commands; @@ -32,12 +29,11 @@ private ExecCredentialPluginPolicy(bool allowAll, string[] commands) public static ExecCredentialPluginPolicy DenyAll { get; } = new ExecCredentialPluginPolicy(false, Array.Empty()); /// - /// Allows command basenames or exact, absolute executable paths. An empty list denies all commands. + /// Allows command basenames or absolute executable paths. The list must not be empty. /// /// - /// Basenames allow that executable name in any directory, including directories searched through PATH. - /// Absolute paths use exact textual matching, without resolving symlinks or normalizing paths. - /// Matching is case-insensitive on Windows and case-sensitive elsewhere. + /// Commands match if their text is identical or if both resolve to the same executable using PATH. + /// Resolution does not follow symlinks or support shell globs. /// Kubeconfig-supplied environment variables are rejected. Plugin arguments are not restricted. /// /// Approved basenames or absolute paths. Relative paths and invalid entries are rejected. @@ -50,6 +46,11 @@ public static ExecCredentialPluginPolicy Allowlist(params string[] commands) } var copy = (string[])commands.Clone(); + if (copy.Length == 0) + { + throw new ArgumentException("Exec allowlists must contain at least one command.", nameof(commands)); + } + foreach (var command in copy) { if (string.IsNullOrWhiteSpace(command) || command == "." || command == ".." || @@ -64,6 +65,9 @@ public static ExecCredentialPluginPolicy Allowlist(params string[] commands) } internal bool Allows(string command) + => Allows(command, Environment.GetEnvironmentVariable("PATH")); + + internal bool Allows(string command, string path) { if (allowAll) { @@ -75,16 +79,134 @@ internal bool Allows(string command) return false; } - return commands.Any(entry => string.Equals( - entry, IsBasename(entry) ? Path.GetFileName(command) : command, CommandComparison)); + if (commands.Any(entry => string.Equals(entry, command, StringComparison.Ordinal))) + { + return true; + } + + if (!TryResolveCommand(command, path, out var resolvedCommand)) + { + return false; + } + + return commands.Any(entry => + TryResolveCommand(entry, path, out var resolvedEntry) && + string.Equals(resolvedEntry, resolvedCommand, StringComparison.Ordinal)); } - internal void Validate(string command, ExternalExecution execution) + internal string Validate(string command, ExternalExecution execution) { - if (!Allows(command) || (!allowAll && execution.EnvironmentVariables?.Count > 0)) + if (allowAll) + { + return command; + } + + if (execution.EnvironmentVariables?.Count > 0 || !Allows(command)) { throw new ExecCredentialPluginDeniedException(command); } + + return TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), out var resolvedCommand) + ? resolvedCommand + : command; + } + + private static bool TryResolveCommand(string command, string path, out string resolvedCommand) + { + resolvedCommand = null; + if (string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) + { + return false; + } + + if (Path.IsPathRooted(command) || + command.IndexOf(Path.DirectorySeparatorChar) >= 0 || + command.IndexOf(Path.AltDirectorySeparatorChar) >= 0) + { + foreach (var extension in GetExecutableExtensions(command)) + { + if (TryGetFullExistingPath(command + extension, out resolvedCommand)) + { + return true; + } + } + + return false; + } + + if (string.IsNullOrEmpty(path)) + { + return false; + } + + foreach (var directory in path.Split(Path.PathSeparator)) + { + if (string.IsNullOrEmpty(directory) || !Path.IsPathRooted(directory)) + { + continue; + } + + foreach (var extension in GetExecutableExtensions(command)) + { + if (TryGetFullExistingPath(Path.Combine(directory, command + extension), out resolvedCommand)) + { + return true; + } + } + } + + return false; + } + + private static bool TryGetFullExistingPath(string command, out string resolvedCommand) + { + resolvedCommand = null; + try + { + var fullPath = Path.GetFullPath(command); + if (!File.Exists(fullPath)) + { + return false; + } + + resolvedCommand = fullPath; + return true; + } + catch (ArgumentException) + { + return false; + } + catch (NotSupportedException) + { + return false; + } + catch (PathTooLongException) + { + return false; + } + } + + private static IEnumerable GetExecutableExtensions(string command) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || Path.HasExtension(command)) + { + yield return string.Empty; + yield break; + } + + var pathExtensions = Environment.GetEnvironmentVariable("PATHEXT"); + if (string.IsNullOrEmpty(pathExtensions)) + { + pathExtensions = ".COM;.EXE;.BAT;.CMD"; + } + + foreach (var extension in pathExtensions.Split(';')) + { + if (!string.IsNullOrEmpty(extension)) + { + yield return extension; + } + } } private static bool IsBasename(string command) diff --git a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs index 38e31b647..cc25457e8 100644 --- a/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs +++ b/src/KubernetesClient/KubernetesClientConfiguration.ConfigFile.cs @@ -582,7 +582,7 @@ public static ExecCredentialResponse ExecuteExternalCommand(ExternalExecution co var captureStdError = ExecStdError; var process = CreateRunnableExternalProcess(config, captureStdError); - policy.Validate(process.StartInfo.FileName, config); + process.StartInfo.FileName = policy.Validate(process.StartInfo.FileName, config); try { process.Start(); diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs index d6cab926f..7514f15ac 100644 --- a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -14,23 +14,41 @@ namespace k8s.Tests public class ExecCredentialPluginPolicyTests { [Fact] - public void AllowlistMatchesBasenamesAndExactPaths() + public void AllowlistMatchesExactCommandsAndResolvedExecutablePaths() { - var path = Path.Combine(Path.GetTempPath(), "trusted-plugin"); - var otherPath = Path.Combine(Path.GetTempPath(), "other", "trusted-plugin"); - var basenamePolicy = ExecCredentialPluginPolicy.Allowlist("trusted-plugin"); - Assert.True(basenamePolicy.Allows("trusted-plugin")); - Assert.True(basenamePolicy.Allows(path)); - Assert.True(basenamePolicy.Allows(otherPath)); - Assert.False(basenamePolicy.Allows("untrusted-plugin")); - Assert.False(basenamePolicy.Allows("trusted-plugin-other")); + var directory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var searchDirectory = Path.Combine(directory, "bin"); + var otherDirectory = Path.Combine(directory, "other"); + Directory.CreateDirectory(searchDirectory); + Directory.CreateDirectory(otherDirectory); - var pathPolicy = ExecCredentialPluginPolicy.Allowlist(path); - Assert.True(pathPolicy.Allows(path)); - Assert.False(pathPolicy.Allows("trusted-plugin")); - Assert.False(pathPolicy.Allows(otherPath)); - Assert.False(pathPolicy.Allows(Path.Combine(Path.GetDirectoryName(path), ".", "trusted-plugin"))); - Assert.Equal(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), pathPolicy.Allows(path.ToUpperInvariant())); + try + { + var extension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; + var commandName = "trusted-plugin"; + var path = Path.Combine(searchDirectory, commandName + extension); + var otherPath = Path.Combine(otherDirectory, commandName + extension); + File.WriteAllText(path, string.Empty); + File.WriteAllText(otherPath, string.Empty); + + var basenamePolicy = ExecCredentialPluginPolicy.Allowlist(commandName); + Assert.True(basenamePolicy.Allows(commandName, searchDirectory)); + Assert.True(basenamePolicy.Allows(path, searchDirectory)); + Assert.False(basenamePolicy.Allows(otherPath, searchDirectory)); + Assert.False(basenamePolicy.Allows("untrusted-plugin", searchDirectory)); + Assert.False(basenamePolicy.Allows("trusted-plugin-other", searchDirectory)); + + var pathPolicy = ExecCredentialPluginPolicy.Allowlist(path); + Assert.True(pathPolicy.Allows(path, searchDirectory)); + Assert.True(pathPolicy.Allows(commandName, searchDirectory)); + Assert.False(pathPolicy.Allows(otherPath, searchDirectory)); + Assert.True(pathPolicy.Allows(Path.Combine(searchDirectory, ".", commandName + extension), searchDirectory)); + Assert.False(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory)); + } + finally + { + Directory.Delete(directory, true); + } } [Theory] @@ -52,9 +70,9 @@ public void InvalidAllowlistEntriesAreRejected(string entry) } [Fact] - public void EmptyAndNullAllowlistsFailClosed() + public void EmptyAndNullAllowlistsAreRejected() { - Assert.False(ExecCredentialPluginPolicy.Allowlist().Allows("trusted-plugin")); + Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist()); Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist(null)); } @@ -138,13 +156,11 @@ public void AllowAllPreservesKubeconfigEnvironmentOverrides() [Theory] [InlineData("deny")] - [InlineData("empty")] [InlineData("unmatched")] public async Task DeniedPluginNeverStarts(string mode) { using var plugin = new TestPlugin(); var policy = mode == "deny" ? ExecCredentialPluginPolicy.DenyAll : - mode == "empty" ? ExecCredentialPluginPolicy.Allowlist() : ExecCredentialPluginPolicy.Allowlist("another-plugin"); var exception = Assert.Throws(() => @@ -167,6 +183,12 @@ await Assert.ThrowsAsync(() => public async Task AllowedPluginRunsAndRefreshes(string mode) { using var plugin = new TestPlugin(); + if (mode == "basename") + { + plugin.Exec.Command = Path.GetFileName(plugin.Exec.Command); + plugin.WriteConfig(); + } + var policy = mode == "default" ? null : mode == "all" ? ExecCredentialPluginPolicy.AllowAll : mode == "basename" ? ExecCredentialPluginPolicy.Allowlist(Path.GetFileName(plugin.Exec.Command)) : From 506e7558b37776e5b30f5ab1db95c4e1de43d959 Mon Sep 17 00:00:00 2001 From: Brendan Burns <5751682+brendandburns@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:14:20 -0700 Subject: [PATCH 06/12] Improve command validation logic in ExecCredentialPluginPolicy Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/KubernetesClient/ExecCredentialPluginPolicy.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index 2cf706821..d5054f7dc 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -74,7 +74,7 @@ internal bool Allows(string command, string path) return true; } - if (string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) + if (commands.Length == 0 || string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) { return false; } From 0157b92682b1675106ab396ebee682f136d2d21e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:40:24 +0000 Subject: [PATCH 07/12] Allow safe exec environment overrides Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- README.md | 10 ++--- .../ExecCredentialPluginPolicy.cs | 38 ++++++++++++++++++- .../ExecCredentialPluginPolicyTests.cs | 28 +++++++++++++- 3 files changed, 67 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ed902fe66..9f2a28530 100644 --- a/README.md +++ b/README.md @@ -82,11 +82,11 @@ when both resolve to the same executable using the application's `PATH`. Thus a basename does not allow a same-named executable in an arbitrary directory. Path resolution does not follow symlinks or support shell globs. Prefer absolute paths to executables in application-controlled directories. The policy does not validate -arguments or executable contents. Allowlist mode rejects all kubeconfig-supplied -environment variables, which could otherwise alter command resolution or inject code -through loader settings. Trusted environment settings can be supplied by the -application through its own process environment. Allowing an interpreter or other -general-purpose launcher can permit arbitrary code execution. It is not a sandbox. +arguments or executable contents. Allowlist mode rejects kubeconfig-supplied +environment variables that can alter executable resolution or inject code, including +`PATH`, dynamic-loader settings, and common language-runtime injection settings. +Ordinary variables such as `AWS_PROFILE` remain allowed. Allowing an interpreter or +other general-purpose launcher can permit arbitrary code execution. It is not a sandbox. ### Listing Objects ```c# diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index d5054f7dc..e1ce12197 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -34,7 +34,8 @@ private ExecCredentialPluginPolicy(bool allowAll, string[] commands) /// /// Commands match if their text is identical or if both resolve to the same executable using PATH. /// Resolution does not follow symlinks or support shell globs. - /// Kubeconfig-supplied environment variables are rejected. Plugin arguments are not restricted. + /// Environment variables that can redirect executable resolution or inject code are rejected. + /// Other kubeconfig-supplied environment variables are allowed. Plugin arguments are not restricted. /// /// Approved basenames or absolute paths. Relative paths and invalid entries are rejected. /// An immutable allowlist policy. @@ -101,7 +102,7 @@ internal string Validate(string command, ExternalExecution execution) return command; } - if (execution.EnvironmentVariables?.Count > 0 || !Allows(command)) + if (HasDangerousEnvironmentVariable(execution.EnvironmentVariables) || !Allows(command)) { throw new ExecCredentialPluginDeniedException(command); } @@ -111,6 +112,39 @@ internal string Validate(string command, ExternalExecution execution) : command; } + private static bool HasDangerousEnvironmentVariable(IList> environmentVariables) + { + return environmentVariables?.Any(variable => + variable != null && variable.ContainsKey("name") && + IsDangerousEnvironmentVariable(variable["name"])) == true; + } + + private static bool IsDangerousEnvironmentVariable(string name) + { + return name != null && (string.Equals(name, "PATH", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PATHEXT", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "COMSPEC", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "LIBPATH", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "SHLIB_PATH", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("LD_", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("DYLD_", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PYTHONPATH", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PYTHONHOME", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PYTHONSTARTUP", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PYTHONINSPECT", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PERL5LIB", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PERL5OPT", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "RUBYOPT", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "RUBYLIB", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "NODE_OPTIONS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "NODE_PATH", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "JAVA_TOOL_OPTIONS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "JDK_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "CLASSPATH", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "DOTNET_STARTUP_HOOKS", StringComparison.OrdinalIgnoreCase)); + } + private static bool TryResolveCommand(string command, string path, out string resolvedCommand) { resolvedCommand = null; diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs index 7514f15ac..f0e8ea79a 100644 --- a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -119,10 +119,16 @@ public void WindowsAbsolutePathsIncludeUncAndForwardSlashes() [Theory] [InlineData("PATH")] + [InlineData("PATHEXT")] + [InlineData("COMSPEC")] [InlineData("LD_PRELOAD")] + [InlineData("LD_LIBRARY_PATH")] [InlineData("DYLD_INSERT_LIBRARIES")] - [InlineData("PLUGIN_SETTING")] - public async Task AllowlistRejectsKubeconfigEnvironmentOverrides(string name) + [InlineData("PYTHONPATH")] + [InlineData("NODE_OPTIONS")] + [InlineData("JAVA_TOOL_OPTIONS")] + [InlineData("DOTNET_STARTUP_HOOKS")] + public async Task AllowlistRejectsDangerousKubeconfigEnvironmentOverrides(string name) { using var plugin = new TestPlugin(); plugin.Exec.EnvironmentVariables = new List> @@ -141,6 +147,24 @@ await Assert.ThrowsAsync(() => Assert.False(File.Exists(plugin.Marker)); } + [Theory] + [InlineData("AWS_PROFILE", "test-profile")] + [InlineData("PLUGIN_SETTING", "trusted-value")] + public void AllowlistAllowsOrdinaryKubeconfigEnvironmentOverrides(string name, string value) + { + using var plugin = new TestPlugin(); + plugin.Exec.EnvironmentVariables = new List> + { + new Dictionary { { "name", name }, { "value", value } }, + }; + var policy = ExecCredentialPluginPolicy.Allowlist(plugin.Exec.Command); + + var response = KubernetesClientConfiguration.ExecuteExternalCommand(plugin.Exec, policy); + + Assert.Equal("test-token", response.Status.Token); + Assert.True(File.Exists(plugin.Marker)); + } + [Fact] public void AllowAllPreservesKubeconfigEnvironmentOverrides() { From 72e7598029a625cfd6a5080a2609d547d552badf Mon Sep 17 00:00:00 2001 From: Brendan Burns <5751682+brendandburns@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:44:47 -0700 Subject: [PATCH 08/12] Add additional environment variable checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/KubernetesClient/ExecCredentialPluginPolicy.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index e1ce12197..db877985b 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -142,7 +142,9 @@ private static bool IsDangerousEnvironmentVariable(string name) string.Equals(name, "_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "JDK_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "CLASSPATH", StringComparison.OrdinalIgnoreCase) || - string.Equals(name, "DOTNET_STARTUP_HOOKS", StringComparison.OrdinalIgnoreCase)); + string.Equals(name, "DOTNET_STARTUP_HOOKS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "BASH_ENV", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PYTHONUSERBASE", StringComparison.OrdinalIgnoreCase)); } private static bool TryResolveCommand(string command, string path, out string resolvedCommand) From 10dd249f0307a8e49fe01c449caa97ffe3eee354 Mon Sep 17 00:00:00 2001 From: Brendan Burns <5751682+brendandburns@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:45:37 -0700 Subject: [PATCH 09/12] Refactor command resolution logic with exception handling Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/KubernetesClient/ExecCredentialPluginPolicy.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index db877985b..c1ec0fbf8 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -107,9 +107,12 @@ internal string Validate(string command, ExternalExecution execution) throw new ExecCredentialPluginDeniedException(command); } - return TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), out var resolvedCommand) - ? resolvedCommand - : command; + if (!TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), out var resolvedCommand)) + { + throw new ExecCredentialPluginDeniedException(command); + } + + return resolvedCommand; } private static bool HasDangerousEnvironmentVariable(IList> environmentVariables) From f7bfc6bb75350ea008998e7817aae21bcd0a0465 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:49:21 +0000 Subject: [PATCH 10/12] Prevent probes of unapproved explicit plugin paths Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- .../ExecCredentialPluginPolicy.cs | 120 ++++++++++++++---- .../ExecCredentialPluginPolicyTests.cs | 19 +++ 2 files changed, 114 insertions(+), 25 deletions(-) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index c1ec0fbf8..fc23a83f6 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -69,6 +69,9 @@ internal bool Allows(string command) => Allows(command, Environment.GetEnvironmentVariable("PATH")); internal bool Allows(string command, string path) + => Allows(command, path, File.Exists); + + internal bool Allows(string command, string path, Func fileExists) { if (allowAll) { @@ -85,13 +88,13 @@ internal bool Allows(string command, string path) return true; } - if (!TryResolveCommand(command, path, out var resolvedCommand)) + if (!TryResolveCommand(command, path, fileExists, out var resolvedCommand)) { return false; } return commands.Any(entry => - TryResolveCommand(entry, path, out var resolvedEntry) && + TryResolveCommand(entry, path, fileExists, out var resolvedEntry) && string.Equals(resolvedEntry, resolvedCommand, StringComparison.Ordinal)); } @@ -107,7 +110,7 @@ internal string Validate(string command, ExternalExecution execution) throw new ExecCredentialPluginDeniedException(command); } - if (!TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), out var resolvedCommand)) + if (!TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), File.Exists, out var resolvedCommand)) { throw new ExecCredentialPluginDeniedException(command); } @@ -150,32 +153,49 @@ private static bool IsDangerousEnvironmentVariable(string name) string.Equals(name, "PYTHONUSERBASE", StringComparison.OrdinalIgnoreCase)); } - private static bool TryResolveCommand(string command, string path, out string resolvedCommand) + private bool IsApprovedPathCandidate(string command, string path) { - resolvedCommand = null; - if (string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) + var pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? + StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + foreach (var candidate in GetCommandCandidates(command, path)) { - return false; + if (!TryGetFullPath(candidate, out var fullCandidate)) + { + continue; + } + + foreach (var entry in commands) + { + foreach (var approvedCandidate in GetCommandCandidates(entry, path)) + { + if (TryGetFullPath(approvedCandidate, out var fullApprovedCandidate) && + string.Equals(fullCandidate, fullApprovedCandidate, pathComparison)) + { + return true; + } + } + } } - if (Path.IsPathRooted(command) || - command.IndexOf(Path.DirectorySeparatorChar) >= 0 || - command.IndexOf(Path.AltDirectorySeparatorChar) >= 0) + return false; + } + + private static IEnumerable GetCommandCandidates(string command, string path) + { + if (IsPathCommand(command)) { foreach (var extension in GetExecutableExtensions(command)) { - if (TryGetFullExistingPath(command + extension, out resolvedCommand)) - { - return true; - } + yield return command + extension; } - return false; + yield break; } if (string.IsNullOrEmpty(path)) { - return false; + yield break; } foreach (var directory in path.Split(Path.PathSeparator)) @@ -187,30 +207,73 @@ private static bool TryResolveCommand(string command, string path, out string re foreach (var extension in GetExecutableExtensions(command)) { - if (TryGetFullExistingPath(Path.Combine(directory, command + extension), out resolvedCommand)) + yield return Path.Combine(directory, command + extension); + } + } + } + + private bool TryResolveCommand(string command, string path, Func fileExists, out string resolvedCommand) + { + resolvedCommand = null; + if (string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command)) + { + return false; + } + + if (IsPathCommand(command) && !IsApprovedPathCandidate(command, path)) + { + return false; + } + + if (IsPathCommand(command)) + { + foreach (var candidate in GetCommandCandidates(command, path)) + { + if (TryGetFullExistingPath(candidate, fileExists, out resolvedCommand)) { return true; } } + + return false; + } + + if (string.IsNullOrEmpty(path)) + { + return false; + } + + foreach (var candidate in GetCommandCandidates(command, path)) + { + if (TryGetFullExistingPath(candidate, fileExists, out resolvedCommand)) + { + return true; + } } return false; } - private static bool TryGetFullExistingPath(string command, out string resolvedCommand) + private static bool TryGetFullExistingPath(string command, Func fileExists, out string resolvedCommand) { resolvedCommand = null; - try + if (TryGetFullPath(command, out var fullPath) && fileExists(fullPath)) { - var fullPath = Path.GetFullPath(command); - if (!File.Exists(fullPath)) - { - return false; - } - resolvedCommand = fullPath; return true; } + + return false; + } + + private static bool TryGetFullPath(string command, out string fullPath) + { + fullPath = null; + try + { + fullPath = Path.GetFullPath(command); + return true; + } catch (ArgumentException) { return false; @@ -225,6 +288,13 @@ private static bool TryGetFullExistingPath(string command, out string resolvedCo } } + private static bool IsPathCommand(string command) + { + return Path.IsPathRooted(command) || + command.IndexOf(Path.DirectorySeparatorChar) >= 0 || + command.IndexOf(Path.AltDirectorySeparatorChar) >= 0; + } + private static IEnumerable GetExecutableExtensions(string command) { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || Path.HasExtension(command)) diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs index f0e8ea79a..ff00c92ff 100644 --- a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -117,6 +117,25 @@ public void WindowsAbsolutePathsIncludeUncAndForwardSlashes() Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist(@"\plugins\trusted-plugin.exe")); } + [Fact] + public void DeniedUncCommandDoesNotProbeTheFileSystem() + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + var policy = ExecCredentialPluginPolicy.Allowlist(@"C:\plugins\trusted-plugin.exe"); + var fileSystemWasProbed = false; + + Assert.False(policy.Allows(@"\\attacker\share\plugin.exe", @"C:\plugins", _ => + { + fileSystemWasProbed = true; + return false; + })); + Assert.False(fileSystemWasProbed); + } + [Theory] [InlineData("PATH")] [InlineData("PATHEXT")] From 53fbfcf27586ec3f581a7e1b5a1338be0c8b1e4a Mon Sep 17 00:00:00 2001 From: Brendan Burns <5751682+brendandburns@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:51:22 -0700 Subject: [PATCH 11/12] Add profiling environment variable checks Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/KubernetesClient/ExecCredentialPluginPolicy.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index fc23a83f6..f4026efc0 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -149,6 +149,10 @@ private static bool IsDangerousEnvironmentVariable(string name) string.Equals(name, "JDK_JAVA_OPTIONS", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "CLASSPATH", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "DOTNET_STARTUP_HOOKS", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "CORECLR_ENABLE_PROFILING", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("CORECLR_PROFILER", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "COR_ENABLE_PROFILING", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("COR_PROFILER", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "BASH_ENV", StringComparison.OrdinalIgnoreCase) || string.Equals(name, "PYTHONUSERBASE", StringComparison.OrdinalIgnoreCase)); } From 8a6b270aa061fbe47dade2cad9289e5da17f0db1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:49:29 +0000 Subject: [PATCH 12/12] Match resolved paths case-insensitively on Windows Co-authored-by: brendandburns <5751682+brendandburns@users.noreply.github.com> --- src/KubernetesClient/ExecCredentialPluginPolicy.cs | 7 ++++++- .../ExecCredentialPluginPolicyTests.cs | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/KubernetesClient/ExecCredentialPluginPolicy.cs b/src/KubernetesClient/ExecCredentialPluginPolicy.cs index f4026efc0..fe6f433b9 100644 --- a/src/KubernetesClient/ExecCredentialPluginPolicy.cs +++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs @@ -95,7 +95,12 @@ internal bool Allows(string command, string path, Func fileExists) return commands.Any(entry => TryResolveCommand(entry, path, fileExists, out var resolvedEntry) && - string.Equals(resolvedEntry, resolvedCommand, StringComparison.Ordinal)); + string.Equals( + resolvedEntry, + resolvedCommand, + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal)); } internal string Validate(string command, ExternalExecution execution) diff --git a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs index ff00c92ff..b27f9b370 100644 --- a/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs +++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs @@ -43,7 +43,14 @@ public void AllowlistMatchesExactCommandsAndResolvedExecutablePaths() Assert.True(pathPolicy.Allows(commandName, searchDirectory)); Assert.False(pathPolicy.Allows(otherPath, searchDirectory)); Assert.True(pathPolicy.Allows(Path.Combine(searchDirectory, ".", commandName + extension), searchDirectory)); - Assert.False(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory)); + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + Assert.True(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory)); + } + else + { + Assert.False(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory)); + } } finally {