@zereight/mcp-gitlab 2.1.26 → 2.1.27
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/README.ko.md +1 -0
- package/README.md +6 -1
- package/README.zh-CN.md +1 -0
- package/build/index.js +92 -20
- package/build/test/dynamic-api-url-allowlist.test.js +104 -0
- package/build/test/sse-auth-guard.test.js +96 -0
- package/package.json +2 -2
package/README.ko.md
CHANGED
|
@@ -238,6 +238,7 @@ MCP 클라이언트 설정:
|
|
|
238
238
|
| `REMOTE_AUTHORIZATION` | 예 | 활성화하려면 `true` |
|
|
239
239
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
240
240
|
| `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용 |
|
|
241
|
+
| `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용 |
|
|
241
242
|
|
|
242
243
|
**예시 요청 헤더:**
|
|
243
244
|
|
package/README.md
CHANGED
|
@@ -111,6 +111,7 @@ docker run -i --rm \
|
|
|
111
111
|
-e USE_MILESTONE=true \
|
|
112
112
|
-e USE_PIPELINE=true \
|
|
113
113
|
-e SSE=true \
|
|
114
|
+
-e SSE_AUTH_TOKEN=your_mcp_sse_token \
|
|
114
115
|
-p 3333:3002 \
|
|
115
116
|
zereight050/gitlab-mcp
|
|
116
117
|
```
|
|
@@ -120,7 +121,10 @@ docker run -i --rm \
|
|
|
120
121
|
"mcpServers": {
|
|
121
122
|
"gitlab": {
|
|
122
123
|
"type": "sse",
|
|
123
|
-
"url": "http://localhost:3333/sse"
|
|
124
|
+
"url": "http://localhost:3333/sse",
|
|
125
|
+
"headers": {
|
|
126
|
+
"Authorization": "Bearer your_mcp_sse_token"
|
|
127
|
+
}
|
|
124
128
|
}
|
|
125
129
|
}
|
|
126
130
|
}
|
|
@@ -267,6 +271,7 @@ the token to GitLab on behalf of the caller.
|
|
|
267
271
|
| `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
|
|
268
272
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
269
273
|
| `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
|
|
274
|
+
| `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
|
|
270
275
|
| `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, and `tools/list` only (tool calls still require auth) |
|
|
271
276
|
| `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, OAuth rate limits) |
|
|
272
277
|
|
package/README.zh-CN.md
CHANGED
|
@@ -238,6 +238,7 @@ MCP 客户端配置:
|
|
|
238
238
|
| `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
|
|
239
239
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
240
240
|
| `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
|
|
241
|
+
| `GITLAB_ALLOWED_HOSTS` | 可选 | 逗号分隔的允许主机;`GITLAB_API_URL` 中的主机始终允许 |
|
|
241
242
|
|
|
242
243
|
**示例请求头:**
|
|
243
244
|
|
package/build/index.js
CHANGED
|
@@ -467,6 +467,14 @@ function createServer() {
|
|
|
467
467
|
/**
|
|
468
468
|
* Validate configuration at startup
|
|
469
469
|
*/
|
|
470
|
+
function isLoopbackBindHost(host) {
|
|
471
|
+
const normalized = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
472
|
+
const isIpv4Loopback = /^127(?:\.\d{1,3}){3}$/.test(normalized);
|
|
473
|
+
return (normalized === "localhost" ||
|
|
474
|
+
isIpv4Loopback ||
|
|
475
|
+
normalized === "::1" ||
|
|
476
|
+
normalized === "0:0:0:0:0:0:0:1");
|
|
477
|
+
}
|
|
470
478
|
function validateConfiguration() {
|
|
471
479
|
const errors = [];
|
|
472
480
|
// Validate SESSION_TIMEOUT_SECONDS
|
|
@@ -517,6 +525,12 @@ function validateConfiguration() {
|
|
|
517
525
|
}
|
|
518
526
|
}
|
|
519
527
|
}
|
|
528
|
+
const allowedHosts = getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS")?.split(",") || [];
|
|
529
|
+
for (const host of allowedHosts) {
|
|
530
|
+
if (host.trim() && !toAllowedGitLabApiUrl(host)) {
|
|
531
|
+
errors.push(`GITLAB_ALLOWED_HOSTS contains an invalid host or URL: ${host.trim()}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
520
534
|
// Validate auth configuration
|
|
521
535
|
const remoteAuth = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
522
536
|
const useOAuth = getConfig("use-oauth", "GITLAB_USE_OAUTH") === "true";
|
|
@@ -526,12 +540,19 @@ function validateConfiguration() {
|
|
|
526
540
|
const mcpOAuth = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
527
541
|
const mcpServerUrl = getConfig("mcp-server-url", "MCP_SERVER_URL");
|
|
528
542
|
const streamableHttp = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
543
|
+
const sse = getConfig("sse", "SSE") === "true";
|
|
544
|
+
const bindHost = getConfig("host", "HOST") || "127.0.0.1";
|
|
545
|
+
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
|
|
546
|
+
const allowUnauthenticatedRemoteSse = getConfig("sse-dangerously-allow-unauthenticated-remote", "SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE") === "true";
|
|
529
547
|
if (!remoteAuth && !useOAuth && !hasToken && !hasJobToken && !hasCookie && !mcpOAuth) {
|
|
530
548
|
errors.push("Either --token, --job-token, --cookie-path, --use-oauth=true, --remote-auth=true, or --mcp-oauth=true must be set (or use environment variables)");
|
|
531
549
|
}
|
|
532
550
|
if (streamableHttp && (hasToken || hasJobToken) && !remoteAuth && !mcpOAuth) {
|
|
533
551
|
errors.push("STREAMABLE_HTTP=true/--streamable-http with GITLAB_PERSONAL_ACCESS_TOKEN/--token or GITLAB_JOB_TOKEN/--job-token requires REMOTE_AUTHORIZATION=true/--remote-auth=true or GITLAB_MCP_OAUTH=true/--mcp-oauth=true");
|
|
534
552
|
}
|
|
553
|
+
if (sse && !isLoopbackBindHost(bindHost) && !sseAuthToken && !allowUnauthenticatedRemoteSse) {
|
|
554
|
+
errors.push("SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)");
|
|
555
|
+
}
|
|
535
556
|
if (mcpOAuth) {
|
|
536
557
|
if (!mcpServerUrl) {
|
|
537
558
|
errors.push("MCP_SERVER_URL is required when GITLAB_MCP_OAUTH=true (e.g. https://mcp.example.com)");
|
|
@@ -986,11 +1007,57 @@ if (GITLAB_TOOLSETS_RAW && (USE_PIPELINE || USE_MILESTONE || USE_GITLAB_WIKI)) {
|
|
|
986
1007
|
"Legacy flags add tools additively on top of the toolset selection and may produce unexpected results.");
|
|
987
1008
|
}
|
|
988
1009
|
const MERGE_REQUEST_DEPLOYMENT_SUMMARY_LIMIT = 10;
|
|
1010
|
+
function toAllowedGitLabApiUrl(value) {
|
|
1011
|
+
const trimmed = value.trim();
|
|
1012
|
+
if (!trimmed)
|
|
1013
|
+
return null;
|
|
1014
|
+
try {
|
|
1015
|
+
const url = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`);
|
|
1016
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
1017
|
+
return null;
|
|
1018
|
+
return { host: url.host, apiUrl: normalizeGitLabApiUrl(url.toString()) };
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
function parseAllowedGitLabApiUrls(value) {
|
|
1025
|
+
return value
|
|
1026
|
+
.split(",")
|
|
1027
|
+
.map(toAllowedGitLabApiUrl)
|
|
1028
|
+
.filter((entry) => Boolean(entry));
|
|
1029
|
+
}
|
|
1030
|
+
function encodeGitLabPathSegment(value) {
|
|
1031
|
+
return encodeURIComponent(decodeURIComponent(value));
|
|
1032
|
+
}
|
|
1033
|
+
function encodeGitLabPath(value) {
|
|
1034
|
+
return value.split("/").map(encodeGitLabPathSegment).join("/");
|
|
1035
|
+
}
|
|
1036
|
+
function resolveTrustedGitLabApiUrl(value) {
|
|
1037
|
+
const parsed = new URL(normalizeGitLabApiUrl(value));
|
|
1038
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1039
|
+
throw new Error("GitLab API URL must use HTTP or HTTPS");
|
|
1040
|
+
}
|
|
1041
|
+
const allowedApiUrl = GITLAB_ALLOWED_API_URLS_BY_HOST.get(parsed.host);
|
|
1042
|
+
if (!allowedApiUrl) {
|
|
1043
|
+
throw new Error(`GitLab API URL host is not allowed: ${parsed.host}`);
|
|
1044
|
+
}
|
|
1045
|
+
return allowedApiUrl;
|
|
1046
|
+
}
|
|
989
1047
|
// Use the normalizeGitLabApiUrl function to handle various URL formats
|
|
990
1048
|
const GITLAB_API_URLS = (getConfig("api-url", "GITLAB_API_URL") || "https://gitlab.com")
|
|
991
1049
|
.split(",")
|
|
992
1050
|
.map(normalizeGitLabApiUrl);
|
|
993
1051
|
const GITLAB_API_URL = GITLAB_API_URLS[0];
|
|
1052
|
+
const GITLAB_ALLOWED_API_URLS_BY_HOST = new Map();
|
|
1053
|
+
for (const { host, apiUrl } of [
|
|
1054
|
+
...GITLAB_API_URLS.map(toAllowedGitLabApiUrl).filter((entry) => Boolean(entry)),
|
|
1055
|
+
...parseAllowedGitLabApiUrls(getConfig("allowed-hosts", "GITLAB_ALLOWED_HOSTS") || ""),
|
|
1056
|
+
]) {
|
|
1057
|
+
if (!GITLAB_ALLOWED_API_URLS_BY_HOST.has(host)) {
|
|
1058
|
+
GITLAB_ALLOWED_API_URLS_BY_HOST.set(host, apiUrl);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
994
1061
|
const GITLAB_PROJECT_ID = process.env.GITLAB_PROJECT_ID;
|
|
995
1062
|
const GITLAB_ALLOWED_PROJECT_IDS = process.env.GITLAB_ALLOWED_PROJECT_IDS?.split(",")
|
|
996
1063
|
.map(id => id.trim())
|
|
@@ -8844,18 +8911,15 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8844
8911
|
return;
|
|
8845
8912
|
}
|
|
8846
8913
|
// API URL: prefer token-embedded URL, then X-GitLab-API-URL header, then default
|
|
8847
|
-
let apiUrl =
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
8851
|
-
|
|
8852
|
-
|
|
8853
|
-
|
|
8854
|
-
}
|
|
8855
|
-
|
|
8856
|
-
res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
|
|
8857
|
-
return;
|
|
8858
|
-
}
|
|
8914
|
+
let apiUrl = GITLAB_API_URL;
|
|
8915
|
+
const requestedApiUrl = tokenApiUrl || req.headers["x-gitlab-api-url"]?.trim();
|
|
8916
|
+
if (ENABLE_DYNAMIC_API_URL && requestedApiUrl) {
|
|
8917
|
+
try {
|
|
8918
|
+
apiUrl = resolveTrustedGitLabApiUrl(requestedApiUrl);
|
|
8919
|
+
}
|
|
8920
|
+
catch {
|
|
8921
|
+
res.status(400).json({ error: "Invalid X-GitLab-API-URL" });
|
|
8922
|
+
return;
|
|
8859
8923
|
}
|
|
8860
8924
|
}
|
|
8861
8925
|
const { type } = req.params;
|
|
@@ -8869,7 +8933,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8869
8933
|
return;
|
|
8870
8934
|
}
|
|
8871
8935
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8872
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${job_id}/artifacts`;
|
|
8936
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(job_id)}/artifacts`;
|
|
8873
8937
|
break;
|
|
8874
8938
|
}
|
|
8875
8939
|
case "attachment": {
|
|
@@ -8879,7 +8943,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8879
8943
|
return;
|
|
8880
8944
|
}
|
|
8881
8945
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8882
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${secret}/${filename}`;
|
|
8946
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPath(filename)}`;
|
|
8883
8947
|
break;
|
|
8884
8948
|
}
|
|
8885
8949
|
case "release-asset": {
|
|
@@ -8891,7 +8955,7 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8891
8955
|
return;
|
|
8892
8956
|
}
|
|
8893
8957
|
const effectiveProjectId = getEffectiveProjectId(decodeURIComponent(project_id));
|
|
8894
|
-
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${direct_asset_path}`;
|
|
8958
|
+
gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${encodeGitLabPath(direct_asset_path)}`;
|
|
8895
8959
|
break;
|
|
8896
8960
|
}
|
|
8897
8961
|
default:
|
|
@@ -8943,12 +9007,21 @@ function registerDownloadProxy(app, maxRequestsPerMinute = Number.parseInt(proce
|
|
|
8943
9007
|
*/
|
|
8944
9008
|
async function startSSEServer() {
|
|
8945
9009
|
const app = express();
|
|
9010
|
+
const sseAuthToken = getConfig("sse-auth-token", "SSE_AUTH_TOKEN");
|
|
8946
9011
|
if (MCP_TRUST_PROXY) {
|
|
8947
9012
|
app.set("trust proxy", 1);
|
|
8948
9013
|
}
|
|
9014
|
+
const requireSseAuth = (req, res, next) => {
|
|
9015
|
+
if (!sseAuthToken)
|
|
9016
|
+
return next();
|
|
9017
|
+
const match = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || "");
|
|
9018
|
+
if (match?.[1] === sseAuthToken)
|
|
9019
|
+
return next();
|
|
9020
|
+
res.status(401).json({ error: "SSE authentication required" });
|
|
9021
|
+
};
|
|
8949
9022
|
const transports = {};
|
|
8950
9023
|
let shuttingDown = false;
|
|
8951
|
-
app.get("/sse", async (_, res) => {
|
|
9024
|
+
app.get("/sse", requireSseAuth, async (_, res) => {
|
|
8952
9025
|
const serverInstance = createServer();
|
|
8953
9026
|
const transport = new SSEServerTransport("/messages", res);
|
|
8954
9027
|
transports[transport.sessionId] = transport;
|
|
@@ -8957,7 +9030,7 @@ async function startSSEServer() {
|
|
|
8957
9030
|
});
|
|
8958
9031
|
await serverInstance.connect(transport);
|
|
8959
9032
|
});
|
|
8960
|
-
app.post("/messages", async (req, res) => {
|
|
9033
|
+
app.post("/messages", requireSseAuth, async (req, res) => {
|
|
8961
9034
|
const sessionId = req.query.sessionId;
|
|
8962
9035
|
const transport = transports[sessionId];
|
|
8963
9036
|
if (transport) {
|
|
@@ -9081,12 +9154,11 @@ async function startStreamableHTTPServer() {
|
|
|
9081
9154
|
// Only process dynamic URL if the feature is enabled
|
|
9082
9155
|
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
|
|
9083
9156
|
try {
|
|
9084
|
-
|
|
9085
|
-
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
|
|
9157
|
+
apiUrl = resolveTrustedGitLabApiUrl(dynamicApiUrl);
|
|
9086
9158
|
}
|
|
9087
9159
|
catch {
|
|
9088
9160
|
logger.warn(`Invalid X-GitLab-API-URL provided: ${dynamicApiUrl}. Auth will fail.`);
|
|
9089
|
-
return null; // Reject if URL is malformed
|
|
9161
|
+
return null; // Reject if URL is malformed or not allowed
|
|
9090
9162
|
}
|
|
9091
9163
|
}
|
|
9092
9164
|
// Extract token — priority: Private-Token > JOB-TOKEN > Authorization Bearer
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { after, before, describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
|
|
5
|
+
import { MockGitLabServer, findMockServerPort } from "./utils/mock-gitlab-server.js";
|
|
6
|
+
import { CustomHeaderClient } from "./clients/custom-header-client.js";
|
|
7
|
+
const MOCK_TOKEN = "glpat-dynamic-url-token";
|
|
8
|
+
async function startAttackerServer(port) {
|
|
9
|
+
let hits = 0;
|
|
10
|
+
const server = createServer((_req, res) => {
|
|
11
|
+
hits++;
|
|
12
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
13
|
+
res.end("[]");
|
|
14
|
+
});
|
|
15
|
+
await new Promise((resolve, reject) => {
|
|
16
|
+
server.once("error", reject);
|
|
17
|
+
server.listen(port, HOST, () => {
|
|
18
|
+
server.off("error", reject);
|
|
19
|
+
resolve();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
return { server, getHits: () => hits };
|
|
23
|
+
}
|
|
24
|
+
describe("Dynamic API URL allowlist", () => {
|
|
25
|
+
let primaryGitLab;
|
|
26
|
+
let secondaryGitLab;
|
|
27
|
+
let attackerServer;
|
|
28
|
+
let mcpServer;
|
|
29
|
+
let mcpUrl;
|
|
30
|
+
let secondaryHit = false;
|
|
31
|
+
let getAttackerHits = () => 0;
|
|
32
|
+
before(async () => {
|
|
33
|
+
const primaryPort = await findMockServerPort(9100);
|
|
34
|
+
primaryGitLab = new MockGitLabServer({ port: primaryPort, validTokens: [MOCK_TOKEN] });
|
|
35
|
+
await primaryGitLab.start();
|
|
36
|
+
const secondaryPort = await findMockServerPort(9200);
|
|
37
|
+
secondaryGitLab = new MockGitLabServer({ port: secondaryPort, validTokens: [MOCK_TOKEN] });
|
|
38
|
+
secondaryGitLab.addMockHandler("get", "/projects/1/issues", (_req, res) => {
|
|
39
|
+
secondaryHit = true;
|
|
40
|
+
res.json([]);
|
|
41
|
+
});
|
|
42
|
+
await secondaryGitLab.start();
|
|
43
|
+
const attackerPort = await findAvailablePort(9300);
|
|
44
|
+
const attacker = await startAttackerServer(attackerPort);
|
|
45
|
+
attackerServer = attacker.server;
|
|
46
|
+
getAttackerHits = attacker.getHits;
|
|
47
|
+
const mcpPort = await findAvailablePort(3100);
|
|
48
|
+
mcpServer = await launchServer({
|
|
49
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
50
|
+
port: mcpPort,
|
|
51
|
+
timeout: 5000,
|
|
52
|
+
env: {
|
|
53
|
+
STREAMABLE_HTTP: "true",
|
|
54
|
+
REMOTE_AUTHORIZATION: "true",
|
|
55
|
+
ENABLE_DYNAMIC_API_URL: "true",
|
|
56
|
+
GITLAB_API_URL: `${primaryGitLab.getUrl()}/api/v4`,
|
|
57
|
+
GITLAB_ALLOWED_HOSTS: `${secondaryGitLab.getUrl()}/api/v4`,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
mcpUrl = `http://${HOST}:${mcpPort}/mcp`;
|
|
61
|
+
});
|
|
62
|
+
after(async () => {
|
|
63
|
+
if (mcpServer)
|
|
64
|
+
cleanupServers([mcpServer]);
|
|
65
|
+
await primaryGitLab?.stop();
|
|
66
|
+
await secondaryGitLab?.stop();
|
|
67
|
+
const server = attackerServer;
|
|
68
|
+
if (server)
|
|
69
|
+
await new Promise(resolve => server.close(() => resolve()));
|
|
70
|
+
});
|
|
71
|
+
test("allows dynamic API URLs on configured hosts", async () => {
|
|
72
|
+
const client = new CustomHeaderClient({
|
|
73
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
74
|
+
"x-gitlab-api-url": `${secondaryGitLab.getUrl()}/api/v4`,
|
|
75
|
+
});
|
|
76
|
+
await client.connect(mcpUrl);
|
|
77
|
+
await client.callTool("list_issues", { project_id: "1" });
|
|
78
|
+
await client.disconnect();
|
|
79
|
+
assert.strictEqual(secondaryHit, true, "allowed dynamic host should receive GitLab calls");
|
|
80
|
+
});
|
|
81
|
+
test("rejects dynamic API URLs on unconfigured hosts before forwarding tokens", async () => {
|
|
82
|
+
const server = attackerServer;
|
|
83
|
+
assert.ok(server, "attacker server should be running");
|
|
84
|
+
const attackerUrl = `http://${HOST}:${server.address().port}/api/v4`;
|
|
85
|
+
const client = new CustomHeaderClient({
|
|
86
|
+
authorization: `Bearer ${MOCK_TOKEN}`,
|
|
87
|
+
"x-gitlab-api-url": attackerUrl,
|
|
88
|
+
});
|
|
89
|
+
let connected = false;
|
|
90
|
+
try {
|
|
91
|
+
await client.connect(mcpUrl);
|
|
92
|
+
connected = true;
|
|
93
|
+
await client.callTool("list_issues", { project_id: "1" });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Expected: the session is rejected before any GitLab API request is made.
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await client.disconnect();
|
|
100
|
+
}
|
|
101
|
+
assert.strictEqual(connected, false, "untrusted dynamic host should not initialize a session");
|
|
102
|
+
assert.strictEqual(getAttackerHits(), 0, "token-bearing requests must not reach untrusted hosts");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { afterEach, describe, test } from "node:test";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { findAvailablePort } from "./utils/server-launcher.js";
|
|
7
|
+
const ERROR_MESSAGE = "SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN (or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)";
|
|
8
|
+
const LOOPBACK = "127.0.0.1";
|
|
9
|
+
const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
|
|
10
|
+
const running = new Set();
|
|
11
|
+
function startSseServer(env, port) {
|
|
12
|
+
const child = spawn("node", [SERVER_PATH], {
|
|
13
|
+
env: {
|
|
14
|
+
...process.env,
|
|
15
|
+
GITLAB_API_URL: "https://gitlab.example.com/api/v4",
|
|
16
|
+
HOST: "0.0.0.0",
|
|
17
|
+
PORT: String(port),
|
|
18
|
+
SSE: "true",
|
|
19
|
+
STREAMABLE_HTTP: "false",
|
|
20
|
+
REMOTE_AUTHORIZATION: "false",
|
|
21
|
+
GITLAB_MCP_OAUTH: "false",
|
|
22
|
+
GITLAB_USE_OAUTH: "false",
|
|
23
|
+
GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
|
|
24
|
+
GITLAB_JOB_TOKEN: "",
|
|
25
|
+
GITLAB_AUTH_COOKIE_PATH: "",
|
|
26
|
+
...env,
|
|
27
|
+
},
|
|
28
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
29
|
+
});
|
|
30
|
+
running.add(child);
|
|
31
|
+
child.once("exit", () => running.delete(child));
|
|
32
|
+
return child;
|
|
33
|
+
}
|
|
34
|
+
async function waitForExit(child, timeoutMs = 5000) {
|
|
35
|
+
let output = "";
|
|
36
|
+
child.stdout?.on("data", chunk => {
|
|
37
|
+
output += chunk.toString();
|
|
38
|
+
});
|
|
39
|
+
child.stderr?.on("data", chunk => {
|
|
40
|
+
output += chunk.toString();
|
|
41
|
+
});
|
|
42
|
+
let timeoutHandle;
|
|
43
|
+
const timeout = new Promise((_, reject) => {
|
|
44
|
+
timeoutHandle = setTimeout(() => reject(new Error(`server did not exit within ${timeoutMs}ms`)), timeoutMs);
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
const [code] = (await Promise.race([once(child, "exit"), timeout]));
|
|
48
|
+
return { code, output };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
clearTimeout(timeoutHandle);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function waitForHealth(port, timeoutMs = 5000) {
|
|
55
|
+
const deadline = Date.now() + timeoutMs;
|
|
56
|
+
let lastError;
|
|
57
|
+
while (Date.now() < deadline) {
|
|
58
|
+
try {
|
|
59
|
+
const response = await fetch(`http://${LOOPBACK}:${port}/health`);
|
|
60
|
+
if (response.ok)
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
lastError = error;
|
|
65
|
+
}
|
|
66
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`server did not become healthy: ${String(lastError)}`);
|
|
69
|
+
}
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
for (const child of running) {
|
|
72
|
+
if (!child.killed)
|
|
73
|
+
child.kill("SIGTERM");
|
|
74
|
+
}
|
|
75
|
+
running.clear();
|
|
76
|
+
});
|
|
77
|
+
describe("SSE remote auth guard", () => {
|
|
78
|
+
test("refuses unauthenticated SSE on non-loopback hosts", async () => {
|
|
79
|
+
const port = await findAvailablePort(4400);
|
|
80
|
+
const child = startSseServer({}, port);
|
|
81
|
+
const { code, output } = await waitForExit(child);
|
|
82
|
+
assert.notEqual(code, 0);
|
|
83
|
+
assert.match(output, new RegExp(ERROR_MESSAGE.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
|
84
|
+
});
|
|
85
|
+
test("requires bearer auth on SSE endpoints when SSE_AUTH_TOKEN is configured", async () => {
|
|
86
|
+
const port = await findAvailablePort(4410);
|
|
87
|
+
startSseServer({ SSE_AUTH_TOKEN: "mcp_sse_secret" }, port);
|
|
88
|
+
await waitForHealth(port);
|
|
89
|
+
const sseResponse = await fetch(`http://${LOOPBACK}:${port}/sse`);
|
|
90
|
+
assert.equal(sseResponse.status, 401);
|
|
91
|
+
const unauthenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST" });
|
|
92
|
+
assert.equal(unauthenticatedMessage.status, 401);
|
|
93
|
+
const authenticatedMessage = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=missing`, { method: "POST", headers: { Authorization: "Bearer mcp_sse_secret" } });
|
|
94
|
+
assert.equal(authenticatedMessage.status, 400);
|
|
95
|
+
});
|
|
96
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.27",
|
|
4
4
|
"mcpName": "io.github.zereight/gitlab-mcp",
|
|
5
5
|
"description": "GitLab MCP server for projects, merge requests, issues, pipelines, wiki, releases, and more",
|
|
6
6
|
"keywords": [
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"changelog": "auto-changelog -p",
|
|
52
52
|
"test": "npm run test:all",
|
|
53
53
|
"test:all": "npm run build && npm run test:mock && npm run test:live",
|
|
54
|
-
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
54
|
+
"test:mock": "node --import tsx/esm --test test/remote-auth-simple-test.ts && node --import tsx/esm --test test/dynamic-api-url-allowlist.test.ts && node --import tsx/esm --test test/mcp-oauth-tests.ts && node --import tsx/esm --test test/test-oauth-proxy-rate-limit.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.test.ts && node --import tsx/esm --test test/sse-auth-guard.test.ts && node --import tsx/esm --test test/streamable-http-concurrent-session.test.ts && node --import tsx/esm --test test/streamable-http-unauthenticated-discovery.test.ts && tsx test/oauth-tests.ts && tsx test/test-list-merge-requests.ts && tsx test/test-list-issues.ts && node --import tsx/esm --test test/test-create-repository.ts && node --import tsx/esm --test test/test-update-project.ts && node --import tsx/esm --test test/test-merge-request-pipelines.ts && tsx test/test-list-project-members.ts && tsx test/test-download-attachment.ts && node --import tsx/esm --test test/test-upload-markdown.ts && node --import tsx/esm --test test/test-job-artifacts.ts && node --import tsx/esm --test test/test-remote-downloads.ts && node --import tsx/esm --test test/test-deployment-tools.ts && node --import tsx/esm --test test/test-merge-request-approval-state-tools.ts && node --import tsx/esm --test test/test-search-code.ts && node --import tsx/esm --test test/test-tags.ts && node --import tsx/esm --test test/test-protected-branches.ts && node --import tsx/esm --test test/test-toolset-filtering.ts && node --import tsx/esm --test test/test-ci-lint.ts && node --import tsx/esm --test test/test-ci-catalog.ts && node --import tsx/esm --test test/test-todos.ts && node --import tsx/esm --test test/test-auth-retry.ts && node --import tsx/esm --test test/test-issue-description-patch.ts && node --import tsx/esm --test test/test-geteffectiveprojectid.ts && node --import tsx/esm --test test/test-get-file-blame.ts && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts && node --import tsx/esm --test test/utils/tool-args.test.ts && node --import tsx/esm --test test/utils/proxy-client-ip.test.ts && node --import tsx/esm --test test/utils/forwarded-public-base-url.test.ts && node --import tsx/esm --test test/utils/graphql-query.test.ts && node --import tsx/esm --test test/utils/wiki-title.test.ts && node --import tsx/esm --test test/utils/merge-request-position.test.ts && node --import tsx/esm --test test/nullish-tool-arguments-schema.test.ts && node --import tsx/esm --test test/test-ci-variables.ts && node --import tsx/esm --test test/test-dependency-proxy.ts",
|
|
55
55
|
"test:stateless": "npm run build && node --import tsx/esm --test test/stateless/codec.test.ts test/stateless/client-id.test.ts test/stateless/callback-proxy.test.ts test/stateless/session-id.test.ts test/stateless/session-id-integration.test.ts test/stateless/config-ttl.test.ts",
|
|
56
56
|
"test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
|
|
57
57
|
"test:live": "node test/validate-api.js",
|