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.
- package/dist/config/server-config.d.ts +1 -1
- package/dist/config/server-config.js +1 -1
- package/dist/security/kubectl-flags.d.ts +28 -5
- package/dist/security/kubectl-flags.js +138 -10
- package/dist/tools/kubectl-delete.js +6 -1
- package/dist/tools/kubectl-describe.js +6 -1
- package/dist/tools/kubectl-generic.js +15 -1
- package/dist/tools/kubectl-get.js +6 -1
- package/dist/tools/kubectl-scale.js +6 -1
- package/dist/utils/allowed-hosts.d.ts +28 -0
- package/dist/utils/allowed-hosts.js +65 -0
- package/dist/utils/sse.js +19 -34
- package/dist/utils/streamable-http.js +19 -34
- package/package.json +2 -2
|
@@ -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
|
|
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 (
|
|
35
|
-
* references are repo/name pairs, paths
|
|
36
|
-
*
|
|
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 (
|
|
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
|
|
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 (
|
|
247
|
-
* references are repo/name pairs, paths
|
|
248
|
-
*
|
|
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
|
|
258
|
-
`"-" is parsed as a command-line flag rather than as
|
|
259
|
-
`which would let the caller inject arbitrary
|
|
260
|
-
|
|
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
|
|
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://${
|
|
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
|
|
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://${
|
|
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.
|
|
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.
|
|
49
|
+
"js-yaml": "4.3.2",
|
|
50
50
|
"yaml": "2.8.3",
|
|
51
51
|
"zod": "3.25.76"
|
|
52
52
|
},
|