mcp-server-kubernetes 3.6.2 → 3.8.0
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.
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate user-supplied kubectl flags and args. Throws an McpError if any
|
|
3
|
+
* dangerous flag is present and the unsafe-flags escape hatch is not set.
|
|
4
|
+
*
|
|
5
|
+
* The check covers:
|
|
6
|
+
* - keys of the `flags` object (e.g. { server: "..." })
|
|
7
|
+
* - tokens in the `args` array, in both joined ("--server=x") and split
|
|
8
|
+
* ("--server", "x") forms, plus short aliases ("-s").
|
|
9
|
+
*/
|
|
10
|
+
export declare function assertNoDangerousFlags(flags?: Record<string, unknown>, args?: string[]): void;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
// Flags that would let a caller redirect kubectl to a different API server,
|
|
3
|
+
// substitute credentials, or impersonate another identity. Allowing any of
|
|
4
|
+
// these to flow in from tool inputs lets an attacker who can influence the
|
|
5
|
+
// LLM's tool arguments (e.g. via indirect prompt injection in pod logs)
|
|
6
|
+
// exfiltrate the operator's bearer token to an attacker-controlled host.
|
|
7
|
+
//
|
|
8
|
+
// Names are stored in canonical (long-form) kebab-case, without the leading
|
|
9
|
+
// "--". Short aliases that have the same effect are listed in SHORT_ALIASES.
|
|
10
|
+
const DANGEROUS_FLAGS = new Set([
|
|
11
|
+
// Target / endpoint overrides
|
|
12
|
+
"server",
|
|
13
|
+
"kubeconfig",
|
|
14
|
+
"cluster",
|
|
15
|
+
"context",
|
|
16
|
+
"user",
|
|
17
|
+
"tls-server-name",
|
|
18
|
+
// TLS bypass
|
|
19
|
+
"insecure-skip-tls-verify",
|
|
20
|
+
"certificate-authority",
|
|
21
|
+
"client-certificate",
|
|
22
|
+
"client-key",
|
|
23
|
+
// Credential overrides
|
|
24
|
+
"token",
|
|
25
|
+
"username",
|
|
26
|
+
"password",
|
|
27
|
+
"auth-provider",
|
|
28
|
+
"auth-provider-arg",
|
|
29
|
+
"exec-command",
|
|
30
|
+
"exec-arg",
|
|
31
|
+
"exec-api-version",
|
|
32
|
+
"exec-env",
|
|
33
|
+
// Identity impersonation
|
|
34
|
+
"as",
|
|
35
|
+
"as-group",
|
|
36
|
+
"as-uid",
|
|
37
|
+
]);
|
|
38
|
+
const SHORT_ALIASES = new Set([
|
|
39
|
+
"s", // -s is an alias for --server
|
|
40
|
+
]);
|
|
41
|
+
function isUnsafeFlagsAllowed() {
|
|
42
|
+
return process.env.ALLOW_KUBECTL_UNSAFE_FLAGS === "true";
|
|
43
|
+
}
|
|
44
|
+
function normalizeFlagName(raw) {
|
|
45
|
+
// Strip leading dashes; drop "=value" suffix; lowercase.
|
|
46
|
+
let name = raw.replace(/^-+/, "");
|
|
47
|
+
const eq = name.indexOf("=");
|
|
48
|
+
if (eq !== -1)
|
|
49
|
+
name = name.slice(0, eq);
|
|
50
|
+
return name.toLowerCase();
|
|
51
|
+
}
|
|
52
|
+
function isDangerousFlagName(rawName, fromArgs) {
|
|
53
|
+
const name = normalizeFlagName(rawName);
|
|
54
|
+
if (DANGEROUS_FLAGS.has(name))
|
|
55
|
+
return true;
|
|
56
|
+
// Short aliases (-s) are only meaningful when they appear as a CLI token,
|
|
57
|
+
// not as a key in the `flags` object.
|
|
58
|
+
if (fromArgs && SHORT_ALIASES.has(name))
|
|
59
|
+
return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
function reject(flag) {
|
|
63
|
+
throw new McpError(ErrorCode.InvalidParams, `Refusing to run kubectl with flag "${flag}": this flag can redirect ` +
|
|
64
|
+
`kubectl to a different API server or substitute credentials, which ` +
|
|
65
|
+
`would allow exfiltration of the operator's bearer token. If you ` +
|
|
66
|
+
`genuinely need this flag, set ALLOW_KUBECTL_UNSAFE_FLAGS=true in the ` +
|
|
67
|
+
`server environment.`);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate user-supplied kubectl flags and args. Throws an McpError if any
|
|
71
|
+
* dangerous flag is present and the unsafe-flags escape hatch is not set.
|
|
72
|
+
*
|
|
73
|
+
* The check covers:
|
|
74
|
+
* - keys of the `flags` object (e.g. { server: "..." })
|
|
75
|
+
* - tokens in the `args` array, in both joined ("--server=x") and split
|
|
76
|
+
* ("--server", "x") forms, plus short aliases ("-s").
|
|
77
|
+
*/
|
|
78
|
+
export function assertNoDangerousFlags(flags, args) {
|
|
79
|
+
if (isUnsafeFlagsAllowed())
|
|
80
|
+
return;
|
|
81
|
+
if (flags) {
|
|
82
|
+
for (const key of Object.keys(flags)) {
|
|
83
|
+
if (isDangerousFlagName(key, false))
|
|
84
|
+
reject(`--${normalizeFlagName(key)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (args) {
|
|
88
|
+
for (const tok of args) {
|
|
89
|
+
if (typeof tok !== "string")
|
|
90
|
+
continue;
|
|
91
|
+
if (!tok.startsWith("-"))
|
|
92
|
+
continue;
|
|
93
|
+
if (isDangerousFlagName(tok, true))
|
|
94
|
+
reject(tok);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -2,6 +2,7 @@ import { execFileSync } from "child_process";
|
|
|
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
6
|
export const kubectlGenericSchema = {
|
|
6
7
|
name: "kubectl_generic",
|
|
7
8
|
description: "Execute any kubectl command with the provided arguments and flags",
|
|
@@ -50,6 +51,9 @@ export const kubectlGenericSchema = {
|
|
|
50
51
|
};
|
|
51
52
|
export async function kubectlGeneric(k8sManager, input) {
|
|
52
53
|
try {
|
|
54
|
+
// Reject credential/target-redirecting flags before constructing the
|
|
55
|
+
// command. See src/security/kubectl-flags.ts for the rationale.
|
|
56
|
+
assertNoDangerousFlags(input.flags, input.args);
|
|
53
57
|
// Start building the kubectl command
|
|
54
58
|
const command = "kubectl";
|
|
55
59
|
const cmdArgs = [input.command];
|
|
@@ -1,22 +1,63 @@
|
|
|
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
36
|
export function startStreamableHTTPServer(server) {
|
|
5
37
|
const app = express();
|
|
6
38
|
app.use(express.json());
|
|
7
39
|
// Create auth middleware - when MCP_AUTH_TOKEN is set, requires X-MCP-AUTH header
|
|
8
40
|
const authMiddleware = createAuthMiddleware();
|
|
41
|
+
// DNS rebinding protection is enabled by default. Set DNS_REBINDING_PROTECTION=false to disable.
|
|
42
|
+
const enableDnsRebindingProtection = process.env.DNS_REBINDING_PROTECTION !== "false";
|
|
43
|
+
const host = process.env.HOST || "localhost";
|
|
44
|
+
const parsed = parseInt(process.env.PORT || "3000", 10);
|
|
45
|
+
const port = Number.isNaN(parsed) ? 3000 : parsed;
|
|
46
|
+
const allowedHosts = process.env.DNS_REBINDING_ALLOWED_HOST
|
|
47
|
+
? [process.env.DNS_REBINDING_ALLOWED_HOST]
|
|
48
|
+
: buildDefaultAllowedHosts(host, port);
|
|
49
|
+
// Warn when binding to all interfaces with DNS rebinding protection disabled
|
|
50
|
+
if (!enableDnsRebindingProtection && (host === "0.0.0.0" || host === "::")) {
|
|
51
|
+
console.warn("WARNING: DNS rebinding protection is disabled while HOST is set to " +
|
|
52
|
+
`'${host}'. This exposes the MCP server to DNS rebinding attacks ` +
|
|
53
|
+
"from any browser on the network. Set DNS_REBINDING_PROTECTION=true " +
|
|
54
|
+
"(the default) or restrict HOST to 'localhost' / '127.0.0.1'.");
|
|
55
|
+
}
|
|
9
56
|
app.post("/mcp", authMiddleware, async (req, res) => {
|
|
10
57
|
// In stateless mode, create a new instance of transport and server for each request
|
|
11
58
|
// to ensure complete isolation. A single instance would cause request ID collisions
|
|
12
59
|
// when multiple clients connect concurrently.
|
|
13
60
|
try {
|
|
14
|
-
// DNS rebinding protection is disabled by default for backwards compatibility. If you are running this server
|
|
15
|
-
// locally, make sure to set DNS_REBINDING_PROTECTION=true
|
|
16
|
-
const enableDnsRebindingProtection = process.env.DNS_REBINDING_PROTECTION === "true";
|
|
17
|
-
const allowedHosts = process.env.DNS_REBINDING_ALLOWED_HOST
|
|
18
|
-
? [process.env.DNS_REBINDING_ALLOWED_HOST]
|
|
19
|
-
: ["127.0.0.1"];
|
|
20
61
|
const transport = new StreamableHTTPServerTransport({
|
|
21
62
|
sessionIdGenerator: undefined,
|
|
22
63
|
enableDnsRebindingProtection,
|
|
@@ -91,14 +132,6 @@ export function startStreamableHTTPServer(server) {
|
|
|
91
132
|
});
|
|
92
133
|
}
|
|
93
134
|
});
|
|
94
|
-
let port = 3000;
|
|
95
|
-
try {
|
|
96
|
-
port = parseInt(process.env.PORT || "3000", 10);
|
|
97
|
-
}
|
|
98
|
-
catch (e) {
|
|
99
|
-
console.error("Invalid PORT environment variable, using default port 3000.");
|
|
100
|
-
}
|
|
101
|
-
const host = process.env.HOST || "localhost";
|
|
102
135
|
const httpServer = app.listen(port, host, () => {
|
|
103
136
|
console.log(`mcp-kubernetes-server is listening on port ${port}\nUse the following url to connect to the server:\nhttp://${host}:${port}/mcp`);
|
|
104
137
|
if (isAuthEnabled()) {
|