UNPKG

mcp-server-kubernetes

Version:

MCP server for interacting with Kubernetes clusters via kubectl

234 lines (233 loc) 9.57 kB
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"; import { execFileSync, } from "child_process"; // Flags that would let a caller redirect kubectl to a different API server, // substitute credentials, or impersonate another identity. Allowing any of // these to flow in from tool inputs lets an attacker who can influence the // LLM's tool arguments (e.g. via indirect prompt injection in pod logs) // exfiltrate the operator's bearer token to an attacker-controlled host. // // Names are stored in canonical (long-form) kebab-case, without the leading // "--". Short aliases that have the same effect are listed in SHORT_ALIASES. const DANGEROUS_FLAGS = new Set([ // Target / endpoint overrides "server", "kubeconfig", "cluster", "context", "user", "tls-server-name", // TLS bypass "insecure-skip-tls-verify", "certificate-authority", "client-certificate", "client-key", // Credential overrides "token", "username", "password", "auth-provider", "auth-provider-arg", "exec-command", "exec-arg", "exec-api-version", "exec-env", // Identity impersonation "as", "as-group", "as-uid", // Writes to attacker-chosen filesystem paths "profile-output", "log-file", "cache-dir", ]); const SHORT_ALIASES = new Set([ "s", // -s is an alias for --server ]); // Short flags that consume a value. pflag stops parsing a shorthand cluster at // the first of these: everything after it inside the token is that flag's // value ("-ojsonpath={.items[0]}"), not more flags. Every other letter is a // boolean shorthand, which pflag parses and then keeps going past — so the // scan in shortFlagLetters() must keep going too. // // Comparison is case-sensitive, exactly like pflag: "-A" (--all-namespaces) // and "-a" are different flags. // // Only letters that take a value in *every* command that defines them belong // here. "-f" is deliberately absent: it is --filename for apply/delete but the // boolean --follow for logs, so pflag keeps parsing the cluster after it. // Leaving a letter out is always the safe direction — at worst an attached // value containing "s" is refused, and the split form ("-f /path") or the long // form ("--filename=/path") still works. const SHORT_VALUE_FLAGS = new Set([ "c", // --container "k", // --kustomize "l", // --selector "L", // --label-columns "n", // --namespace "o", // --output "s", // --server (dangerous; listed so the scan stops after it as well) "v", // --v (log level) ]); // helm exposes the same exfiltration surface as kubectl, but under "kube-" // prefixed flag names (e.g. --kube-apiserver instead of --server). We add // those here so the argv-level guard covers helm invocations too. Context // selection flags (--context / --kube-context) are intentionally omitted: // they can only select a cluster already present in the loaded kubeconfig, // every tool legitimately emits "--context <value>", and without --server / // --kubeconfig they cannot redirect kubectl/helm to an attacker host. const HELM_DANGEROUS_FLAGS = new Set([ "kube-apiserver", "kube-token", "kube-ca-file", "kube-as-user", "kube-as-group", "kube-tls-server-name", "kube-insecure-skip-tls-verify", ]); // Flag names that are dangerous when they appear anywhere in a fully // constructed argv (positional slots included), regardless of which tool // built it. This is DANGEROUS_FLAGS minus the context-selection flags, plus // the helm equivalents. See assertSafeArgv / execFileSyncSafe below. const ARGV_DANGEROUS_FLAGS = new Set([...DANGEROUS_FLAGS, ...HELM_DANGEROUS_FLAGS].filter((name) => name !== "context")); function isUnsafeFlagsAllowed() { return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true"; } function normalizeFlagName(raw) { // Strip leading dashes; drop "=value" suffix; lowercase. let name = raw.replace(/^-+/, ""); const eq = name.indexOf("="); if (eq !== -1) name = name.slice(0, eq); // kubectl and helm install pflag's WordSepNormalizeFunc, which treats "_" as // equivalent to "-" in long flag names: "--insecure_skip_tls_verify" and // "--insecure-skip-tls-verify" are the same flag. Normalize the same way so // both spellings compare equal against the sets above. return name.toLowerCase().replace(/_/g, "-"); } // Return every letter pflag would parse as a flag out of a single-dash token, // in order, or null if the token is not a single-dash short flag. // // pflag walks a shorthand cluster letter by letter: a boolean shorthand is // consumed and parsing continues with the next letter, while a value-taking // shorthand swallows the remainder of the token as its value. "-Aowide" is // therefore "-A -o wide", not a single unknown flag, so every letter pflag // reaches has to be checked — not just the first one after the dash. // // Long "--" flags never attach a value without "=", so normalizeFlagName // already handles them. function shortFlagLetters(raw) { if (!raw.startsWith("-") || raw.startsWith("--")) return null; const body = raw.slice(1); if (body.length === 0) return null; const letters = []; for (let i = 0; i < body.length; i++) { const letter = body[i]; letters.push(letter); // "-o=json": the "=" and everything after it is the value. if (body[i + 1] === "=") break; // A value-taking shorthand consumes the rest of the token as its value, // so no further letters are parsed as flags. if (SHORT_VALUE_FLAGS.has(letter)) break; } return letters; } function hasDangerousShortFlag(raw) { const letters = shortFlagLetters(raw); if (letters === null) return false; return letters.some((letter) => SHORT_ALIASES.has(letter)); } function isDangerousFlagName(rawName, fromArgs) { const name = normalizeFlagName(rawName); if (DANGEROUS_FLAGS.has(name)) return true; // Short aliases (-s) are only meaningful when they appear as a CLI token, // not as a key in the `flags` object. Match both the bare/split forms // (normalizeFlagName -> "s") and the attached/clustered forms ("-sURL", // "-Ashttps://attacker"), where pflag parses the alias out of the cluster. if (fromArgs) { if (SHORT_ALIASES.has(name)) return true; if (hasDangerousShortFlag(rawName)) return true; } return false; } function reject(flag) { throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` + `kubectl to a different API server or substitute credentials, which ` + `would allow exfiltration of the operator's bearer token. If you ` + `genuinely need this flag, set ALLOW_KUBECTL_UNSAFE_FLAGS=true in the ` + `server environment.`); } /** * Validate user-supplied kubectl flags and args. Throws an McpError if any * dangerous flag is present and the unsafe-flags escape hatch is not set. * * The check covers: * - keys of the `flags` object (e.g. { server: "..." }) * - tokens in the `args` array, in both joined ("--server=x") and split * ("--server", "x") forms, plus short aliases ("-s"). */ export function assertNoDangerousFlags(flags, args) { if (isUnsafeFlagsAllowed()) return; if (flags) { for (const key of Object.keys(flags)) { if (isDangerousFlagName(key, false)) reject(`--${normalizeFlagName(key)}`); } } if (args) { for (const tok of args) { if (typeof tok !== "string") continue; if (!tok.startsWith("-")) continue; if (isDangerousFlagName(tok, true)) reject(tok); } } } /** * Validate a fully-constructed kubectl/helm argv. Unlike assertNoDangerousFlags * (which inspects only the free-form `flags`/`args` inputs of kubectl_generic), * this scans every token in the final argv — including bare positional slots * such as resource names, node names, and resource types that the individual * tools push directly. kubectl's pflag parser treats any token beginning with * "-" as a flag regardless of position, so a tool argument like * name: "--server=https://attacker" would otherwise redirect the API server * and leak the operator's bearer token. Throws an McpError on any dangerous * flag unless ALLOW_KUBECTL_UNSAFE_FLAGS=true. */ export function assertSafeArgv(args) { if (isUnsafeFlagsAllowed()) return; for (const tok of args) { if (typeof tok !== "string") continue; if (!tok.startsWith("-")) continue; const name = normalizeFlagName(tok); if (ARGV_DANGEROUS_FLAGS.has(name) || SHORT_ALIASES.has(name)) reject(tok); // Attached/clustered short-flag forms ("-sURL", "-Ashttps://attacker"): // match every letter pflag would parse out of the cluster, not just the // first one. if (hasDangerousShortFlag(tok)) reject(tok); } } /** * Drop-in replacement for child_process.execFileSync that scans the argv for * credential/target-redirecting flags before executing. Tool files import this * as `execFileSync`, so every kubectl/helm call site is guarded at one place. */ export function execFileSyncSafe(file, args, options) { assertSafeArgv(args); return execFileSync(file, args, options); }