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
{