diff --git a/README.md b/README.md index 4bb7f5861f..6d215bb037 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,34 @@ See the wiki page and documentation [here](https://github.com/kubernetes-client/ - [Client Versioning and Compatibility](https://github.com/kubernetes-client/java/wiki/2.-Versioning-and-Compatibility) - [Code Examples](https://github.com/kubernetes-client/java/wiki/3.-Code-Examples) +### Exec credential plugin policy + +Kubeconfig `exec` credential plugins start local processes. Only load trusted kubeconfigs with the +default `AllowAll` policy. Applications can restrict which plugins may run using +`ExecCredentialPluginPolicy`, which follows the `credentialPluginPolicy` / +`credentialPluginAllowlist` semantics of [kuberc](https://kubernetes.io/docs/reference/kubectl/kuberc/): + +```java +// Deny all exec plugins +ApiClient client = ClientBuilder.standard(ExecCredentialPluginPolicy.DENY_ALL).build(); + +// Only allow specific plugins (basenames resolved via PATH, or full paths) +ApiClient client = ClientBuilder.standard( + ExecCredentialPluginPolicy.allowlist("kubelogin", "/usr/local/bin/my-trusted-plugin")).build(); + +// Use the same policy as kubectl ($KUBERC or ~/.kube/kuberc) +ApiClient client = ClientBuilder.standard(ExecCredentialPluginPolicy.fromDefaultKubeRc()).build(); + +// Or on a KubeConfig loaded manually +kubeConfig.setExecCredentialPluginPolicy(ExecCredentialPluginPolicy.DENY_ALL); +``` + +An allowlist entry matches when it is exactly equal to the plugin command, or when both resolve to the +same executable via `PATH` (symlinks and globs are not resolved). Denied plugins are never started and +an `ExecCredentialPluginDeniedException` is thrown. The policy applies to both initial credential +acquisition and token refresh. It does not restrict plugin arguments or environment, and is a +defense-in-depth measure rather than a sandbox. + ## Release Starting from `20.0.0` (Kubernetes 1.28), `client-java-api` has introduced non-backward-compatible changes. Optional diff --git a/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java b/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java index ae0bfb4011..d3326a058c 100644 --- a/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java +++ b/util/src/main/java/io/kubernetes/client/util/ClientBuilder.java @@ -107,11 +107,46 @@ public static ClientBuilder standard() throws IOException { } public static ClientBuilder standard(boolean persistConfig) throws IOException { + return standard(persistConfig, ExecCredentialPluginPolicy.ALLOW_ALL); + } + + /** + * Creates a builder which is pre-configured as described in {@link #standard()}, applying the + * given policy to any exec credential plugin found in the kubeconfig. + * + * @param execCredentialPluginPolicy the exec credential plugin policy, for example {@link + * ExecCredentialPluginPolicy#fromDefaultKubeRc()}; null allows all plugins + * @return ClientBuilder pre-configured using the precedence of {@link #standard()} + * @throws IOException if the configuration file or a file specified in a configuration file + * cannot be read. + * @throws io.kubernetes.client.util.exception.ExecCredentialPluginDeniedException if the + * kubeconfig requires an exec credential plugin which the policy does not allow + */ + public static ClientBuilder standard(ExecCredentialPluginPolicy execCredentialPluginPolicy) + throws IOException { + return standard(true, execCredentialPluginPolicy); + } + + /** + * Same as {@link #standard(ExecCredentialPluginPolicy)}, optionally persisting refreshed + * credentials to the kubeconfig file. + * + * @param persistConfig whether to persist refreshed credentials to the kubeconfig file + * @param execCredentialPluginPolicy the exec credential plugin policy; null allows all plugins + * @return ClientBuilder pre-configured using the precedence of {@link #standard()} + * @throws IOException if the configuration file or a file specified in a configuration file + * cannot be read. + */ + public static ClientBuilder standard( + boolean persistConfig, ExecCredentialPluginPolicy execCredentialPluginPolicy) + throws IOException { final File kubeConfig = findConfigFromEnv(); - ClientBuilder clientBuilderEnv = getClientBuilder(persistConfig, kubeConfig); + ClientBuilder clientBuilderEnv = + getClientBuilder(persistConfig, kubeConfig, execCredentialPluginPolicy); if (clientBuilderEnv != null) return clientBuilderEnv; final File config = findConfigInHomeDir(); - ClientBuilder clientBuilderHomeDir = getClientBuilder(persistConfig, config); + ClientBuilder clientBuilderHomeDir = + getClientBuilder(persistConfig, config, execCredentialPluginPolicy); if (clientBuilderHomeDir != null) return clientBuilderHomeDir; final File clusterCa = new File(SERVICEACCOUNT_CA_PATH); if (clusterCa.exists()) { @@ -120,7 +155,10 @@ public static ClientBuilder standard(boolean persistConfig) throws IOException { return new ClientBuilder(); } - private static ClientBuilder getClientBuilder(boolean persistConfig, File kubeConfig) + private static ClientBuilder getClientBuilder( + boolean persistConfig, + File kubeConfig, + ExecCredentialPluginPolicy execCredentialPluginPolicy) throws IOException { if (kubeConfig != null) { try (BufferedReader kubeConfigReader = @@ -132,6 +170,7 @@ private static ClientBuilder getClientBuilder(boolean persistConfig, File kubeCo kc.setPersistConfig(new FilePersister(kubeConfig)); } kc.setFile(kubeConfig); + kc.setExecCredentialPluginPolicy(execCredentialPluginPolicy); return kubeconfig(kc); } } diff --git a/util/src/main/java/io/kubernetes/client/util/ExecCredentialPluginPolicy.java b/util/src/main/java/io/kubernetes/client/util/ExecCredentialPluginPolicy.java new file mode 100644 index 0000000000..4071c54a12 --- /dev/null +++ b/util/src/main/java/io/kubernetes/client/util/ExecCredentialPluginPolicy.java @@ -0,0 +1,523 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package io.kubernetes.client.util; + +import io.kubernetes.client.util.exception.ExecCredentialPluginDeniedException; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; + +/** + * Policy controlling which kubeconfig exec credential plugins may be run. + * + *

The semantics mirror the {@code credentialPluginPolicy} and {@code credentialPluginAllowlist} + * fields of a kubectl kuberc + * file: + * + *

+ * + *

A policy can be supplied directly by the application, or loaded from a kuberc file using + * {@link #fromKubeRc(Reader)} / {@link #fromDefaultKubeRc()}. When a plugin is denied, an {@link + * ExecCredentialPluginDeniedException} is thrown before any process is started. + * + *

This policy is a defense-in-depth measure, not a sandbox: it does not restrict plugin + * arguments, environment variables or executable contents. + */ +public final class ExecCredentialPluginPolicy { + private static final Logger log = LoggerFactory.getLogger(ExecCredentialPluginPolicy.class); + + public static final String ENV_KUBERC = "KUBERC"; + public static final String ENV_KUBECTL_KUBERC = "KUBECTL_KUBERC"; + public static final String KUBERC_OFF = "off"; + public static final String KUBERC = "kuberc"; + public static final String KUBERC_API_GROUP = "kubectl.config.k8s.io"; + public static final String KUBERC_KIND = "Preference"; + + /** The type of a credential plugin policy, as spelled in a kuberc file. */ + public enum PolicyType { + ALLOW_ALL("AllowAll"), + DENY_ALL("DenyAll"), + ALLOWLIST("Allowlist"); + + private final String value; + + PolicyType(String value) { + this.value = value; + } + + /** @return the kuberc spelling of this policy type. */ + public String getValue() { + return value; + } + + @Override + public String toString() { + return value; + } + } + + /** Allows any exec credential plugin to run. */ + public static final ExecCredentialPluginPolicy ALLOW_ALL = + new ExecCredentialPluginPolicy(PolicyType.ALLOW_ALL, Collections.emptyList()); + + /** Prevents all exec credential plugins from running. */ + public static final ExecCredentialPluginPolicy DENY_ALL = + new ExecCredentialPluginPolicy(PolicyType.DENY_ALL, Collections.emptyList()); + + private final PolicyType type; + private final List allowlist; + + private ExecCredentialPluginPolicy(PolicyType type, List allowlist) { + this.type = type; + this.allowlist = allowlist; + } + + /** + * Creates an {@code Allowlist} policy. + * + * @param commands plugin commands which may run; either basenames (resolved using {@code PATH}) + * or paths. Must not be empty, and each entry must be non-empty and normalized. + * @return an immutable allowlist policy + * @throws IllegalArgumentException if the allowlist or one of its entries is invalid + */ + public static ExecCredentialPluginPolicy allowlist(String... commands) { + if (commands == null) { + throw new IllegalArgumentException( + "credential plugin policy set to \"Allowlist\", but allowlist is unspecified"); + } + return allowlist(Arrays.asList(commands)); + } + + /** + * Creates an {@code Allowlist} policy. + * + * @param commands plugin commands which may run; see {@link #allowlist(String...)} + * @return an immutable allowlist policy + * @throws IllegalArgumentException if the allowlist or one of its entries is invalid + */ + public static ExecCredentialPluginPolicy allowlist(List commands) { + if (commands == null) { + throw new IllegalArgumentException( + "credential plugin policy set to \"Allowlist\", but allowlist is unspecified"); + } + if (commands.isEmpty()) { + throw new IllegalArgumentException( + "credential plugin policy set to \"Allowlist\", but allowlist is empty; use \"DenyAll\" policy instead"); + } + List copy = new ArrayList<>(commands.size()); + for (int i = 0; i < commands.size(); i++) { + String command = commands.get(i); + if (command == null || command.isEmpty()) { + throw new IllegalArgumentException( + "misconfigured credential plugin allowlist: empty allowlist entry #" + (i + 1)); + } + String cleaned = clean(command); + if (cleaned == null) { + throw new IllegalArgumentException( + "misconfigured credential plugin allowlist: invalid file path: \"" + command + "\""); + } + if (!command.equals(cleaned)) { + throw new IllegalArgumentException( + "misconfigured credential plugin allowlist: non-normalized file path: \"" + + command + + "\" vs \"" + + cleaned + + "\""); + } + copy.add(command); + } + return new ExecCredentialPluginPolicy(PolicyType.ALLOWLIST, Collections.unmodifiableList(copy)); + } + + /** + * Loads the credential plugin policy from a kuberc document. + * + * @param input reader for a kuberc ({@code kind: Preference}) YAML document + * @return the policy; {@link #ALLOW_ALL} if {@code credentialPluginPolicy} is unset or empty + * @throws IllegalArgumentException if the document or the policy is invalid + */ + @SuppressWarnings("unchecked") + public static ExecCredentialPluginPolicy fromKubeRc(Reader input) { + Object document; + try { + document = new Yaml(new SafeConstructor(new LoaderOptions())).load(input); + } catch (YAMLException ex) { + throw new IllegalArgumentException("kuberc: failed to parse: " + ex.getMessage(), ex); + } + return fromKubeRcDocument(document); + } + + @SuppressWarnings("unchecked") + private static ExecCredentialPluginPolicy fromKubeRcDocument(Object document) { + if (document == null) { + return ALLOW_ALL; + } + if (!(document instanceof Map)) { + throw new IllegalArgumentException("kuberc: expected a YAML mapping"); + } + Map preference = (Map) document; + + Object apiVersion = preference.get("apiVersion"); + if (!(apiVersion instanceof String) + || !((String) apiVersion).startsWith(KUBERC_API_GROUP + "/")) { + throw new IllegalArgumentException("kuberc: unsupported apiVersion: " + apiVersion); + } + Object kind = preference.get("kind"); + if (!KUBERC_KIND.equals(kind)) { + throw new IllegalArgumentException("kuberc: unsupported kind: " + kind); + } + + Object policy = preference.get("credentialPluginPolicy"); + Object allowlist = preference.get("credentialPluginAllowlist"); + if (policy != null && !(policy instanceof String)) { + throw new IllegalArgumentException("kuberc: credentialPluginPolicy must be a string"); + } + String policyName = policy == null ? "" : (String) policy; + switch (policyName) { + case "": + case "AllowAll": + case "DenyAll": + if (allowlist != null) { + throw new IllegalArgumentException( + "misconfigured credential plugin allowlist: plugin policy is \"" + + policyName + + "\" but allowlist is non-nil"); + } + return "DenyAll".equals(policyName) ? DENY_ALL : ALLOW_ALL; + case "Allowlist": + return allowlist(parseAllowlist(allowlist)); + default: + throw new IllegalArgumentException("unknown plugin policy: \"" + policyName + "\""); + } + } + + @SuppressWarnings("unchecked") + private static List parseAllowlist(Object allowlist) { + if (allowlist == null) { + return null; + } + if (!(allowlist instanceof List)) { + throw new IllegalArgumentException("kuberc: credentialPluginAllowlist must be a list"); + } + List entries = (List) allowlist; + List commands = new ArrayList<>(entries.size()); + for (int i = 0; i < entries.size(); i++) { + Object entry = entries.get(i); + if (entry != null && !(entry instanceof Map)) { + throw new IllegalArgumentException( + "kuberc: credentialPluginAllowlist entry #" + (i + 1) + " must be a mapping"); + } + Map fields = entry == null ? Collections.emptyMap() : (Map) entry; + String command = stringField(fields, "command", i); + String name = stringField(fields, "name", i); + if (!command.isEmpty() && !name.isEmpty()) { + throw new IllegalArgumentException( + "misconfigured credential plugin allowlist: entry #" + + (i + 1) + + " specifies both \"name\" and \"command\""); + } + // "name" is a deprecated alias for "command". + commands.add(command.isEmpty() ? name : command); + } + return commands; + } + + private static String stringField(Map fields, String key, int index) { + Object value = fields.get(key); + if (value == null) { + return ""; + } + if (!(value instanceof String)) { + throw new IllegalArgumentException( + "kuberc: credentialPluginAllowlist entry #" + (index + 1) + " field \"" + key + "\" must be a string"); + } + return (String) value; + } + + /** + * Loads the credential plugin policy from a kuberc file. + * + * @param kuberc the kuberc file + * @return the policy + * @throws IOException if the file cannot be read + * @throws IllegalArgumentException if the document or the policy is invalid + */ + public static ExecCredentialPluginPolicy fromKubeRc(File kuberc) throws IOException { + try (Reader reader = + new InputStreamReader(new FileInputStream(kuberc), StandardCharsets.UTF_8)) { + return fromKubeRc(reader); + } + } + + /** + * Loads the credential plugin policy from the kuberc file that kubectl would use, following the + * same rules as kubectl: + * + *
    + *
  • If {@code $KUBECTL_KUBERC} is {@code false}, or {@code $KUBERC} is {@code off}, kuberc is + * disabled and {@link #ALLOW_ALL} is returned. + *
  • If {@code $KUBERC} is set, that file is used and must exist and be valid. + *
  • Otherwise {@code $HOME/.kube/kuberc} is used if it exists. If it is missing, {@link + * #ALLOW_ALL} is returned; if it cannot be read or parsed, a warning is logged and {@link + * #ALLOW_ALL} is returned. + *
+ * + * In all cases an invalid policy (for example an empty allowlist) results in an exception. + * + * @return the policy + * @throws IOException if an explicitly specified kuberc file cannot be read + * @throws IllegalArgumentException if the policy is invalid + */ + public static ExecCredentialPluginPolicy fromDefaultKubeRc() throws IOException { + return fromDefaultKubeRc(System.getenv(ENV_KUBECTL_KUBERC), System.getenv(ENV_KUBERC), homeDir()); + } + + static ExecCredentialPluginPolicy fromDefaultKubeRc( + String featureGate, String kubercEnv, File homeDir) throws IOException { + if ("false".equalsIgnoreCase(featureGate) || KUBERC_OFF.equals(kubercEnv)) { + return ALLOW_ALL; + } + if (kubercEnv != null && !kubercEnv.isEmpty()) { + return fromKubeRc(new File(kubercEnv)); + } + if (homeDir == null) { + return ALLOW_ALL; + } + File kuberc = new File(new File(homeDir, KubeConfig.KUBEDIR), KUBERC); + if (!kuberc.exists()) { + return ALLOW_ALL; + } + Object document; + try (Reader reader = + new InputStreamReader(new FileInputStream(kuberc), StandardCharsets.UTF_8)) { + document = new Yaml(new SafeConstructor(new LoaderOptions())).load(reader); + } catch (FileNotFoundException ex) { + return ALLOW_ALL; + } catch (IOException | YAMLException ex) { + log.warn("kuberc: no preferences loaded from {}: {}", kuberc, ex.getMessage()); + return ALLOW_ALL; + } + return fromKubeRcDocument(document); + } + + private static File homeDir() { + for (String env : new String[] {KubeConfig.ENV_HOME, "USERPROFILE"}) { + String home = System.getenv(env); + if (home != null && !home.isEmpty()) { + return new File(home); + } + } + String home = System.getProperty("user.home"); + return home == null || home.isEmpty() ? null : new File(home); + } + + /** @return the type of this policy. */ + public PolicyType getType() { + return type; + } + + /** @return the (immutable) allowlist; empty unless the type is {@code Allowlist}. */ + public List getAllowlist() { + return allowlist; + } + + /** + * Checks whether a plugin command may run. + * + * @param command the plugin command (after any kubeconfig-relative path resolution) + * @return the command to execute. For an {@code Allowlist} policy this is the resolved + * executable path, so that the executable which was checked is the one that runs. + * @throws ExecCredentialPluginDeniedException if the plugin may not run + */ + public String checkAllowed(String command) { + return checkAllowed(command, System.getenv("PATH"), System.getenv("PATHEXT"), isWindows()); + } + + String checkAllowed(String command, String pathEnv, String pathExt, boolean windows) { + switch (type) { + case ALLOW_ALL: + return command; + case DENY_ALL: + throw new ExecCredentialPluginDeniedException( + command, "plugin \"" + command + "\" not allowed: policy set to \"DenyAll\""); + case ALLOWLIST: + default: + break; + } + + String cleaned = command == null ? null : clean(command); + if (cleaned == null || cleaned.isEmpty()) { + throw new ExecCredentialPluginDeniedException( + command, "plugin path \"" + command + "\" is not permitted by the credential plugin allowlist"); + } + String resolved = lookPath(cleaned, pathEnv, pathExt, windows); + if (allowlist.contains(cleaned)) { + return resolved != null ? resolved : cleaned; + } + if (resolved == null) { + throw new ExecCredentialPluginDeniedException( + command, + "plugin path \"" + + cleaned + + "\" cannot be resolved for credential plugin allowlist check"); + } + if (allowlist.contains(resolved)) { + return resolved; + } + for (String entry : allowlist) { + if (resolved.equals(lookPath(entry, pathEnv, pathExt, windows))) { + return resolved; + } + } + throw new ExecCredentialPluginDeniedException( + command, + "plugin path \"" + resolved + "\" is not permitted by the credential plugin allowlist"); + } + + /** + * Resolves a command to an executable the way {@code exec.LookPath} does: commands containing a + * path separator are used as-is, others are searched for in {@code PATH}. Relative {@code PATH} + * entries are ignored. Symlinks are not resolved. + * + * @return the resolved path, or null if no executable is found + */ + static String lookPath(String command, String pathEnv, String pathExt, boolean windows) { + if (command == null || command.isEmpty() || command.indexOf('\0') >= 0) { + return null; + } + List extensions = executableExtensions(command, pathExt, windows); + boolean hasSeparator = command.indexOf('/') >= 0 || (windows && command.indexOf('\\') >= 0); + if (hasSeparator) { + return findExecutable(command, extensions, windows); + } + if (pathEnv == null || pathEnv.isEmpty()) { + return null; + } + String separator = windows ? ";" : File.pathSeparator; + for (String dir : pathEnv.split(java.util.regex.Pattern.quote(separator))) { + if (windows && dir.length() > 1 && dir.startsWith("\"") && dir.endsWith("\"")) { + dir = dir.substring(1, dir.length() - 1); + } + if (dir.isEmpty()) { + continue; + } + Path dirPath; + try { + dirPath = Paths.get(dir); + } catch (InvalidPathException ex) { + continue; + } + if (!dirPath.isAbsolute()) { + continue; + } + String found = + findExecutable(dirPath.resolve(command).normalize().toString(), extensions, windows); + if (found != null) { + return found; + } + } + return null; + } + + private static String findExecutable(String candidate, List extensions, boolean windows) { + for (String ext : extensions) { + String file = candidate + ext; + try { + Path path = Paths.get(file); + if (Files.exists(path) + && !Files.isDirectory(path) + && (windows || Files.isExecutable(path))) { + return file; + } + } catch (InvalidPathException ex) { + return null; + } + } + return null; + } + + private static List executableExtensions(String command, String pathExt, boolean windows) { + if (!windows) { + return Collections.singletonList(""); + } + String exts = pathExt == null || pathExt.isEmpty() ? ".com;.exe;.bat;.cmd" : pathExt; + List result = new ArrayList<>(); + String lower = command.toLowerCase(Locale.ROOT); + boolean hasKnownExt = false; + for (String ext : exts.split(";")) { + if (!ext.isEmpty() && lower.endsWith(ext.toLowerCase(Locale.ROOT))) { + hasKnownExt = true; + } + } + if (hasKnownExt) { + result.add(""); + return result; + } + for (String ext : exts.split(";")) { + if (!ext.isEmpty()) { + result.add(ext.startsWith(".") ? ext : "." + ext); + } + } + return result; + } + + /** Lexically normalizes a path, like Go's {@code filepath.Clean}; symlinks are not resolved. */ + static String clean(String path) { + if (path.isEmpty()) { + return "."; + } + try { + String normalized = Paths.get(path).normalize().toString(); + return normalized.isEmpty() ? "." : normalized; + } catch (InvalidPathException ex) { + return null; + } + } + + private static boolean isWindows() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).startsWith("windows"); + } + + @Override + public String toString() { + return type == PolicyType.ALLOWLIST ? type + allowlist.toString() : type.toString(); + } +} diff --git a/util/src/main/java/io/kubernetes/client/util/KubeConfig.java b/util/src/main/java/io/kubernetes/client/util/KubeConfig.java index 18f64f251b..025a0a03f6 100644 --- a/util/src/main/java/io/kubernetes/client/util/KubeConfig.java +++ b/util/src/main/java/io/kubernetes/client/util/KubeConfig.java @@ -69,6 +69,7 @@ public class KubeConfig { Object preferences; ConfigPersister persister; private File file; + private ExecCredentialPluginPolicy execCredentialPluginPolicy = ExecCredentialPluginPolicy.ALLOW_ALL; public static void registerAuthenticator(Authenticator auth) { synchronized (authenticators) { @@ -346,9 +347,9 @@ private JsonElement runExec(String command, List args, ListIf a plugin is denied, {@link #getCredentials()} throws {@link + * io.kubernetes.client.util.exception.ExecCredentialPluginDeniedException} without starting the + * plugin. + * + * @param policy the policy to apply; null restores the default of allowing all plugins + * @see ExecCredentialPluginPolicy#fromDefaultKubeRc() + */ + public void setExecCredentialPluginPolicy(ExecCredentialPluginPolicy policy) { + this.execCredentialPluginPolicy = + policy == null ? ExecCredentialPluginPolicy.ALLOW_ALL : policy; + } + + public ExecCredentialPluginPolicy getExecCredentialPluginPolicy() { + return execCredentialPluginPolicy; + } + public void setPreferences(Object preferences) { this.preferences = preferences; } diff --git a/util/src/main/java/io/kubernetes/client/util/exception/ExecCredentialPluginDeniedException.java b/util/src/main/java/io/kubernetes/client/util/exception/ExecCredentialPluginDeniedException.java new file mode 100644 index 0000000000..19345d1588 --- /dev/null +++ b/util/src/main/java/io/kubernetes/client/util/exception/ExecCredentialPluginDeniedException.java @@ -0,0 +1,31 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package io.kubernetes.client.util.exception; + +/** + * Thrown when a kubeconfig exec credential plugin is not permitted to run by the configured {@link + * io.kubernetes.client.util.ExecCredentialPluginPolicy}. The plugin process is never started. + */ +public class ExecCredentialPluginDeniedException extends RuntimeException { + private final String command; + + public ExecCredentialPluginDeniedException(String command, String message) { + super(message); + this.command = command; + } + + /** @return the exec plugin command which was denied. */ + public String getCommand() { + return command; + } +} diff --git a/util/src/test/java/io/kubernetes/client/util/ExecCredentialPluginPolicyTest.java b/util/src/test/java/io/kubernetes/client/util/ExecCredentialPluginPolicyTest.java new file mode 100644 index 0000000000..a89b874c7b --- /dev/null +++ b/util/src/test/java/io/kubernetes/client/util/ExecCredentialPluginPolicyTest.java @@ -0,0 +1,335 @@ +/* +Copyright 2026 The Kubernetes Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at +http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package io.kubernetes.client.util; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import io.kubernetes.client.util.exception.ExecCredentialPluginDeniedException; +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExecCredentialPluginPolicyTest { + + private static boolean isWindows() { + return System.getProperty("os.name").contains("Windows"); + } + + private static Path executable(Path dir, String name) throws IOException { + Files.createDirectories(dir); + Path file = dir.resolve(name); + Files.writeString(file, "#!/bin/sh\n"); + file.toFile().setExecutable(true); + return file; + } + + @Test + void allowAllAllowsAnything() { + assertThat(ExecCredentialPluginPolicy.ALLOW_ALL.checkAllowed("anything")).isEqualTo("anything"); + } + + @Test + void denyAllDeniesEverything() { + assertThatThrownBy(() -> ExecCredentialPluginPolicy.DENY_ALL.checkAllowed("echo")) + .isInstanceOf(ExecCredentialPluginDeniedException.class) + .hasMessageContaining("DenyAll") + .extracting(e -> ((ExecCredentialPluginDeniedException) e).getCommand()) + .isEqualTo("echo"); + } + + @Test + void invalidAllowlistsAreRejected() { + assertThatThrownBy(() -> ExecCredentialPluginPolicy.allowlist()) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ExecCredentialPluginPolicy.allowlist((String[]) null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ExecCredentialPluginPolicy.allowlist("ok", "")) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ExecCredentialPluginPolicy.allowlist("ok", null)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ExecCredentialPluginPolicy.allowlist("./foo")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-normalized"); + } + + @Test + void allowlistCopiesInput() { + String[] entries = {"trusted"}; + ExecCredentialPluginPolicy policy = ExecCredentialPluginPolicy.allowlist(entries); + entries[0] = "untrusted"; + assertThat(policy.getAllowlist()).containsExactly("trusted"); + } + + @Test + void allowlistMatching(@TempDir Path tempDir) throws IOException { + if (isWindows()) { + return; + } + Path bin = tempDir.resolve("bin"); + Path other = tempDir.resolve("other"); + Path plugin = executable(bin, "trusted-plugin"); + Path otherPlugin = executable(other, "trusted-plugin"); + String path = bin.toString(); + + // basename entry + ExecCredentialPluginPolicy basename = ExecCredentialPluginPolicy.allowlist("trusted-plugin"); + assertThat(basename.checkAllowed("trusted-plugin", path, null, false)) + .isEqualTo(plugin.toString()); + assertThat(basename.checkAllowed(plugin.toString(), path, null, false)) + .isEqualTo(plugin.toString()); + assertThatThrownBy(() -> basename.checkAllowed(otherPlugin.toString(), path, null, false)) + .isInstanceOf(ExecCredentialPluginDeniedException.class); + assertThatThrownBy(() -> basename.checkAllowed("untrusted-plugin", path, null, false)) + .isInstanceOf(ExecCredentialPluginDeniedException.class); + + // full path entry + ExecCredentialPluginPolicy full = ExecCredentialPluginPolicy.allowlist(plugin.toString()); + assertThat(full.checkAllowed(plugin.toString(), path, null, false)) + .isEqualTo(plugin.toString()); + assertThat(full.checkAllowed("trusted-plugin", path, null, false)) + .isEqualTo(plugin.toString()); + assertThat(full.checkAllowed(bin + "/./trusted-plugin", path, null, false)) + .isEqualTo(plugin.toString()); + assertThatThrownBy(() -> full.checkAllowed(otherPlugin.toString(), path, null, false)) + .isInstanceOf(ExecCredentialPluginDeniedException.class); + // basename resolved through a different PATH is denied + assertThatThrownBy(() -> full.checkAllowed("trusted-plugin", other.toString(), null, false)) + .isInstanceOf(ExecCredentialPluginDeniedException.class); + } + + @Test + void allowlistDoesNotFollowSymlinks(@TempDir Path tempDir) throws IOException { + if (isWindows()) { + return; + } + Path target = executable(tempDir.resolve("target"), "plugin"); + Path link = Files.createDirectories(tempDir.resolve("links")).resolve("plugin"); + Files.createSymbolicLink(link, target); + + ExecCredentialPluginPolicy policy = ExecCredentialPluginPolicy.allowlist(link.toString()); + assertThat(policy.checkAllowed(link.toString(), "", null, false)).isEqualTo(link.toString()); + assertThatThrownBy(() -> policy.checkAllowed(target.toString(), "", null, false)) + .isInstanceOf(ExecCredentialPluginDeniedException.class); + } + + @Test + void lookPathIgnoresRelativeAndMissingEntries(@TempDir Path tempDir) throws IOException { + if (isWindows()) { + return; + } + Path bin = tempDir.resolve("bin"); + Path plugin = executable(bin, "p"); + Files.writeString(bin.resolve("not-exec"), ""); + String pathEnv = "relative" + File.pathSeparator + File.pathSeparator + bin; + assertThat(ExecCredentialPluginPolicy.lookPath("p", pathEnv, null, false)) + .isEqualTo(plugin.toString()); + assertThat(ExecCredentialPluginPolicy.lookPath("not-exec", pathEnv, null, false)).isNull(); + assertThat(ExecCredentialPluginPolicy.lookPath("missing", pathEnv, null, false)).isNull(); + assertThat(ExecCredentialPluginPolicy.lookPath("p", null, null, false)).isNull(); + } + + @Test + void kubeRcPolicies() { + String header = "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\n"; + assertThat(ExecCredentialPluginPolicy.fromKubeRc(new StringReader(header))) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + assertThat( + ExecCredentialPluginPolicy.fromKubeRc( + new StringReader(header + "credentialPluginPolicy: \"\"\n"))) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + assertThat( + ExecCredentialPluginPolicy.fromKubeRc( + new StringReader(header + "credentialPluginPolicy: AllowAll\n"))) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + assertThat( + ExecCredentialPluginPolicy.fromKubeRc( + new StringReader(header + "credentialPluginPolicy: DenyAll\n"))) + .isSameAs(ExecCredentialPluginPolicy.DENY_ALL); + + ExecCredentialPluginPolicy allowlist = + ExecCredentialPluginPolicy.fromKubeRc( + new StringReader( + header + + "credentialPluginPolicy: Allowlist\n" + + "credentialPluginAllowlist:\n" + + " - command: my-trusted-binary\n" + + " - name: /usr/local/bin/my-other-trusted-binary\n")); + assertThat(allowlist.getType()).isEqualTo(ExecCredentialPluginPolicy.PolicyType.ALLOWLIST); + assertThat(allowlist.getAllowlist()) + .containsExactly("my-trusted-binary", "/usr/local/bin/my-other-trusted-binary"); + } + + @Test + void invalidKubeRcPolicies() { + String header = "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\n"; + for (String body : + Arrays.asList( + // allowlist missing (e.g. misspelled) + "credentialPluginPolicy: Allowlist\ncredentialPluginAlowlist:\n - command: foo\n", + // allowlist empty + "credentialPluginPolicy: Allowlist\ncredentialPluginAllowlist: []\n", + // empty entry + "credentialPluginPolicy: Allowlist\ncredentialPluginAllowlist:\n - command: \"\"\n", + "credentialPluginPolicy: Allowlist\ncredentialPluginAllowlist:\n - {}\n", + // both name and command + "credentialPluginPolicy: Allowlist\ncredentialPluginAllowlist:\n - name: a\n command: a\n", + // allowlist with non-Allowlist policy + "credentialPluginPolicy: DenyAll\ncredentialPluginAllowlist:\n - command: foo\n", + "credentialPluginAllowlist:\n - command: foo\n", + // unknown policy + "credentialPluginPolicy: allowall\n")) { + assertThatThrownBy(() -> ExecCredentialPluginPolicy.fromKubeRc(new StringReader(header + body))) + .as(body) + .isInstanceOf(IllegalArgumentException.class); + } + assertThatThrownBy( + () -> + ExecCredentialPluginPolicy.fromKubeRc( + new StringReader("apiVersion: v1\nkind: Config\n"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void defaultKubeRcLocation(@TempDir Path home) throws IOException { + // missing kuberc + assertThat(ExecCredentialPluginPolicy.fromDefaultKubeRc(null, null, home.toFile())) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + + Path kuberc = Files.createDirectories(home.resolve(".kube")).resolve("kuberc"); + Files.writeString( + kuberc, + "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\ncredentialPluginPolicy: DenyAll\n"); + assertThat(ExecCredentialPluginPolicy.fromDefaultKubeRc(null, null, home.toFile())) + .isSameAs(ExecCredentialPluginPolicy.DENY_ALL); + // disabled + assertThat(ExecCredentialPluginPolicy.fromDefaultKubeRc(null, "off", home.toFile())) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + assertThat(ExecCredentialPluginPolicy.fromDefaultKubeRc("false", null, home.toFile())) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + + // explicit $KUBERC + Path explicit = home.resolve("explicit"); + Files.writeString( + explicit, + "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\n" + + "credentialPluginPolicy: Allowlist\ncredentialPluginAllowlist:\n - command: foo\n"); + assertThat( + ExecCredentialPluginPolicy.fromDefaultKubeRc(null, explicit.toString(), home.toFile()) + .getAllowlist()) + .isEqualTo(Collections.singletonList("foo")); + assertThatThrownBy( + () -> + ExecCredentialPluginPolicy.fromDefaultKubeRc( + null, home.resolve("missing").toString(), home.toFile())) + .isInstanceOf(IOException.class); + + // unparseable default kuberc is ignored, like kubectl + Files.writeString(kuberc, "{{{ not yaml"); + assertThat(ExecCredentialPluginPolicy.fromDefaultKubeRc(null, null, home.toFile())) + .isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + // but an invalid policy is an error + Files.writeString( + kuberc, + "apiVersion: kubectl.config.k8s.io/v1beta1\nkind: Preference\ncredentialPluginPolicy: Allowlist\n"); + assertThatThrownBy(() -> ExecCredentialPluginPolicy.fromDefaultKubeRc(null, null, home.toFile())) + .isInstanceOf(IllegalArgumentException.class); + } + + private static final String KUBECONFIG_EXEC_SCRIPT = + "apiVersion: v1\n" + + "current-context: c\n" + + "contexts:\n" + + "- name: c\n" + + " context:\n" + + " user: u\n" + + "users:\n" + + "- name: u\n" + + " user:\n" + + " exec:\n" + + " apiVersion: client.authentication.k8s.io/v1beta1\n" + + " command: ./bin/authenticate\n"; + + private static final String AUTH_SCRIPT = + "#!/bin/sh\n" + + "touch \"$(dirname \"$0\")/started\"\n" + + "echo '{\"apiVersion\": \"client.authentication.k8s.io/v1beta1\", \"kind\": \"ExecCredential\", \"status\": {\"token\": \"abc123\"}}'\n"; + + private static KubeConfig loadScriptConfig(Path tempDir) throws IOException { + Path config = tempDir.resolve(".kubeconfig"); + Files.writeString(config, KUBECONFIG_EXEC_SCRIPT); + Path script = Files.createDirectories(tempDir.resolve("bin")).resolve("authenticate"); + Files.writeString(script, AUTH_SCRIPT); + script.toFile().setExecutable(true); + try (FileReader reader = new FileReader(config.toFile())) { + KubeConfig kc = KubeConfig.loadKubeConfig(reader); + kc.setFile(config.toFile()); + return kc; + } + } + + @Test + void kubeConfigDeniedPluginNeverStarts(@TempDir Path tempDir) throws IOException { + if (isWindows()) { + return; + } + KubeConfig kc = loadScriptConfig(tempDir); + kc.setExecCredentialPluginPolicy(ExecCredentialPluginPolicy.DENY_ALL); + assertThatThrownBy(kc::getCredentials).isInstanceOf(ExecCredentialPluginDeniedException.class); + + kc.setExecCredentialPluginPolicy(ExecCredentialPluginPolicy.allowlist("another-plugin")); + assertThatThrownBy(kc::getCredentials).isInstanceOf(ExecCredentialPluginDeniedException.class); + assertThat(tempDir.resolve("bin").resolve("started")).doesNotExist(); + } + + @Test + void kubeConfigAllowedPluginRuns(@TempDir Path tempDir) throws IOException { + if (isWindows()) { + return; + } + KubeConfig kc = loadScriptConfig(tempDir); + String script = tempDir.resolve("bin").resolve("authenticate").toString(); + kc.setExecCredentialPluginPolicy(ExecCredentialPluginPolicy.allowlist(script)); + assertThat(kc.getCredentials()).containsEntry(KubeConfig.CRED_TOKEN_KEY, "abc123"); + assertThat(tempDir.resolve("bin").resolve("started")).exists(); + + kc.setExecCredentialPluginPolicy(null); + assertThat(kc.getExecCredentialPluginPolicy()).isSameAs(ExecCredentialPluginPolicy.ALLOW_ALL); + assertThat(kc.getCredentials()).containsEntry(KubeConfig.CRED_TOKEN_KEY, "abc123"); + } + + @Test + void denyAllDoesNotAffectNonExecCredentials() { + String tokenConfig = + "apiVersion: v1\n" + + "current-context: c\n" + + "contexts:\n" + + "- name: c\n" + + " context:\n" + + " user: u\n" + + "users:\n" + + "- name: u\n" + + " user:\n" + + " token: foobaz\n"; + KubeConfig kc = KubeConfig.loadKubeConfig(new StringReader(tokenConfig)); + kc.setExecCredentialPluginPolicy(ExecCredentialPluginPolicy.DENY_ALL); + assertThat(kc.getCredentials()).containsEntry(KubeConfig.CRED_TOKEN_KEY, "foobaz"); + } +}