mcp-server-kubernetes 4.1.5 → 4.1.7

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.5";
3
+ readonly version: "4.1.7";
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.5",
3
+ version: "4.1.7",
4
4
  capabilities: {
5
5
  resources: {},
6
6
  tools: {},
@@ -21,19 +21,42 @@ 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;
24
46
  /**
25
47
  * Reject a caller-supplied value that a tool is about to place in a *positional*
26
- * argv slot (a release name, a chart reference, a namespace) when it is shaped
27
- * like a command-line flag.
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.
28
50
  *
29
51
  * assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
30
52
  * so every dangerous flag added upstream is exploitable through any positional
31
53
  * slot until the list is extended. This guard closes the injection point
32
54
  * instead of chasing the flags: pflag treats any token starting with "-" as a
33
55
  * flag no matter where it sits, and none of these operands is ever legitimately
34
- * flag-shaped (helm release names and namespaces are RFC 1123 names; chart
35
- * references are repo/name pairs, paths or URLs). Refusing a leading "-" here
36
- * means caller data can never occupy a slot where it would be parsed as a flag.
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.
37
60
  *
38
61
  * Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
39
62
  */
@@ -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
@@ -100,6 +101,42 @@ const HELM_DANGEROUS_FLAGS = new Set([
100
101
  // built it. This is DANGEROUS_FLAGS minus the context-selection flags, plus
101
102
  // the helm equivalents. See assertSafeArgv / execFileSyncSafe below.
102
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
+ ]);
103
140
  function isUnsafeFlagsAllowed() {
104
141
  return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true";
105
142
  }
@@ -218,7 +255,8 @@ export function assertNoDangerousFlags(flags, args) {
218
255
  export function assertSafeArgv(args) {
219
256
  if (isUnsafeFlagsAllowed())
220
257
  return;
221
- for (const tok of args) {
258
+ for (let i = 0; i < args.length; i++) {
259
+ const tok = args[i];
222
260
  if (typeof tok !== "string")
223
261
  continue;
224
262
  if (!tok.startsWith("-"))
@@ -231,21 +269,111 @@ export function assertSafeArgv(args) {
231
269
  // first one.
232
270
  if (hasDangerousShortFlag(tok))
233
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);
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
+ }
234
361
  }
235
362
  }
236
363
  /**
237
364
  * Reject a caller-supplied value that a tool is about to place in a *positional*
238
- * argv slot (a release name, a chart reference, a namespace) when it is shaped
239
- * like a command-line flag.
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.
240
367
  *
241
368
  * assertSafeArgv is a deny-list: it can only refuse the flags it knows about,
242
369
  * so every dangerous flag added upstream is exploitable through any positional
243
370
  * slot until the list is extended. This guard closes the injection point
244
371
  * instead of chasing the flags: pflag treats any token starting with "-" as a
245
372
  * flag no matter where it sits, and none of these operands is ever legitimately
246
- * flag-shaped (helm release names and namespaces are RFC 1123 names; chart
247
- * references are repo/name pairs, paths or URLs). Refusing a leading "-" here
248
- * means caller data can never occupy a slot where it would be parsed as a flag.
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.
249
377
  *
250
378
  * Honours the same ALLOW_KUBECTL_UNSAFE_FLAGS escape hatch as the flag guard.
251
379
  */
@@ -254,10 +382,10 @@ export function assertNotFlagLike(value, field) {
254
382
  return;
255
383
  if (typeof value !== "string" || !value.startsWith("-"))
256
384
  return;
257
- throw new McpError(ErrorCode.InvalidParams, `Refusing to run helm with ${field}="${value}": a value starting with ` +
258
- `"-" is parsed as a command-line flag rather than as the ${field}, ` +
259
- `which would let the caller inject arbitrary helm flags. Provide a ` +
260
- `${field} that does not begin with "-".`);
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 "-".`);
261
389
  }
262
390
  /**
263
391
  * Drop-in replacement for child_process.execFileSync that scans the argv for
@@ -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.5",
3
+ "version": "4.1.7",
4
4
  "description": "MCP server for interacting with Kubernetes clusters via kubectl",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,7 +46,7 @@
46
46
  "@opentelemetry/sdk-node": "0.219.0",
47
47
  "@opentelemetry/semantic-conventions": "1.40.0",
48
48
  "express": "4.22.2",
49
- "js-yaml": "4.3.1",
49
+ "js-yaml": "4.3.2",
50
50
  "yaml": "2.8.3",
51
51
  "zod": "3.25.76"
52
52
  },