mcp-server-kubernetes 4.1.4 → 4.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  export declare const serverConfig: {
2
2
  readonly name: "kubernetes";
3
- readonly version: "4.1.4";
3
+ readonly version: "4.1.6";
4
4
  readonly capabilities: {
5
5
  readonly resources: {};
6
6
  readonly tools: {};
@@ -1,6 +1,6 @@
1
1
  export const serverConfig = {
2
2
  name: "kubernetes",
3
- version: "4.1.4",
3
+ version: "4.1.6",
4
4
  capabilities: {
5
5
  resources: {},
6
6
  tools: {},
@@ -21,6 +21,46 @@ export declare function assertNoDangerousFlags(flags?: Record<string, unknown>,
21
21
  * flag unless ALLOW_KUBECTL_UNSAFE_FLAGS=true.
22
22
  */
23
23
  export declare function assertSafeArgv(args: readonly string[]): void;
24
+ /**
25
+ * Reject flags that make kubectl read a file from the MCP server's filesystem,
26
+ * on remote transports only. No-op under stdio.
27
+ *
28
+ * kubectl_generic lets a caller assemble an arbitrary kubectl argv, so the
29
+ * per-parameter guards the structured tools use (`filename`, `fromFile`,
30
+ * `patchFile`, `valuesFile`) have nothing to attach to: the same read is
31
+ * reachable as a raw "--from-file=leak=/etc/passwd" token. Under stdio that is
32
+ * the operator reading their own files and stays allowed; over SSE/Streamable
33
+ * HTTP the path comes from whoever can reach the endpoint but resolves on the
34
+ * server host, turning kubectl into an arbitrary file-read oracle (kubeconfig,
35
+ * service-account token, /proc/self/environ) — with --dry-run=client, without
36
+ * even a cluster.
37
+ *
38
+ * Takes a fully-constructed argv rather than the `flags`/`args` inputs so that
39
+ * flags built from the object form and file-shaped values smuggled through
40
+ * positional slots (resourceType, name) are covered by the same scan. Unlike
41
+ * assertNoDangerousFlags there is no ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch:
42
+ * this mirrors the transport guards in the structured tools, which reject
43
+ * server-side paths unconditionally.
44
+ */
45
+ export declare function assertNoRemoteFileReads(args: readonly string[]): void;
46
+ /**
47
+ * Reject a caller-supplied value that a tool is about to place in a *positional*
48
+ * argv slot (a resource type, a resource name, a release name, a chart
49
+ * reference, a namespace) when it is shaped like a command-line flag.
50
+ *
51
+ * assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
52
+ * so every dangerous flag added upstream is exploitable through any positional
53
+ * slot until the list is extended. This guard closes the injection point
54
+ * instead of chasing the flags: pflag treats any token starting with "-" as a
55
+ * flag no matter where it sits, and none of these operands is ever legitimately
56
+ * flag-shaped (Kubernetes resource types and names, helm release names and
57
+ * namespaces are RFC 1123 names; chart references are repo/name pairs, paths
58
+ * or URLs). Refusing a leading "-" here means caller data can never occupy a
59
+ * slot where it would be parsed as a flag.
60
+ *
61
+ * Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
62
+ */
63
+ export declare function assertNotFlagLike(value: string | undefined, field: string): void;
24
64
  /**
25
65
  * Drop-in replacement for child_process.execFileSync that scans the argv for
26
66
  * credential/target-redirecting flags before executing. Tool files import this
@@ -1,5 +1,6 @@
1
1
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
2
2
  import { execFileSync, } from "child_process";
3
+ import { isRemoteTransport } from "./transport.js";
3
4
  // Flags that would let a caller redirect kubectl to a different API server,
4
5
  // substitute credentials, or impersonate another identity. Allowing any of
5
6
  // these to flow in from tool inputs lets an attacker who can influence the
@@ -69,7 +70,8 @@ const SHORT_VALUE_FLAGS = new Set([
69
70
  "v", // --v (log level)
70
71
  ]);
71
72
  // helm exposes the same exfiltration surface as kubectl, but under "kube-"
72
- // prefixed flag names (e.g. --kube-apiserver instead of --server). We add
73
+ // prefixed flag names (e.g. --kube-apiserver instead of --server), plus a few
74
+ // helm-only flags that run or overwrite things on the MCP server host. We add
73
75
  // those here so the argv-level guard covers helm invocations too. Context
74
76
  // selection flags (--context / --kube-context) are intentionally omitted:
75
77
  // they can only select a cluster already present in the loaded kubeconfig,
@@ -83,12 +85,58 @@ const HELM_DANGEROUS_FLAGS = new Set([
83
85
  "kube-as-group",
84
86
  "kube-tls-server-name",
85
87
  "kube-insecure-skip-tls-verify",
88
+ // Runs a binary on the MCP server host: helm 3.x resolves --post-renderer to
89
+ // an executable path (or a $PATH name) and runs it over the rendered
90
+ // manifests. Nothing about installing a chart otherwise gives host-side
91
+ // execution, so this is never a flag a tool should emit.
92
+ "post-renderer",
93
+ "post-renderer-args",
94
+ // Writes to attacker-chosen filesystem paths: helm rewrites these files as
95
+ // part of "repo add" / registry login.
96
+ "repository-config",
97
+ "registry-config",
86
98
  ]);
87
99
  // Flag names that are dangerous when they appear anywhere in a fully
88
100
  // constructed argv (positional slots included), regardless of which tool
89
101
  // built it. This is DANGEROUS_FLAGS minus the context-selection flags, plus
90
102
  // the helm equivalents. See assertSafeArgv / execFileSyncSafe below.
91
103
  const ARGV_DANGEROUS_FLAGS = new Set([...DANGEROUS_FLAGS, ...HELM_DANGEROUS_FLAGS].filter((name) => name !== "context"));
104
+ // Flags that make kubectl read a file from the machine it is running on.
105
+ //
106
+ // These are not in DANGEROUS_FLAGS: under stdio that machine is the operator's
107
+ // own, and "-f manifest.yaml" is the whole point of the tool. They only become
108
+ // a vulnerability on a remote (SSE / Streamable HTTP) transport, where the path
109
+ // is chosen by a client but resolved on the server host — so they are rejected
110
+ // by transport, in assertNoRemoteFileReads, rather than denied outright.
111
+ //
112
+ // Long names only; the single-dash spellings live in FILE_READ_SHORT_FLAGS.
113
+ const FILE_READ_FLAGS = new Set([
114
+ "filename",
115
+ "from-file",
116
+ "from-env-file",
117
+ "kustomize",
118
+ "patch-file",
119
+ ]);
120
+ // Single-letter spellings of the above. Unlike SHORT_ALIASES these are checked
121
+ // against every letter pflag would parse out of a shorthand cluster, so the
122
+ // attached ("-f/etc/passwd") and clustered ("-Rf/etc/passwd") forms are caught
123
+ // alongside the split one ("-f", "/etc/passwd").
124
+ const FILE_READ_SHORT_FLAGS = new Set([
125
+ "f", // --filename
126
+ "k", // --kustomize
127
+ ]);
128
+ // Output formats whose argument is a file path on the machine kubectl runs on:
129
+ // "-o go-template-file=<path>" makes kubectl read that file and render it into
130
+ // the result. That is the same read as --from-file under a different flag
131
+ // name, but unlike --from-file no tool here ever legitimately emits one — so
132
+ // assertSafeArgv refuses them in any argv, on any transport, rather than only
133
+ // where a server-side path would be attacker-chosen. Every tool has positional
134
+ // slots, read-only ones included, so a name-shaped denial is not enough.
135
+ const FILE_READ_OUTPUT_FORMATS = new Set([
136
+ "go-template-file",
137
+ "jsonpath-file",
138
+ "custom-columns-file",
139
+ ]);
92
140
  function isUnsafeFlagsAllowed() {
93
141
  return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true";
94
142
  }
@@ -159,10 +207,10 @@ function isDangerousFlagName(rawName, fromArgs) {
159
207
  }
160
208
  function reject(flag) {
161
209
  throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` +
162
- `kubectl to a different API server or substitute credentials, which ` +
163
- `would allow exfiltration of the operator's bearer token. If you ` +
164
- `genuinely need this flag, set ALLOW_KUBECTL_UNSAFE_FLAGS=true in the ` +
165
- `server environment.`);
210
+ `kubectl/helm to a different API server, substitute credentials, or ` +
211
+ `act on the MCP server's own host, which would allow exfiltration of ` +
212
+ `the operator's bearer token. If you genuinely need this flag, set ` +
213
+ `ALLOW_KUBECTL_UNSAFE_FLAGS=true in the server environment.`);
166
214
  }
167
215
  /**
168
216
  * Validate user-supplied kubectl flags and args. Throws an McpError if any
@@ -207,7 +255,8 @@ export function assertNoDangerousFlags(flags, args) {
207
255
  export function assertSafeArgv(args) {
208
256
  if (isUnsafeFlagsAllowed())
209
257
  return;
210
- for (const tok of args) {
258
+ for (let i = 0; i < args.length; i++) {
259
+ const tok = args[i];
211
260
  if (typeof tok !== "string")
212
261
  continue;
213
262
  if (!tok.startsWith("-"))
@@ -220,7 +269,123 @@ export function assertSafeArgv(args) {
220
269
  // first one.
221
270
  if (hasDangerousShortFlag(tok))
222
271
  reject(tok);
272
+ // A file read hiding in the *value* of an otherwise ordinary flag
273
+ // ("-o go-template-file=<path>"), so it has to be matched on the value
274
+ // rather than on the flag name.
275
+ const format = outputFormatValue(tok, args[i + 1]);
276
+ if (format !== undefined &&
277
+ FILE_READ_OUTPUT_FORMATS.has(format.split("=")[0])) {
278
+ rejectOutputFileRead(tok, format);
279
+ }
280
+ }
281
+ }
282
+ /**
283
+ * Return the value kubectl would take for -o / --output in `tok`, or undefined
284
+ * if `tok` is not an output flag. The value may be attached to the token
285
+ * ("-ojson", "-o=json", "--output=json") or sit in the following one, which is
286
+ * why the caller passes `next`.
287
+ */
288
+ function outputFormatValue(tok, next) {
289
+ if (tok.startsWith("--")) {
290
+ if (normalizeFlagName(tok) !== "output")
291
+ return undefined;
292
+ const eq = tok.indexOf("=");
293
+ return eq === -1 ? next : tok.slice(eq + 1);
223
294
  }
295
+ const letters = shortFlagLetters(tok);
296
+ if (letters === null)
297
+ return undefined;
298
+ const idx = letters.indexOf("o");
299
+ if (idx === -1)
300
+ return undefined;
301
+ // "o" takes a value, so pflag stops the cluster there and reads the rest of
302
+ // the token as the format ("-Aojson" -> "-A -o json"). An empty remainder
303
+ // means the format is the next argv token instead.
304
+ let rest = tok.slice(1 + idx + 1);
305
+ if (rest.startsWith("="))
306
+ rest = rest.slice(1);
307
+ return rest === "" ? next : rest;
308
+ }
309
+ function rejectOutputFileRead(tok, format) {
310
+ throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${tok} ${format}": this output format ` +
311
+ `takes a file path and makes kubectl read that file from the MCP ` +
312
+ `server's own filesystem, rendering it into the result. No tool needs ` +
313
+ `it, and a resource name or type that parses as this flag is an ` +
314
+ `injection attempt. Use an inline template ("-o go-template=...") or a ` +
315
+ `jsonpath expression instead.`);
316
+ }
317
+ function rejectFileRead(flag) {
318
+ throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with "${flag}": this flag reads a file from the ` +
319
+ `MCP server's own filesystem, which is disabled on remote (SSE/` +
320
+ `Streamable HTTP) transports because the path is chosen by the client ` +
321
+ `but resolved on the server host. Pass the file contents inline ` +
322
+ `instead (kubectl_apply/kubectl_create "manifest", kubectl_create ` +
323
+ `"fromFileContent", or install_helm_chart "values").`);
324
+ }
325
+ /**
326
+ * Reject flags that make kubectl read a file from the MCP server's filesystem,
327
+ * on remote transports only. No-op under stdio.
328
+ *
329
+ * kubectl_generic lets a caller assemble an arbitrary kubectl argv, so the
330
+ * per-parameter guards the structured tools use (`filename`, `fromFile`,
331
+ * `patchFile`, `valuesFile`) have nothing to attach to: the same read is
332
+ * reachable as a raw "--from-file=leak=/etc/passwd" token. Under stdio that is
333
+ * the operator reading their own files and stays allowed; over SSE/Streamable
334
+ * HTTP the path comes from whoever can reach the endpoint but resolves on the
335
+ * server host, turning kubectl into an arbitrary file-read oracle (kubeconfig,
336
+ * service-account token, /proc/self/environ) — with --dry-run=client, without
337
+ * even a cluster.
338
+ *
339
+ * Takes a fully-constructed argv rather than the `flags`/`args` inputs so that
340
+ * flags built from the object form and file-shaped values smuggled through
341
+ * positional slots (resourceType, name) are covered by the same scan. Unlike
342
+ * assertNoDangerousFlags there is no ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch:
343
+ * this mirrors the transport guards in the structured tools, which reject
344
+ * server-side paths unconditionally.
345
+ */
346
+ export function assertNoRemoteFileReads(args) {
347
+ if (!isRemoteTransport())
348
+ return;
349
+ for (let i = 0; i < args.length; i++) {
350
+ const tok = args[i];
351
+ if (typeof tok !== "string" || !tok.startsWith("-"))
352
+ continue;
353
+ if (FILE_READ_FLAGS.has(normalizeFlagName(tok)))
354
+ rejectFileRead(tok);
355
+ // Short spellings, including the attached and clustered forms pflag
356
+ // accepts: "-f/etc/passwd", "-Rf/etc/passwd".
357
+ const letters = shortFlagLetters(tok);
358
+ if (letters?.some((letter) => FILE_READ_SHORT_FLAGS.has(letter))) {
359
+ rejectFileRead(tok);
360
+ }
361
+ }
362
+ }
363
+ /**
364
+ * Reject a caller-supplied value that a tool is about to place in a *positional*
365
+ * argv slot (a resource type, a resource name, a release name, a chart
366
+ * reference, a namespace) when it is shaped like a command-line flag.
367
+ *
368
+ * assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
369
+ * so every dangerous flag added upstream is exploitable through any positional
370
+ * slot until the list is extended. This guard closes the injection point
371
+ * instead of chasing the flags: pflag treats any token starting with "-" as a
372
+ * flag no matter where it sits, and none of these operands is ever legitimately
373
+ * flag-shaped (Kubernetes resource types and names, helm release names and
374
+ * namespaces are RFC 1123 names; chart references are repo/name pairs, paths
375
+ * or URLs). Refusing a leading "-" here means caller data can never occupy a
376
+ * slot where it would be parsed as a flag.
377
+ *
378
+ * Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
379
+ */
380
+ export function assertNotFlagLike(value, field) {
381
+ if (isUnsafeFlagsAllowed())
382
+ return;
383
+ if (typeof value !== "string" || !value.startsWith("-"))
384
+ return;
385
+ throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl/helm with ${field}="${value}": a value ` +
386
+ `starting with "-" is parsed as a command-line flag rather than as ` +
387
+ `the ${field}, which would let the caller inject arbitrary flags. ` +
388
+ `Provide a ${field} that does not begin with "-".`);
224
389
  }
225
390
  /**
226
391
  * Drop-in replacement for child_process.execFileSync that scans the argv for
@@ -5,7 +5,7 @@
5
5
  * Supports local chart paths, remote repositories, and custom values.
6
6
  */
7
7
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
8
- import { execFileSyncSafe } from "../security/kubectl-flags.js";
8
+ import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
9
9
  import { writeFileSync, unlinkSync } from "fs";
10
10
  import { dump } from "js-yaml";
11
11
  import { isRemoteTransport } from "../security/transport.js";
@@ -166,6 +166,19 @@ const rejectValuesFileOnRemoteTransport = (valuesFile) => {
166
166
  throw new McpError(ErrorCode.InvalidRequest, "The 'valuesFile' parameter reads a file from the MCP server's filesystem and is disabled on remote (SSE/Streamable HTTP) transports. Pass the values inline via 'values' instead.");
167
167
  }
168
168
  };
169
+ // The release name, chart reference, namespace and repo URL are all placed in
170
+ // positional argv slots ("helm install <name> <chart>", "helm repo add
171
+ // <name> <url>", "kubectl create namespace <ns>"). pflag parses any token
172
+ // beginning with "-" as a flag regardless of position, so a caller-supplied
173
+ // value such as "--post-renderer=/tmp/x.sh" would become a helm flag instead
174
+ // of an operand. Reject flag-shaped operands up front rather than relying on
175
+ // the argv deny-list to know every dangerous helm flag.
176
+ const assertOperandsNotFlagLike = (params) => {
177
+ assertNotFlagLike(params.name, "release name");
178
+ assertNotFlagLike(params.chart, "chart");
179
+ assertNotFlagLike(params.namespace, "namespace");
180
+ assertNotFlagLike(params.repo, "repo");
181
+ };
169
182
  /**
170
183
  * Install a Helm chart using template mode (helm template + kubectl apply).
171
184
  * This mode bypasses authentication issues and kubeconfig API version mismatches.
@@ -274,6 +287,7 @@ async function installHelmChartTemplate(params) {
274
287
  */
275
288
  export async function installHelmChart(params) {
276
289
  rejectValuesFileOnRemoteTransport(params.valuesFile);
290
+ assertOperandsNotFlagLike(params);
277
291
  // Use template mode if requested
278
292
  if (params.useTemplate) {
279
293
  return installHelmChartTemplate(params);
@@ -349,6 +363,7 @@ export async function installHelmChart(params) {
349
363
  */
350
364
  export async function upgradeHelmChart(params) {
351
365
  rejectValuesFileOnRemoteTransport(params.valuesFile);
366
+ assertOperandsNotFlagLike(params);
352
367
  try {
353
368
  // Add repository if provided
354
369
  if (params.repo) {
@@ -415,6 +430,7 @@ export async function upgradeHelmChart(params) {
415
430
  * @returns Promise with uninstall result
416
431
  */
417
432
  export async function uninstallHelmChart(params) {
433
+ assertOperandsNotFlagLike(params);
418
434
  try {
419
435
  executeCommand("helm", [
420
436
  "uninstall",
@@ -1,4 +1,4 @@
1
- import { execFileSyncSafe } from "../security/kubectl-flags.js";
1
+ import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
2
2
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
@@ -94,6 +94,11 @@ export async function kubectlDelete(k8sManager, input) {
94
94
  }
95
95
  else {
96
96
  // Handle deleting by resource type and name/selector
97
+ // A resource type or name is an RFC 1123 name, never a flag. Refusing
98
+ // a leading "-" closes the positional injection slot itself rather than
99
+ // relying on the argv denylist to know every dangerous flag.
100
+ assertNotFlagLike(input.resourceType, "resourceType");
101
+ assertNotFlagLike(input.name, "name");
97
102
  args.push(input.resourceType);
98
103
  if (input.name) {
99
104
  args.push(input.name);
@@ -1,4 +1,4 @@
1
- import { execFileSyncSafe } from "../security/kubectl-flags.js";
1
+ import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
2
2
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { getSpawnMaxBuffer } from "../config/max-buffer.js";
4
4
  import { namespaceParameter, contextParameter, } from "../models/common-parameters.js";
@@ -32,6 +32,11 @@ export const kubectlDescribeSchema = {
32
32
  };
33
33
  export async function kubectlDescribe(k8sManager, input) {
34
34
  try {
35
+ // A resource type or name is an RFC 1123 name, never a flag. Refusing a
36
+ // leading "-" closes the positional injection slot itself rather than
37
+ // relying on the argv denylist to know every dangerous flag.
38
+ assertNotFlagLike(input.resourceType, "resourceType");
39
+ assertNotFlagLike(input.name, "name");
35
40
  const resourceType = input.resourceType.toLowerCase();
36
41
  const name = input.name;
37
42
  const namespace = input.namespace || "default";
@@ -2,7 +2,7 @@ import { execFileSyncSafe } from "../security/kubectl-flags.js";
2
2
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { getSpawnMaxBuffer } from "../config/max-buffer.js";
4
4
  import { contextParameter, namespaceParameter, } from "../models/common-parameters.js";
5
- import { assertNoDangerousFlags } from "../security/kubectl-flags.js";
5
+ import { assertNoDangerousFlags, assertNoRemoteFileReads, assertNotFlagLike, } from "../security/kubectl-flags.js";
6
6
  export const kubectlGenericSchema = {
7
7
  name: "kubectl_generic",
8
8
  description: "Execute any kubectl command with the provided arguments and flags",
@@ -59,6 +59,13 @@ export async function kubectlGeneric(k8sManager, input) {
59
59
  // Reject credential/target-redirecting flags before constructing the
60
60
  // command. See src/security/kubectl-flags.ts for the rationale.
61
61
  assertNoDangerousFlags(input.flags, input.args);
62
+ // The verb and the resource operands are never flags — only the free-form
63
+ // `args` array is. Refusing a leading "-" in the operand slots closes the
64
+ // positional injection point without narrowing what `args` can express.
65
+ assertNotFlagLike(input.command, "command");
66
+ assertNotFlagLike(input.subCommand, "subCommand");
67
+ assertNotFlagLike(input.resourceType, "resourceType");
68
+ assertNotFlagLike(input.name, "name");
62
69
  // Start building the kubectl command
63
70
  const command = "kubectl";
64
71
  const cmdArgs = [input.command];
@@ -107,6 +114,13 @@ export async function kubectlGeneric(k8sManager, input) {
107
114
  if (input.context) {
108
115
  cmdArgs.push("--context", input.context);
109
116
  }
117
+ // Reject server-side filesystem reads on remote transports, matching the
118
+ // per-parameter guards in the structured tools. This tool hands the caller
119
+ // a free-form kubectl argv, so there is no parameter to attach them to:
120
+ // "--from-file", "-f" and friends arrive as raw tokens and have to be
121
+ // matched in the argv. See src/security/transport.ts for the trust model.
122
+ // No-op under stdio, where the files are the operator's own.
123
+ assertNoRemoteFileReads(cmdArgs);
110
124
  // Execute the command
111
125
  try {
112
126
  console.error(`Executing: kubectl ${cmdArgs.join(" ")}`);
@@ -1,4 +1,4 @@
1
- import { execFileSyncSafe } from "../security/kubectl-flags.js";
1
+ import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
2
2
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { getSpawnMaxBuffer } from "../config/max-buffer.js";
4
4
  import * as yaml from "js-yaml";
@@ -51,6 +51,11 @@ export const kubectlGetSchema = {
51
51
  };
52
52
  export async function kubectlGet(k8sManager, input) {
53
53
  try {
54
+ // A resource type or name is an RFC 1123 name, never a flag. Refusing a
55
+ // leading "-" closes the positional injection slot itself rather than
56
+ // relying on the argv denylist to know every dangerous flag.
57
+ assertNotFlagLike(input.resourceType, "resourceType");
58
+ assertNotFlagLike(input.name, "name");
54
59
  const resourceType = input.resourceType.toLowerCase();
55
60
  const name = input.name || "";
56
61
  const namespace = input.namespace || "default";
@@ -1,4 +1,4 @@
1
- import { execFileSyncSafe } from "../security/kubectl-flags.js";
1
+ import { assertNotFlagLike, execFileSyncSafe, } from "../security/kubectl-flags.js";
2
2
  import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
3
3
  import { getSpawnMaxBuffer } from "../config/max-buffer.js";
4
4
  import { contextParameter, namespaceParameter } from "../models/common-parameters.js";
@@ -32,6 +32,11 @@ export const kubectlScaleSchema = {
32
32
  };
33
33
  export async function kubectlScale(k8sManager, input) {
34
34
  try {
35
+ // A resource type or name is an RFC 1123 name, never a flag. Refusing a
36
+ // leading "-" closes the positional injection slot itself rather than
37
+ // relying on the argv denylist to know every dangerous flag.
38
+ assertNotFlagLike(input.resourceType, "resourceType");
39
+ assertNotFlagLike(input.name, "name");
35
40
  const namespace = input.namespace || "default";
36
41
  const resourceType = input.resourceType || "deployment";
37
42
  const context = input.context || "";
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Shared construction of the allowedHosts list used by both HTTP transports
3
+ * for DNS rebinding protection.
4
+ */
5
+ /**
6
+ * True when `host` is an all-interfaces bind address ("0.0.0.0", "::", and the
7
+ * bracketed IPv6 spelling).
8
+ */
9
+ export declare function isAllInterfacesHost(host: string): boolean;
10
+ /**
11
+ * Build the default allowedHosts list for DNS rebinding protection.
12
+ * Includes localhost variants with and without port.
13
+ *
14
+ * The configured HOST is added too, so that binding to a specific address also
15
+ * accepts that address as a Host header — but only when it names an actual
16
+ * interface. An all-interfaces bind ("0.0.0.0", "::") is deliberately excluded:
17
+ * it is a bind directive, not an address a client resolves, so accepting it
18
+ * would let any caller satisfy the allowlist by sending "Host: 0.0.0.0:<port>"
19
+ * — a value that is public knowledge rather than a property of the deployment.
20
+ * That would defeat the guard exactly where it matters most, since binding to
21
+ * all interfaces is what makes the server reachable off-host in the first
22
+ * place.
23
+ *
24
+ * Deployments that bind to all interfaces and need to accept a real hostname
25
+ * (a Kubernetes Service DNS name, an ingress host) set
26
+ * DNS_REBINDING_ALLOWED_HOST to that name; it replaces this list entirely.
27
+ */
28
+ export declare function buildDefaultAllowedHosts(host: string, port: number): string[];
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Shared construction of the allowedHosts list used by both HTTP transports
3
+ * for DNS rebinding protection.
4
+ */
5
+ // Bind addresses that mean "every interface". They are not hostnames: no
6
+ // legitimate client resolves them, so no legitimate client sends one in a Host
7
+ // header. See isAllInterfacesHost / buildDefaultAllowedHosts below.
8
+ const ALL_INTERFACES_HOSTS = new Set(["0.0.0.0", "::", "::0", "0"]);
9
+ /**
10
+ * True when `host` is an all-interfaces bind address ("0.0.0.0", "::", and the
11
+ * bracketed IPv6 spelling).
12
+ */
13
+ export function isAllInterfacesHost(host) {
14
+ const bare = host.startsWith("[") && host.endsWith("]")
15
+ ? host.slice(1, -1)
16
+ : host;
17
+ return ALL_INTERFACES_HOSTS.has(bare);
18
+ }
19
+ /**
20
+ * Build the default allowedHosts list for DNS rebinding protection.
21
+ * Includes localhost variants with and without port.
22
+ *
23
+ * The configured HOST is added too, so that binding to a specific address also
24
+ * accepts that address as a Host header — but only when it names an actual
25
+ * interface. An all-interfaces bind ("0.0.0.0", "::") is deliberately excluded:
26
+ * it is a bind directive, not an address a client resolves, so accepting it
27
+ * would let any caller satisfy the allowlist by sending "Host: 0.0.0.0:<port>"
28
+ * — a value that is public knowledge rather than a property of the deployment.
29
+ * That would defeat the guard exactly where it matters most, since binding to
30
+ * all interfaces is what makes the server reachable off-host in the first
31
+ * place.
32
+ *
33
+ * Deployments that bind to all interfaces and need to accept a real hostname
34
+ * (a Kubernetes Service DNS name, an ingress host) set
35
+ * DNS_REBINDING_ALLOWED_HOST to that name; it replaces this list entirely.
36
+ */
37
+ export function buildDefaultAllowedHosts(host, port) {
38
+ // Always allow the bare host and host:port for common localhost addresses
39
+ const localhostAliases = ["127.0.0.1", "localhost", "::1"];
40
+ const hosts = [];
41
+ for (const alias of localhostAliases) {
42
+ hosts.push(alias);
43
+ // HTTP Host header uses bracket notation for IPv6: [::1]:3000
44
+ if (alias.includes(":")) {
45
+ hosts.push(`[${alias}]`);
46
+ hosts.push(`[${alias}]:${port}`);
47
+ }
48
+ else {
49
+ hosts.push(`${alias}:${port}`);
50
+ }
51
+ }
52
+ // Also add the configured host if it's not already covered, unless it is an
53
+ // all-interfaces bind address (see above).
54
+ if (!localhostAliases.includes(host) && !isAllInterfacesHost(host)) {
55
+ hosts.push(host);
56
+ if (host.includes(":") && !host.startsWith("[")) {
57
+ hosts.push(`[${host}]`);
58
+ hosts.push(`[${host}]:${port}`);
59
+ }
60
+ else {
61
+ hosts.push(`${host}:${port}`);
62
+ }
63
+ }
64
+ return hosts;
65
+ }
package/dist/utils/sse.js CHANGED
@@ -1,38 +1,7 @@
1
1
  import express from "express";
2
2
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3
3
  import { createAuthMiddleware, isAuthEnabled } from "./auth.js";
4
- /**
5
- * Build the default allowedHosts list for DNS rebinding protection.
6
- * Includes localhost variants with and without port.
7
- */
8
- function buildDefaultAllowedHosts(host, port) {
9
- // Always allow the bare host and host:port for common localhost addresses
10
- const localhostAliases = ["127.0.0.1", "localhost", "::1"];
11
- const hosts = [];
12
- for (const alias of localhostAliases) {
13
- hosts.push(alias);
14
- // HTTP Host header uses bracket notation for IPv6: [::1]:3000
15
- if (alias.includes(":")) {
16
- hosts.push(`[${alias}]`);
17
- hosts.push(`[${alias}]:${port}`);
18
- }
19
- else {
20
- hosts.push(`${alias}:${port}`);
21
- }
22
- }
23
- // Also add the configured host if it's not already covered
24
- if (!localhostAliases.includes(host)) {
25
- hosts.push(host);
26
- if (host.includes(":") && !host.startsWith("[")) {
27
- hosts.push(`[${host}]`);
28
- hosts.push(`[${host}]:${port}`);
29
- }
30
- else {
31
- hosts.push(`${host}:${port}`);
32
- }
33
- }
34
- return hosts;
35
- }
4
+ import { buildDefaultAllowedHosts, isAllInterfacesHost, } from "./allowed-hosts.js";
36
5
  export function startSSEServer(server) {
37
6
  const app = express();
38
7
  // Create auth middleware - when MCP_AUTH_TOKEN is set, requires X-MCP-AUTH header
@@ -46,12 +15,24 @@ export function startSSEServer(server) {
46
15
  ? [process.env.DNS_REBINDING_ALLOWED_HOST]
47
16
  : buildDefaultAllowedHosts(host, port);
48
17
  // Warn when binding to all interfaces with DNS rebinding protection disabled
49
- if (!enableDnsRebindingProtection && (host === "0.0.0.0" || host === "::")) {
18
+ if (!enableDnsRebindingProtection && isAllInterfacesHost(host)) {
50
19
  console.warn("WARNING: DNS rebinding protection is disabled while HOST is set to " +
51
20
  `'${host}'. This exposes the MCP server to DNS rebinding attacks ` +
52
21
  "from any browser on the network. Set DNS_REBINDING_PROTECTION=true " +
53
22
  "(the default) or restrict HOST to 'localhost' / '127.0.0.1'.");
54
23
  }
24
+ // Binding to all interfaces makes the server reachable off-host, but the
25
+ // default allowlist only covers localhost: a client reaching it under any
26
+ // other name gets a 403 until the operator names that hostname explicitly.
27
+ if (enableDnsRebindingProtection &&
28
+ isAllInterfacesHost(host) &&
29
+ !process.env.DNS_REBINDING_ALLOWED_HOST) {
30
+ console.warn(`NOTE: HOST is set to '${host}' (all interfaces) and DNS rebinding ` +
31
+ "protection is enabled, so only requests with a localhost Host header " +
32
+ "are accepted. Clients reaching this server under another hostname " +
33
+ "(a Kubernetes Service name, an ingress host) must be allowed by " +
34
+ "setting DNS_REBINDING_ALLOWED_HOST to that hostname.");
35
+ }
55
36
  // Currently just copying from docs & allowing for multiple transport connections: https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse
56
37
  // Note: When MCP_AUTH_TOKEN is set, requests require X-MCP-AUTH header for authentication
57
38
  let transports = [];
@@ -95,8 +76,12 @@ export function startSSEServer(server) {
95
76
  });
96
77
  }
97
78
  });
79
+ // An all-interfaces bind is not a hostname a client can use: with DNS
80
+ // rebinding protection on it is not in the allowlist either, so advertise a
81
+ // localhost URL that actually works rather than 'http://0.0.0.0:<port>'.
82
+ const advertisedHost = isAllInterfacesHost(host) ? "localhost" : host;
98
83
  app.listen(port, host, () => {
99
- console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/sse`);
84
+ console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${advertisedHost}:${port}/sse`);
100
85
  if (isAuthEnabled()) {
101
86
  console.log("Authentication enabled: X-MCP-AUTH header required for all MCP requests");
102
87
  }
@@ -1,38 +1,7 @@
1
1
  import express from "express";
2
2
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
3
  import { createAuthMiddleware, isAuthEnabled } from "./auth.js";
4
- /**
5
- * Build the default allowedHosts list for DNS rebinding protection.
6
- * Includes localhost variants with and without port.
7
- */
8
- function buildDefaultAllowedHosts(host, port) {
9
- // Always allow the bare host and host:port for common localhost addresses
10
- const localhostAliases = ["127.0.0.1", "localhost", "::1"];
11
- const hosts = [];
12
- for (const alias of localhostAliases) {
13
- hosts.push(alias);
14
- // HTTP Host header uses bracket notation for IPv6: [::1]:3000
15
- if (alias.includes(":")) {
16
- hosts.push(`[${alias}]`);
17
- hosts.push(`[${alias}]:${port}`);
18
- }
19
- else {
20
- hosts.push(`${alias}:${port}`);
21
- }
22
- }
23
- // Also add the configured host if it's not already covered
24
- if (!localhostAliases.includes(host)) {
25
- hosts.push(host);
26
- if (host.includes(":") && !host.startsWith("[")) {
27
- hosts.push(`[${host}]`);
28
- hosts.push(`[${host}]:${port}`);
29
- }
30
- else {
31
- hosts.push(`${host}:${port}`);
32
- }
33
- }
34
- return hosts;
35
- }
4
+ import { buildDefaultAllowedHosts, isAllInterfacesHost, } from "./allowed-hosts.js";
36
5
  export function startStreamableHTTPServer(server) {
37
6
  const app = express();
38
7
  app.use(express.json());
@@ -47,12 +16,24 @@ export function startStreamableHTTPServer(server) {
47
16
  ? [process.env.DNS_REBINDING_ALLOWED_HOST]
48
17
  : buildDefaultAllowedHosts(host, port);
49
18
  // Warn when binding to all interfaces with DNS rebinding protection disabled
50
- if (!enableDnsRebindingProtection && (host === "0.0.0.0" || host === "::")) {
19
+ if (!enableDnsRebindingProtection && isAllInterfacesHost(host)) {
51
20
  console.warn("WARNING: DNS rebinding protection is disabled while HOST is set to " +
52
21
  `'${host}'. This exposes the MCP server to DNS rebinding attacks ` +
53
22
  "from any browser on the network. Set DNS_REBINDING_PROTECTION=true " +
54
23
  "(the default) or restrict HOST to 'localhost' / '127.0.0.1'.");
55
24
  }
25
+ // Binding to all interfaces makes the server reachable off-host, but the
26
+ // default allowlist only covers localhost: a client reaching it under any
27
+ // other name gets a 403 until the operator names that hostname explicitly.
28
+ if (enableDnsRebindingProtection &&
29
+ isAllInterfacesHost(host) &&
30
+ !process.env.DNS_REBINDING_ALLOWED_HOST) {
31
+ console.warn(`NOTE: HOST is set to '${host}' (all interfaces) and DNS rebinding ` +
32
+ "protection is enabled, so only requests with a localhost Host header " +
33
+ "are accepted. Clients reaching this server under another hostname " +
34
+ "(a Kubernetes Service name, an ingress host) must be allowed by " +
35
+ "setting DNS_REBINDING_ALLOWED_HOST to that hostname.");
36
+ }
56
37
  app.post("/mcp", authMiddleware, async (req, res) => {
57
38
  // In stateless mode, create a new instance of transport and server for each request
58
39
  // to ensure complete isolation. A single instance would cause request ID collisions
@@ -132,8 +113,12 @@ export function startStreamableHTTPServer(server) {
132
113
  });
133
114
  }
134
115
  });
116
+ // An all-interfaces bind is not a hostname a client can use: with DNS
117
+ // rebinding protection on it is not in the allowlist either, so advertise a
118
+ // localhost URL that actually works rather than 'http://0.0.0.0:<port>'.
119
+ const advertisedHost = isAllInterfacesHost(host) ? "localhost" : host;
135
120
  const httpServer = app.listen(port, host, () => {
136
- console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/mcp`);
121
+ console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${advertisedHost}:${port}/mcp`);
137
122
  if (isAuthEnabled()) {
138
123
  console.log("Authentication enabled: X-MCP-AUTH header required for all MCP requests");
139
124
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-server-kubernetes",
3
- "version": "4.1.4",
3
+ "version": "4.1.6",
4
4
  "description": "MCP server for interacting with Kubernetes clusters via kubectl",
5
5
  "license": "MIT",
6
6
  "type": "module",