diff --git a/README.md b/README.md
index ab022676b..9f2a28530 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,43 @@ 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:
+
+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);
+
+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 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 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#
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..b913adcf0 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,15 +545,27 @@ 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)
{
throw new ArgumentNullException(nameof(config));
}
+ var policy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll;
+ policy.Validate(config.Command, config);
var captureStdError = ExecStdError;
var process = CreateRunnableExternalProcess(config, captureStdError);
+ process.StartInfo.FileName = policy.Validate(process.StartInfo.FileName, config);
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..fe6f433b9
--- /dev/null
+++ b/src/KubernetesClient/ExecCredentialPluginPolicy.cs
@@ -0,0 +1,354 @@
+using k8s.Exceptions;
+using k8s.KubeConfigModels;
+using System.Runtime.InteropServices;
+
+namespace k8s
+{
+ ///
+ /// Application-controlled policy for kubeconfig exec credential plugins.
+ ///
+ public sealed class ExecCredentialPluginPolicy
+ {
+ 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 absolute executable paths. The list must not be empty.
+ ///
+ ///
+ /// 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.
+ /// 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.
+ public static ExecCredentialPluginPolicy Allowlist(params string[] commands)
+ {
+ if (commands == null)
+ {
+ throw new ArgumentNullException(nameof(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 == ".." ||
+ 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));
+ }
+ }
+
+ return new ExecCredentialPluginPolicy(false, copy);
+ }
+
+ 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)
+ {
+ return true;
+ }
+
+ if (commands.Length == 0 || string.IsNullOrWhiteSpace(command) || HasInvalidCommandCharacters(command))
+ {
+ return false;
+ }
+
+ if (commands.Any(entry => string.Equals(entry, command, StringComparison.Ordinal)))
+ {
+ return true;
+ }
+
+ if (!TryResolveCommand(command, path, fileExists, out var resolvedCommand))
+ {
+ return false;
+ }
+
+ return commands.Any(entry =>
+ TryResolveCommand(entry, path, fileExists, out var resolvedEntry) &&
+ string.Equals(
+ resolvedEntry,
+ resolvedCommand,
+ RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal));
+ }
+
+ internal string Validate(string command, ExternalExecution execution)
+ {
+ if (allowAll)
+ {
+ return command;
+ }
+
+ if (HasDangerousEnvironmentVariable(execution.EnvironmentVariables) || !Allows(command))
+ {
+ throw new ExecCredentialPluginDeniedException(command);
+ }
+
+ if (!TryResolveCommand(command, Environment.GetEnvironmentVariable("PATH"), File.Exists, out var resolvedCommand))
+ {
+ throw new ExecCredentialPluginDeniedException(command);
+ }
+
+ return resolvedCommand;
+ }
+
+ 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) ||
+ 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));
+ }
+
+ private bool IsApprovedPathCandidate(string command, string path)
+ {
+ var pathComparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ?
+ StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ foreach (var candidate in GetCommandCandidates(command, path))
+ {
+ 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;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static IEnumerable GetCommandCandidates(string command, string path)
+ {
+ if (IsPathCommand(command))
+ {
+ foreach (var extension in GetExecutableExtensions(command))
+ {
+ yield return command + extension;
+ }
+
+ yield break;
+ }
+
+ if (string.IsNullOrEmpty(path))
+ {
+ yield break;
+ }
+
+ foreach (var directory in path.Split(Path.PathSeparator))
+ {
+ if (string.IsNullOrEmpty(directory) || !Path.IsPathRooted(directory))
+ {
+ continue;
+ }
+
+ foreach (var extension in GetExecutableExtensions(command))
+ {
+ 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, Func fileExists, out string resolvedCommand)
+ {
+ resolvedCommand = null;
+ if (TryGetFullPath(command, out var fullPath) && fileExists(fullPath))
+ {
+ 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;
+ }
+ catch (NotSupportedException)
+ {
+ return false;
+ }
+ catch (PathTooLongException)
+ {
+ return false;
+ }
+ }
+
+ 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))
+ {
+ 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)
+ {
+ 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))
+ {
+ return false;
+ }
+
+ var root = Path.GetPathRoot(command);
+ return !RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ||
+ 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 ca9e206bd..cc25457e8 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,15 +562,27 @@ 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)
{
throw new ArgumentNullException(nameof(config));
}
+ var policy = execCredentialPluginPolicy ?? ExecCredentialPluginPolicy.AllowAll;
+ policy.Validate(config.Command, config);
var captureStdError = ExecStdError;
var process = CreateRunnableExternalProcess(config, captureStdError);
+ 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
new file mode 100644
index 000000000..b27f9b370
--- /dev/null
+++ b/tests/KubernetesClient.Tests/ExecCredentialPluginPolicyTests.cs
@@ -0,0 +1,383 @@
+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 AllowlistMatchesExactCommandsAndResolvedExecutablePaths()
+ {
+ 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);
+
+ 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));
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.True(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory));
+ }
+ else
+ {
+ Assert.False(pathPolicy.Allows(path.ToUpperInvariant(), searchDirectory));
+ }
+ }
+ finally
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(".")]
+ [InlineData("..")]
+ [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)
+ {
+ Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist("trusted-plugin", entry));
+ }
+
+ [Fact]
+ public void EmptyAndNullAllowlistsAreRejected()
+ {
+ Assert.Throws(() => ExecCredentialPluginPolicy.Allowlist());
+ 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"));
+ }
+
+ [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"));
+ }
+
+ [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")]
+ [InlineData("COMSPEC")]
+ [InlineData("LD_PRELOAD")]
+ [InlineData("LD_LIBRARY_PATH")]
+ [InlineData("DYLD_INSERT_LIBRARIES")]
+ [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>
+ {
+ 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));
+ }
+
+ [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()
+ {
+ 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("unmatched")]
+ public async Task DeniedPluginNeverStarts(string mode)
+ {
+ using var plugin = new TestPlugin();
+ var policy = mode == "deny" ? ExecCredentialPluginPolicy.DenyAll :
+ 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();
+ 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)) :
+ 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");
+ WriteConfig();
+ }
+
+ public ExternalExecution Exec { get; }
+
+ public string Marker { get; }
+
+ public string KubeconfigPath { get; }
+
+ 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 397c4f431..10c29b2ca 100644
--- a/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs
+++ b/tests/KubernetesClient.Tests/KubernetesClientConfigurationTests.cs
@@ -530,6 +530,48 @@ 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));
+ 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));
+ }
+ finally
+ {
+ KubernetesClientConfiguration.KubeConfigEnvironmentVariable = previousVariable;
+ Environment.SetEnvironmentVariable(environmentVariable, null);
+ File.Delete(path);
+ }
+ }
+
[Fact]
public void LoadKubeConfigFromEnvironmentVariableMultipleConfigs()
{