mcp-server-kubernetes
Version:
MCP server for interacting with Kubernetes clusters via kubectl
526 lines (525 loc) • 24.4 kB
JavaScript
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
import { execFileSync, } from "child_process";
import { isRemoteTransport } from "./transport.js";
// 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), plus a few
// helm-only flags that run or overwrite things on the MCP server host. 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",
// Runs a binary on the MCP server host: helm 3.x resolves --post-renderer to
// an executable path (or a $PATH name) and runs it over the rendered
// manifests. Nothing about installing a chart otherwise gives host-side
// execution, so this is never a flag a tool should emit.
"post-renderer",
"post-renderer-args",
// Writes to attacker-chosen filesystem paths: helm rewrites these files as
// part of "repo add" / registry login.
"repository-config",
"registry-config",
]);
// 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"));
// Flags that make kubectl read a file from the machine it is running on.
//
// These are not in DANGEROUS_FLAGS: under stdio that machine is the operator's
// own, and "-f manifest.yaml" is the whole point of the tool. They only become
// a vulnerability on a remote (SSE / Streamable HTTP) transport, where the path
// is chosen by a client but resolved on the server host — so they are rejected
// by transport, in assertNoRemoteFileReads, rather than denied outright.
//
// Long names only; the single-dash spellings live in FILE_READ_SHORT_FLAGS.
const FILE_READ_FLAGS = new Set([
"filename",
"from-file",
"from-env-file",
"kustomize",
"patch-file",
]);
// Single-letter spellings of the above. Unlike SHORT_ALIASES these are checked
// against every letter pflag would parse out of a shorthand cluster, so the
// attached ("-f/etc/passwd") and clustered ("-Rf/etc/passwd") forms are caught
// alongside the split one ("-f", "/etc/passwd").
const FILE_READ_SHORT_FLAGS = new Set([
"f", // --filename
"k", // --kustomize
]);
// Output formats whose argument is a file path on the machine kubectl runs on:
// "-o go-template-file=<path>" makes kubectl read that file and render it into
// the result. That is the same read as --from-file under a different flag
// name, but unlike --from-file no tool here ever legitimately emits one — so
// assertSafeArgv refuses them in any argv, on any transport, rather than only
// where a server-side path would be attacker-chosen. Every tool has positional
// slots, read-only ones included, so a name-shaped denial is not enough.
//
// kubectl also accepts "templatefile" as an alias of "go-template-file", so
// the set is a list of known spellings rather than the whole rule: every
// file-reading format kubectl has ever shipped is named "...file", and
// isFileReadOutputFormat refuses that suffix too, so an alias added upstream
// is refused before anyone has to notice it here.
const FILE_READ_OUTPUT_FORMATS = new Set([
"go-template-file",
"templatefile",
"jsonpath-file",
"custom-columns-file",
]);
// `format` is the full -o value, e.g. "templatefile=/etc/passwd" or just
// "go-template-file" when the path arrives through --template instead.
function isFileReadOutputFormat(format) {
const name = format.split("=")[0].toLowerCase();
return FILE_READ_OUTPUT_FORMATS.has(name) || name.endsWith("file");
}
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;
}
// Tool inputs are typed string[] at compile time only: nothing validates the
// JSON a client sends against the tool's inputSchema, so an `args` entry can be
// an array, number or object. child_process stringifies every argv element, so
// ["--server=https://attacker"] reaches kubectl as "--server=https://attacker"
// — a token the scans below would never have looked at had they skipped it.
// Refuse anything that is not a string rather than guess how it will print.
function assertStringToken(tok) {
if (typeof tok === "string")
return;
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl/helm with a non-string argument ` +
`(${JSON.stringify(tok) ?? typeof tok}): every command-line argument ` +
`must be a string.`);
}
function reject(flag) {
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` +
`kubectl/helm to a different API server, substitute credentials, or ` +
`act on the MCP server's own host, 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) {
assertStringToken(tok);
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) {
args.forEach(assertStringToken);
if (isUnsafeFlagsAllowed())
return;
for (let i = 0; i < args.length; i++) {
const tok = args[i];
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);
// A file read hiding in the *value* of an otherwise ordinary flag
// ("-o go-template-file=<path>"), so it has to be matched on the value
// rather than on the flag name.
const format = outputFormatValue(tok, args[i + 1]);
if (format !== undefined && isFileReadOutputFormat(format)) {
rejectOutputFileRead(tok, format);
}
}
}
/**
* Return the value kubectl would take for -o / --output in `tok`, or undefined
* if `tok` is not an output flag. The value may be attached to the token
* ("-ojson", "-o=json", "--output=json") or sit in the following one, which is
* why the caller passes `next`.
*/
function outputFormatValue(tok, next) {
if (tok.startsWith("--")) {
if (normalizeFlagName(tok) !== "output")
return undefined;
const eq = tok.indexOf("=");
return eq === -1 ? next : tok.slice(eq + 1);
}
const letters = shortFlagLetters(tok);
if (letters === null)
return undefined;
const idx = letters.indexOf("o");
if (idx === -1)
return undefined;
// "o" takes a value, so pflag stops the cluster there and reads the rest of
// the token as the format ("-Aojson" -> "-A -o json"). An empty remainder
// means the format is the next argv token instead.
let rest = tok.slice(1 + idx + 1);
if (rest.startsWith("="))
rest = rest.slice(1);
return rest === "" ? next : rest;
}
function rejectOutputFileRead(tok, format) {
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${tok} ${format}": this output format ` +
`takes a file path and makes kubectl read that file from the MCP ` +
`server's own filesystem, rendering it into the result. No tool needs ` +
`it, and a resource name or type that parses as this flag is an ` +
`injection attempt. Use an inline template ("-o go-template=...") or a ` +
`jsonpath expression instead.`);
}
function rejectFileRead(flag) {
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${flag}": this flag reads a file from the ` +
`MCP server's own filesystem, which is disabled on remote (SSE/` +
`Streamable HTTP) transports because the path is chosen by the client ` +
`but resolved on the server host. Pass the file contents inline ` +
`instead (kubectl_apply/kubectl_create "manifest", kubectl_create ` +
`"fromFileContent", or install_helm_chart "values").`);
}
/**
* Reject flags that make kubectl read a file from the MCP server's filesystem,
* on remote transports only. No-op under stdio.
*
* kubectl_generic lets a caller assemble an arbitrary kubectl argv, so the
* per-parameter guards the structured tools use (`filename`, `fromFile`,
* `patchFile`, `valuesFile`) have nothing to attach to: the same read is
* reachable as a raw "--from-file=leak=/etc/passwd" token. Under stdio that is
* the operator reading their own files and stays allowed; over SSE/Streamable
* HTTP the path comes from whoever can reach the endpoint but resolves on the
* server host, turning kubectl into an arbitrary file-read oracle (kubeconfig,
* service-account token, /proc/self/environ) — with --dry-run=client, without
* even a cluster.
*
* Takes a fully-constructed argv rather than the `flags`/`args` inputs so that
* flags built from the object form and file-shaped values smuggled through
* positional slots (resourceType, name) are covered by the same scan. Unlike
* assertNoDangerousFlags there is no ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch:
* this mirrors the transport guards in the structured tools, which reject
* server-side paths unconditionally.
*/
export function assertNoRemoteFileReads(args) {
if (!isRemoteTransport())
return;
for (let i = 0; i < args.length; i++) {
const tok = args[i];
assertStringToken(tok);
if (!tok.startsWith("-"))
continue;
if (FILE_READ_FLAGS.has(normalizeFlagName(tok)))
rejectFileRead(tok);
// Short spellings, including the attached and clustered forms pflag
// accepts: "-f/etc/passwd", "-Rf/etc/passwd".
const letters = shortFlagLetters(tok);
if (letters?.some((letter) => FILE_READ_SHORT_FLAGS.has(letter))) {
rejectFileRead(tok);
}
}
}
/**
* Reject a caller-supplied value that a tool is about to place in a *positional*
* argv slot (a resource type, a resource name, a release name, a chart
* reference, a namespace) when it is shaped like a command-line flag.
*
* assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
* so every dangerous flag added upstream is exploitable through any positional
* slot until the list is extended. This guard closes the injection point
* instead of chasing the flags: pflag treats any token starting with "-" as a
* flag no matter where it sits, and none of these operands is ever legitimately
* flag-shaped (Kubernetes resource types and names, helm release names and
* namespaces are RFC 1123 names; chart references are repo/name pairs, paths
* or URLs). Refusing a leading "-" here means caller data can never occupy a
* slot where it would be parsed as a flag.
*
* Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
*/
export function assertNotFlagLike(value, field) {
if (isUnsafeFlagsAllowed())
return;
if (typeof value !== "string" || !value.startsWith("-"))
return;
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl/helm with ${field}="${value}": a value ` +
`starting with "-" is parsed as a command-line flag rather than as ` +
`the ${field}, which would let the caller inject arbitrary flags. ` +
`Provide a ${field} that does not begin with "-".`);
}
// kubectl verbs kubectl_generic refuses on every transport. Each one either
// hands back or rewrites the operator's own credentials, or never returns:
// execFileSync blocks the event loop, so a call that does not exit stalls the
// server for every client until the child is killed. The structured tools
// cover the legitimate uses (kubectl_context, port_forward).
const GENERIC_DENIED_VERBS = new Map([
[
"config",
"it prints and rewrites the operator's kubeconfig (\"config view --raw\" " +
"returns the credentials, \"config set\" repoints a cluster at another " +
"server). Use the kubectl_context tool for context operations",
],
[
"proxy",
"it serves the Kubernetes API with the operator's credentials on a " +
"local port and never exits",
],
[
"port-forward",
"it never exits. Use the port_forward tool, which runs it in the " +
"background",
],
["attach", "it is interactive and never exits"],
["edit", "it opens an interactive editor on the MCP server host"],
]);
// kubectl verbs whose positional operands are paths on the machine kubectl
// runs on. Like FILE_READ_FLAGS they are only a problem on a remote transport,
// where that machine is the server host rather than the caller's — but the
// path is a positional operand, which assertNoRemoteFileReads cannot see.
const GENERIC_REMOTE_DENIED_VERBS = new Set(["cp", "kustomize"]);
// kubectl subcommand names, including plugin names ("kubectl foo" runs
// kubectl-foo). Anything else is not a verb: kubectl skips an empty first
// argument and takes the next token as the verb instead, so an empty
// `command` would let the real verb ride in through a later slot unchecked.
const VERB_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
function rejectVerb(verb, reason) {
throw new McpError(ErrorCode.InvalidParams, `Refusing to run "kubectl ${verb}" through kubectl_generic: ${reason}.`);
}
/**
* Apply kubectl_generic's verb policy to a fully-constructed argv, whose first
* token is the verb.
*
* The flag guards above can only see dash-prefixed tokens, but some kubectl
* verbs reach the same assets through plain operands: "config view --raw"
* prints the operator's credentials, "config set clusters.x.server <url>"
* persistently redirects the kubeconfig, "cp" reads and writes local paths
* given as positionals. Those are matched on the verb instead.
*
* kubectl cp resolves any operand without a ":" on the local machine. Under
* stdio a local *source* is the operator reading their own file, the same as
* "-f", and stays allowed; a local *destination* is a write to a path chosen
* by whoever steers the tool arguments, like --log-file, and is refused. Over
* a remote transport both sides would be the server host, so cp is refused
* outright.
*
* The remote-only checks have no escape hatch, matching
* assertNoRemoteFileReads; the rest honour ALLOW_KUBECTL_UNSAFE_FLAGS.
*/
export function assertAllowedGenericCommand(args) {
const verb = args[0];
if (typeof verb !== "string" || !VERB_PATTERN.test(verb)) {
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl_generic with command="${verb}": the command ` +
`must be a kubectl subcommand name such as "get" or "rollout".`);
}
if (isRemoteTransport() && GENERIC_REMOTE_DENIED_VERBS.has(verb)) {
rejectVerb(verb, "its operands are paths on the MCP server's own filesystem, which is " +
"disabled on remote (SSE/Streamable HTTP) transports because the " +
"path is chosen by the client but resolved on the server host");
}
if (isUnsafeFlagsAllowed())
return;
const reason = GENERIC_DENIED_VERBS.get(verb);
if (reason !== undefined)
rejectVerb(verb, reason);
if (verb === "cp") {
const destinations = cpOperands(args).slice(1);
const local = destinations.find((operand) => !operand.includes(":"));
if (local !== undefined) {
rejectVerb(verb, `"${local}" would be written to the MCP server's own filesystem. ` +
`kubectl_generic only copies into a pod ("<pod>:<path>"); ` +
`copying out of one would let the caller overwrite any file the ` +
`server user can write`);
}
}
}
// The operands of a "kubectl cp" argv, in order. Every token that does not
// start with "-" counts, including the values of split flags ("-c main"):
// telling those apart would need kubectl's flag table, and treating a value as
// an operand can only refuse a call, never let one through. After "--" every
// token is an operand.
function cpOperands(args) {
const operands = [];
let afterSeparator = false;
for (const tok of args.slice(1)) {
if (!afterSeparator && tok === "--") {
afterSeparator = true;
continue;
}
if (afterSeparator || !tok.startsWith("-"))
operands.push(tok);
}
return operands;
}
/**
* 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);
}