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() {