@zereight/mcp-gitlab 2.1.21 → 2.1.22
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 -1
- package/README.md +9 -1
- package/README.zh-CN.md +1 -1
- package/build/config.js +8 -2
- package/build/index.js +98 -18
- package/build/test/config-allowed-groups.test.js +97 -0
- package/build/test/test-remote-downloads.js +162 -1
- package/package.json +2 -2
package/README.ko.md
CHANGED
|
@@ -187,7 +187,7 @@ MCP 서버가 직접 로컬 브라우저 callback을 받을 때만 `GITLAB_OAUTH
|
|
|
187
187
|
| `STREAMABLE_HTTP` | 예 | 반드시 `true` |
|
|
188
188
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | 선택 | MCP 서버의 고정 `/callback` URL을 사용하려면 `true` |
|
|
189
189
|
| `GITLAB_OAUTH_SCOPES` | 선택 | 쉼표로 구분된 scope 목록(기본값: `api,read_api,read_user`) |
|
|
190
|
-
| `
|
|
190
|
+
| `GITLAB_OAUTH_ALLOWED_GROUPS` | 선택 | 쉼표로 구분된 GitLab 그룹 전체 경로 — 해당 그룹 및 하위 그룹 멤버만 토큰을 발급받을 수 있음 (기존 `GITLAB_ALLOWED_GROUPS` 대체) |
|
|
191
191
|
|
|
192
192
|
> **`Unregistered redirect_uri` 문제 해결**
|
|
193
193
|
>
|
package/README.md
CHANGED
|
@@ -208,7 +208,7 @@ exchanging credentials with GitLab on behalf of the client.
|
|
|
208
208
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
209
209
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | optional | Set to `true` to use the MCP server's fixed `/callback` URL |
|
|
210
210
|
| `GITLAB_OAUTH_SCOPES` | optional | Comma-separated scopes (default: `api,read_api,read_user`) |
|
|
211
|
-
| `
|
|
211
|
+
| `GITLAB_OAUTH_ALLOWED_GROUPS` | optional | Comma-separated group full paths — only members (and subgroup members) may obtain a token (replaces deprecated `GITLAB_ALLOWED_GROUPS`) |
|
|
212
212
|
|
|
213
213
|
When `STREAMABLE_HTTP=true`, server-side `GITLAB_PERSONAL_ACCESS_TOKEN` or `GITLAB_JOB_TOKEN` require `REMOTE_AUTHORIZATION=true` or `GITLAB_MCP_OAUTH=true`.
|
|
214
214
|
|
|
@@ -265,6 +265,13 @@ the token to GitLab on behalf of the caller.
|
|
|
265
265
|
| `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
|
|
266
266
|
| `STREAMABLE_HTTP` | ✅ | Must be `true` |
|
|
267
267
|
| `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
|
|
268
|
+
| `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers for public download URLs when deployed behind a trusted reverse proxy |
|
|
269
|
+
|
|
270
|
+
When `MCP_SERVER_URL` is not set, remote download URLs fall back to the local
|
|
271
|
+
server address. Set `MCP_TRUST_PROXY=true` only if the server is reachable through a
|
|
272
|
+
trusted reverse proxy and direct client access to the MCP server is blocked.
|
|
273
|
+
This lets the server derive public download URLs from `Forwarded` /
|
|
274
|
+
`X-Forwarded-Proto`, `X-Forwarded-Host`, and `X-Forwarded-Prefix`.
|
|
268
275
|
|
|
269
276
|
**Example request headers**:
|
|
270
277
|
|
|
@@ -299,6 +306,7 @@ Commonly referenced variables:
|
|
|
299
306
|
- `GITLAB_PERSONAL_ACCESS_TOKEN`
|
|
300
307
|
- `GITLAB_USE_OAUTH`
|
|
301
308
|
- `REMOTE_AUTHORIZATION`
|
|
309
|
+
- `MCP_TRUST_PROXY`
|
|
302
310
|
- `GITLAB_MCP_OAUTH`
|
|
303
311
|
- `GITLAB_OAUTH_CALLBACK_PROXY`
|
|
304
312
|
- `OAUTH_STATELESS_MODE`
|
package/README.zh-CN.md
CHANGED
|
@@ -187,7 +187,7 @@ OpenCode、MCPJam、Claude.ai 等远程 MCP 客户端可能会在授权时发送
|
|
|
187
187
|
| `STREAMABLE_HTTP` | 是 | 必须为 `true` |
|
|
188
188
|
| `GITLAB_OAUTH_CALLBACK_PROXY` | 可选 | 设置为 `true` 时使用 MCP 服务器固定的 `/callback` URL |
|
|
189
189
|
| `GITLAB_OAUTH_SCOPES` | 可选 | 逗号分隔的 scope(默认:`api,read_api,read_user`) |
|
|
190
|
-
| `
|
|
190
|
+
| `GITLAB_OAUTH_ALLOWED_GROUPS` | 可选 | 逗号分隔的 GitLab 群组完整路径 — 仅该群组及其子群组的成员可获取令牌(替代已废弃的 `GITLAB_ALLOWED_GROUPS`)|
|
|
191
191
|
|
|
192
192
|
> **排查 `Unregistered redirect_uri`**
|
|
193
193
|
>
|
package/build/config.js
CHANGED
|
@@ -47,6 +47,7 @@ export const SSE = getConfig("sse", "SSE") === "true";
|
|
|
47
47
|
export const STREAMABLE_HTTP = getConfig("streamable-http", "STREAMABLE_HTTP") === "true";
|
|
48
48
|
export const REMOTE_AUTHORIZATION = getConfig("remote-auth", "REMOTE_AUTHORIZATION") === "true";
|
|
49
49
|
export const GITLAB_MCP_OAUTH = getConfig("mcp-oauth", "GITLAB_MCP_OAUTH") === "true";
|
|
50
|
+
export const MCP_TRUST_PROXY = getConfig("mcp-trust-proxy", "MCP_TRUST_PROXY") === "true";
|
|
50
51
|
// ---------------------------------------------------------------------------
|
|
51
52
|
// OAuth / MCP OAuth
|
|
52
53
|
// ---------------------------------------------------------------------------
|
|
@@ -57,8 +58,13 @@ export const GITLAB_OAUTH_SCOPES = GITLAB_OAUTH_SCOPES_RAW
|
|
|
57
58
|
? GITLAB_OAUTH_SCOPES_RAW.split(",").map((s) => s.trim()).filter(Boolean)
|
|
58
59
|
: undefined;
|
|
59
60
|
export const GITLAB_OAUTH_CALLBACK_PROXY = getConfig("oauth-callback-proxy", "GITLAB_OAUTH_CALLBACK_PROXY") === "true";
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
/** @deprecated Use GITLAB_OAUTH_ALLOWED_GROUPS_RAW instead. Will be removed in the next major version. */
|
|
62
|
+
export const GITLAB_ALLOWED_GROUPS_RAW = getConfig("allowed-groups", "GITLAB_ALLOWED_GROUPS");
|
|
63
|
+
export const GITLAB_OAUTH_ALLOWED_GROUPS_RAW = getConfig("oauth-allowed-groups", "GITLAB_OAUTH_ALLOWED_GROUPS");
|
|
64
|
+
export const GITLAB_OAUTH_ALLOWED_GROUPS = (() => {
|
|
65
|
+
const newVar = GITLAB_OAUTH_ALLOWED_GROUPS_RAW;
|
|
66
|
+
const oldVar = GITLAB_ALLOWED_GROUPS_RAW;
|
|
67
|
+
const raw = newVar ?? oldVar;
|
|
62
68
|
if (!raw)
|
|
63
69
|
return undefined;
|
|
64
70
|
const groups = raw.split(",").map((g) => g.trim()).filter(Boolean);
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW,
|
|
2
|
+
import { getConfig, ENABLE_DYNAMIC_API_URL, GITLAB_AUTH_COOKIE_PATH, GITLAB_CA_CERT_PATH, GITLAB_JOB_TOKEN, GITLAB_MCP_OAUTH, GITLAB_OAUTH_APP_ID, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_CALLBACK_PROXY, GITLAB_PERSONAL_ACCESS_TOKEN, GITLAB_POOL_MAX_SIZE, GITLAB_READ_ONLY_MODE, GITLAB_TOOLSETS_RAW, GITLAB_TOOLS_RAW, HOST, HTTP_PROXY, HTTPS_PROXY, IS_OLD, MCP_SERVER_URL, NODE_TLS_REJECT_UNAUTHORIZED, NO_PROXY, OAUTH_STATELESS_CLIENT_TTL_SECONDS, OAUTH_STATELESS_MODE, OAUTH_STATELESS_PENDING_TTL_SECONDS, OAUTH_STATELESS_SESSION_TTL_SECONDS, OAUTH_STATELESS_STORED_TTL_SECONDS, PORT, REMOTE_AUTHORIZATION, SESSION_TIMEOUT_SECONDS, SSE, STREAMABLE_HTTP, MCP_TRUST_PROXY, USE_GITLAB_WIKI, USE_MILESTONE, USE_OAUTH, USE_PIPELINE, GITLAB_TOOL_POLICY_APPROVE_RAW, GITLAB_TOOL_POLICY_HIDDEN_RAW, GITLAB_OAUTH_ALLOWED_GROUPS_RAW, GITLAB_ALLOWED_GROUPS_RAW, GITLAB_OAUTH_ALLOWED_GROUPS, } from "./config.js";
|
|
3
3
|
/** True when the server is running in remote/network mode (SSE or StreamableHTTP transport). */
|
|
4
4
|
const IS_REMOTE = SSE || STREAMABLE_HTTP;
|
|
5
5
|
/**
|
|
@@ -59,17 +59,74 @@ function decryptDownloadToken(tokenStr) {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
function getLastHeaderValue(value) {
|
|
63
|
+
const raw = Array.isArray(value) ? value[value.length - 1] : value;
|
|
64
|
+
if (!raw)
|
|
65
|
+
return undefined;
|
|
66
|
+
const parts = raw.split(",").map(part => part.trim()).filter(Boolean);
|
|
67
|
+
return parts.length > 0 ? parts[parts.length - 1] : undefined;
|
|
68
|
+
}
|
|
69
|
+
function unquoteHeaderValue(value) {
|
|
70
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
71
|
+
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
function getForwardedPublicBaseUrl(req) {
|
|
76
|
+
if (!MCP_TRUST_PROXY)
|
|
77
|
+
return undefined;
|
|
78
|
+
const forwarded = getLastHeaderValue(req.headers.forwarded);
|
|
79
|
+
const forwardedValues = {};
|
|
80
|
+
if (forwarded) {
|
|
81
|
+
for (const part of forwarded.split(";")) {
|
|
82
|
+
const separator = part.indexOf("=");
|
|
83
|
+
if (separator <= 0)
|
|
84
|
+
continue;
|
|
85
|
+
const key = part.slice(0, separator).trim().toLowerCase();
|
|
86
|
+
const value = unquoteHeaderValue(part.slice(separator + 1).trim());
|
|
87
|
+
if (key && value)
|
|
88
|
+
forwardedValues[key] = value;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const proto = (forwardedValues.proto || getLastHeaderValue(req.headers["x-forwarded-proto"]))?.toLowerCase();
|
|
92
|
+
const host = forwardedValues.host || getLastHeaderValue(req.headers["x-forwarded-host"]);
|
|
93
|
+
if (!proto || !host || !/^https?$/.test(proto))
|
|
94
|
+
return undefined;
|
|
95
|
+
if (/[\s/\\]/.test(host))
|
|
96
|
+
return undefined;
|
|
97
|
+
const prefix = getLastHeaderValue(req.headers["x-forwarded-prefix"]);
|
|
98
|
+
const safePrefix = prefix &&
|
|
99
|
+
prefix.startsWith("/") &&
|
|
100
|
+
!prefix.startsWith("//") &&
|
|
101
|
+
!prefix.includes("://") &&
|
|
102
|
+
!/[\s\\]/.test(prefix)
|
|
103
|
+
? prefix.replace(/\/+$/, "")
|
|
104
|
+
: undefined;
|
|
105
|
+
try {
|
|
106
|
+
const baseUrl = new URL(`${proto}://${host}`);
|
|
107
|
+
if (baseUrl.username || baseUrl.password || baseUrl.pathname !== "/" || baseUrl.search || baseUrl.hash) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
if (safePrefix)
|
|
111
|
+
baseUrl.pathname = safePrefix;
|
|
112
|
+
return baseUrl.toString().replace(/\/$/, "");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
62
118
|
/**
|
|
63
119
|
* Build a URL pointing to the download proxy endpoint.
|
|
64
120
|
* Embeds an encrypted auth token (and API URL for dynamic routing)
|
|
65
121
|
* from the current session so the URL works standalone.
|
|
66
122
|
*/
|
|
67
123
|
function buildDownloadUrl(type, params) {
|
|
68
|
-
const base = MCP_SERVER_URL || `http://${HOST}:${PORT}`;
|
|
124
|
+
const base = MCP_SERVER_URL || sessionAuthStore.getStore()?.publicBaseUrl || `http://${HOST}:${PORT}`;
|
|
69
125
|
const baseUrl = new URL(base);
|
|
70
126
|
// Preserve any path prefix (e.g. /gitlab-mcp) from the base URL
|
|
71
127
|
const basePath = baseUrl.pathname.replace(/\/+$/, "");
|
|
72
|
-
const
|
|
128
|
+
const safeBasePath = basePath ? `/${basePath.replace(/^\/+/, "")}` : "";
|
|
129
|
+
const url = new URL(`${safeBasePath}/downloads/${type}`, baseUrl.origin);
|
|
73
130
|
for (const [key, value] of Object.entries(params)) {
|
|
74
131
|
url.searchParams.set(key, value);
|
|
75
132
|
}
|
|
@@ -429,6 +486,7 @@ function createServer() {
|
|
|
429
486
|
token: authData.token,
|
|
430
487
|
lastUsed: authData.lastUsed,
|
|
431
488
|
apiUrl: authData.apiUrl,
|
|
489
|
+
publicBaseUrl: authData.publicBaseUrl,
|
|
432
490
|
};
|
|
433
491
|
// Run the handler within the retrieved context
|
|
434
492
|
const result = await sessionAuthStore.run(sessionContext, () => handleToolCall(request.params));
|
|
@@ -856,6 +914,15 @@ const sessionAuthStore = new AsyncLocalStorage();
|
|
|
856
914
|
// Session context map for storing auth data by session ID
|
|
857
915
|
// This survives async boundaries where AsyncLocalStorage might not
|
|
858
916
|
const authBySession = {};
|
|
917
|
+
function withPublicBaseUrl(authData, publicBaseUrl, previous) {
|
|
918
|
+
const effectivePublicBaseUrl = publicBaseUrl || previous?.publicBaseUrl;
|
|
919
|
+
return effectivePublicBaseUrl ? { ...authData, publicBaseUrl: effectivePublicBaseUrl } : authData;
|
|
920
|
+
}
|
|
921
|
+
function updateSessionPublicBaseUrl(sessionId, publicBaseUrl) {
|
|
922
|
+
if (sessionId && publicBaseUrl && authBySession[sessionId]) {
|
|
923
|
+
authBySession[sessionId].publicBaseUrl = publicBaseUrl;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
859
926
|
// Base headers without authentication
|
|
860
927
|
const BASE_HEADERS = {
|
|
861
928
|
Accept: "application/json",
|
|
@@ -8995,6 +9062,7 @@ async function startStreamableHTTPServer() {
|
|
|
8995
9062
|
token: effective.token,
|
|
8996
9063
|
lastUsed: Date.now(),
|
|
8997
9064
|
apiUrl: effective.apiUrl,
|
|
9065
|
+
publicBaseUrl: getForwardedPublicBaseUrl(req),
|
|
8998
9066
|
};
|
|
8999
9067
|
// Step 4: create a fresh transport per request.
|
|
9000
9068
|
const transport = isInit
|
|
@@ -9044,13 +9112,13 @@ async function startStreamableHTTPServer() {
|
|
|
9044
9112
|
});
|
|
9045
9113
|
};
|
|
9046
9114
|
// Configure Express middleware
|
|
9115
|
+
if (MCP_TRUST_PROXY) {
|
|
9116
|
+
app.set("trust proxy", 1);
|
|
9117
|
+
}
|
|
9047
9118
|
app.use(express.json());
|
|
9048
9119
|
registerDownloadProxy(app);
|
|
9049
9120
|
// MCP OAuth — mount auth router and prepare bearer-auth middleware
|
|
9050
9121
|
if (GITLAB_MCP_OAUTH) {
|
|
9051
|
-
// Trust first proxy so express-rate-limit uses X-Forwarded-For for real client IP.
|
|
9052
|
-
// Only enabled in OAuth mode where the server is typically behind a reverse proxy.
|
|
9053
|
-
app.set("trust proxy", 1);
|
|
9054
9122
|
const gitlabBaseUrl = GITLAB_API_URL.replace(/\/api\/v4\/?$/, "").replace(/\/$/, "");
|
|
9055
9123
|
const issuerUrl = new URL(MCP_SERVER_URL);
|
|
9056
9124
|
const callbackUrl = GITLAB_OAUTH_CALLBACK_PROXY
|
|
@@ -9064,7 +9132,7 @@ async function startStreamableHTTPServer() {
|
|
|
9064
9132
|
storedTtlSeconds: OAUTH_STATELESS_STORED_TTL_SECONDS,
|
|
9065
9133
|
}
|
|
9066
9134
|
: null;
|
|
9067
|
-
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES,
|
|
9135
|
+
const oauthProvider = createGitLabOAuthProvider(gitlabBaseUrl, GITLAB_OAUTH_APP_ID, "GitLab MCP Server", GITLAB_READ_ONLY_MODE, GITLAB_OAUTH_SCOPES, GITLAB_OAUTH_ALLOWED_GROUPS, GITLAB_OAUTH_CALLBACK_PROXY, callbackUrl, statelessOptions);
|
|
9068
9136
|
const scopesSupported = GITLAB_OAUTH_SCOPES ?? ["api", "read_api", "read_user"];
|
|
9069
9137
|
// When server URL has a path (e.g. behind Kong), the SDK's well-known metadata
|
|
9070
9138
|
// advertises root-level endpoints. Override to use path-prefixed endpoints.
|
|
@@ -9178,6 +9246,7 @@ async function startStreamableHTTPServer() {
|
|
|
9178
9246
|
// Streamable HTTP endpoint - handles both session creation and message handling
|
|
9179
9247
|
app.post("/mcp", mcpBearerAuth, async (req, res) => {
|
|
9180
9248
|
const sessionId = readMcpSessionIdHeader(req);
|
|
9249
|
+
const publicBaseUrl = getForwardedPublicBaseUrl(req);
|
|
9181
9250
|
// Track request
|
|
9182
9251
|
metrics.requestsProcessed++;
|
|
9183
9252
|
// Stateless-mode branch: bypass authBySession / streamableTransports
|
|
@@ -9222,19 +9291,20 @@ async function startStreamableHTTPServer() {
|
|
|
9222
9291
|
return;
|
|
9223
9292
|
}
|
|
9224
9293
|
// Store auth for this session
|
|
9225
|
-
authBySession[sessionId] = authData;
|
|
9294
|
+
authBySession[sessionId] = withPublicBaseUrl(authData, publicBaseUrl);
|
|
9226
9295
|
logger.info(`Session ${sessionId}: stored ${authData.header} header`);
|
|
9227
9296
|
setAuthTimeout(sessionId);
|
|
9228
9297
|
}
|
|
9229
9298
|
else if (sessionId && authData) {
|
|
9230
9299
|
// Existing session: allow auth rotation/update
|
|
9231
|
-
authBySession[sessionId] = authData;
|
|
9300
|
+
authBySession[sessionId] = withPublicBaseUrl(authData, publicBaseUrl, authBySession[sessionId]);
|
|
9232
9301
|
logger.debug(`Session ${sessionId}: updated ${authData.header} header`);
|
|
9233
9302
|
setAuthTimeout(sessionId);
|
|
9234
9303
|
}
|
|
9235
9304
|
else if (sessionId && authBySession[sessionId]) {
|
|
9236
9305
|
// Existing session with stored auth: update last used time and reset timeout
|
|
9237
9306
|
authBySession[sessionId].lastUsed = Date.now();
|
|
9307
|
+
updateSessionPublicBaseUrl(sessionId, publicBaseUrl);
|
|
9238
9308
|
setAuthTimeout(sessionId);
|
|
9239
9309
|
}
|
|
9240
9310
|
else if (!sessionId && !authData) {
|
|
@@ -9250,17 +9320,12 @@ async function startStreamableHTTPServer() {
|
|
|
9250
9320
|
if (headerAuthData) {
|
|
9251
9321
|
if (headerAuthData && sessionId) {
|
|
9252
9322
|
if (!authBySession[sessionId]) {
|
|
9253
|
-
authBySession[sessionId] = headerAuthData;
|
|
9323
|
+
authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl);
|
|
9254
9324
|
logger.info(`Session ${sessionId}: stored ${headerAuthData.header} header (header auth)`);
|
|
9255
9325
|
setAuthTimeout(sessionId);
|
|
9256
9326
|
}
|
|
9257
9327
|
else {
|
|
9258
|
-
authBySession[sessionId] =
|
|
9259
|
-
...authBySession[sessionId],
|
|
9260
|
-
header: headerAuthData.header,
|
|
9261
|
-
token: headerAuthData.token,
|
|
9262
|
-
lastUsed: Date.now(),
|
|
9263
|
-
};
|
|
9328
|
+
authBySession[sessionId] = withPublicBaseUrl(headerAuthData, publicBaseUrl, authBySession[sessionId]);
|
|
9264
9329
|
setAuthTimeout(sessionId);
|
|
9265
9330
|
}
|
|
9266
9331
|
}
|
|
@@ -9274,6 +9339,7 @@ async function startStreamableHTTPServer() {
|
|
|
9274
9339
|
token: authInfo.token,
|
|
9275
9340
|
lastUsed: Date.now(),
|
|
9276
9341
|
apiUrl: GITLAB_API_URL,
|
|
9342
|
+
publicBaseUrl,
|
|
9277
9343
|
};
|
|
9278
9344
|
logger.info(`Session ${sessionId}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
9279
9345
|
setAuthTimeout(sessionId);
|
|
@@ -9282,6 +9348,7 @@ async function startStreamableHTTPServer() {
|
|
|
9282
9348
|
// Update token on every request — the client may have refreshed it
|
|
9283
9349
|
authBySession[sessionId].token = authInfo.token;
|
|
9284
9350
|
authBySession[sessionId].lastUsed = Date.now();
|
|
9351
|
+
updateSessionPublicBaseUrl(sessionId, publicBaseUrl);
|
|
9285
9352
|
setAuthTimeout(sessionId);
|
|
9286
9353
|
}
|
|
9287
9354
|
}
|
|
@@ -9309,7 +9376,7 @@ async function startStreamableHTTPServer() {
|
|
|
9309
9376
|
if (REMOTE_AUTHORIZATION && !authBySession[newSessionId]) {
|
|
9310
9377
|
const authData = parseAuthHeaders(req);
|
|
9311
9378
|
if (authData) {
|
|
9312
|
-
authBySession[newSessionId] = authData;
|
|
9379
|
+
authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
|
|
9313
9380
|
logger.info(`Session ${newSessionId}: stored ${authData.header} header`);
|
|
9314
9381
|
setAuthTimeout(newSessionId);
|
|
9315
9382
|
}
|
|
@@ -9320,7 +9387,7 @@ async function startStreamableHTTPServer() {
|
|
|
9320
9387
|
if (hasHeaderAuth(req)) {
|
|
9321
9388
|
const authData = parseAuthHeaders(req);
|
|
9322
9389
|
if (authData) {
|
|
9323
|
-
authBySession[newSessionId] = authData;
|
|
9390
|
+
authBySession[newSessionId] = withPublicBaseUrl(authData, publicBaseUrl);
|
|
9324
9391
|
logger.info(`Session ${newSessionId}: stored ${authData.header} header (header auth)`);
|
|
9325
9392
|
setAuthTimeout(newSessionId);
|
|
9326
9393
|
}
|
|
@@ -9333,6 +9400,7 @@ async function startStreamableHTTPServer() {
|
|
|
9333
9400
|
token: authInfo.token,
|
|
9334
9401
|
lastUsed: Date.now(),
|
|
9335
9402
|
apiUrl: GITLAB_API_URL,
|
|
9403
|
+
publicBaseUrl,
|
|
9336
9404
|
};
|
|
9337
9405
|
logger.info(`Session ${newSessionId}: stored OAuth token (client: ${authInfo.clientId})`);
|
|
9338
9406
|
setAuthTimeout(newSessionId);
|
|
@@ -9380,6 +9448,7 @@ async function startStreamableHTTPServer() {
|
|
|
9380
9448
|
token: authData.token,
|
|
9381
9449
|
lastUsed: authData.lastUsed,
|
|
9382
9450
|
apiUrl: authData.apiUrl,
|
|
9451
|
+
publicBaseUrl: authData.publicBaseUrl,
|
|
9383
9452
|
};
|
|
9384
9453
|
// Run the entire request handling within AsyncLocalStorage context
|
|
9385
9454
|
await sessionAuthStore.run(ctx, handleRequest);
|
|
@@ -9551,6 +9620,17 @@ async function runServer() {
|
|
|
9551
9620
|
await initializeServerByTransportMode(transportMode);
|
|
9552
9621
|
logger.info(`Configured GitLab API URLs: ${GITLAB_API_URLS.join(", ")}`);
|
|
9553
9622
|
logger.info(`Default GitLab API URL: ${GITLAB_API_URL}`);
|
|
9623
|
+
if (GITLAB_ALLOWED_GROUPS_RAW) {
|
|
9624
|
+
if (GITLAB_OAUTH_ALLOWED_GROUPS_RAW) {
|
|
9625
|
+
logger.warn("GITLAB_ALLOWED_GROUPS is set but ignored — GITLAB_OAUTH_ALLOWED_GROUPS takes precedence.");
|
|
9626
|
+
}
|
|
9627
|
+
else {
|
|
9628
|
+
logger.warn("GITLAB_ALLOWED_GROUPS is deprecated. Use GITLAB_OAUTH_ALLOWED_GROUPS instead.");
|
|
9629
|
+
}
|
|
9630
|
+
}
|
|
9631
|
+
if (GITLAB_OAUTH_ALLOWED_GROUPS) {
|
|
9632
|
+
logger.info(`Group access control enabled for: ${GITLAB_OAUTH_ALLOWED_GROUPS.join(", ")}`);
|
|
9633
|
+
}
|
|
9554
9634
|
}
|
|
9555
9635
|
catch (error) {
|
|
9556
9636
|
logger.error("Error initializing server:", error);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for GITLAB_OAUTH_ALLOWED_GROUPS config resolution.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* - The new GITLAB_OAUTH_ALLOWED_GROUPS env var is preferred when set.
|
|
6
|
+
* - The deprecated GITLAB_ALLOWED_GROUPS is accepted as a fallback and
|
|
7
|
+
* sets the deprecation flag so callers can emit a warning.
|
|
8
|
+
* - When both are set, the new var wins and GITLAB_ALLOWED_GROUPS_RAW remains
|
|
9
|
+
* set, so callers can emit a "set but ignored" warning.
|
|
10
|
+
*
|
|
11
|
+
* config.ts reads process.env at module load, so each scenario runs in a
|
|
12
|
+
* fresh child process — same pattern as test/stateless/config-ttl.test.ts.
|
|
13
|
+
*/
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import * as url from "node:url";
|
|
18
|
+
import { describe, test } from "node:test";
|
|
19
|
+
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
|
|
20
|
+
const CONFIG_PATH = path.resolve(__dirname, "../config.ts");
|
|
21
|
+
function loadConfig(env) {
|
|
22
|
+
const script = `
|
|
23
|
+
import(${JSON.stringify(url.pathToFileURL(CONFIG_PATH).href)}).then((m) => {
|
|
24
|
+
const out = {
|
|
25
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: m.GITLAB_OAUTH_ALLOWED_GROUPS ?? null,
|
|
26
|
+
GITLAB_OAUTH_ALLOWED_GROUPS_RAW: m.GITLAB_OAUTH_ALLOWED_GROUPS_RAW ?? null,
|
|
27
|
+
GITLAB_ALLOWED_GROUPS_RAW: m.GITLAB_ALLOWED_GROUPS_RAW ?? null,
|
|
28
|
+
};
|
|
29
|
+
process.stdout.write(JSON.stringify(out));
|
|
30
|
+
}).catch((err) => {
|
|
31
|
+
process.stderr.write(String(err && err.stack || err));
|
|
32
|
+
process.exit(2);
|
|
33
|
+
});
|
|
34
|
+
`;
|
|
35
|
+
const childEnv = {
|
|
36
|
+
...process.env,
|
|
37
|
+
};
|
|
38
|
+
delete childEnv.GITLAB_OAUTH_ALLOWED_GROUPS;
|
|
39
|
+
delete childEnv.GITLAB_ALLOWED_GROUPS;
|
|
40
|
+
for (const [k, v] of Object.entries(env)) {
|
|
41
|
+
if (v === undefined) {
|
|
42
|
+
delete childEnv[k];
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
childEnv[k] = v;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const stdout = execFileSync(process.execPath, ["--import", "tsx/esm", "--input-type=module", "--eval", script], { env: childEnv, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
49
|
+
return JSON.parse(stdout);
|
|
50
|
+
}
|
|
51
|
+
describe("config.ts — GITLAB_OAUTH_ALLOWED_GROUPS resolution", () => {
|
|
52
|
+
test("neither var set — resolved value and raws are null", () => {
|
|
53
|
+
const cfg = loadConfig({});
|
|
54
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
55
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
|
|
56
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
|
|
57
|
+
});
|
|
58
|
+
test("only new var set — resolved correctly, deprecated raw is null", () => {
|
|
59
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "my-org" });
|
|
60
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
|
|
61
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "my-org");
|
|
62
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, null);
|
|
63
|
+
});
|
|
64
|
+
test("only deprecated var set — resolved via fallback, deprecated raw is set", () => {
|
|
65
|
+
const cfg = loadConfig({ GITLAB_ALLOWED_GROUPS: "my-org" });
|
|
66
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["my-org"]);
|
|
67
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "my-org");
|
|
68
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, null);
|
|
69
|
+
});
|
|
70
|
+
test("both vars set — new var wins, both raws are set (triggers 'set but ignored' warning)", () => {
|
|
71
|
+
const cfg = loadConfig({
|
|
72
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: "new-org",
|
|
73
|
+
GITLAB_ALLOWED_GROUPS: "old-org",
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, ["new-org"]);
|
|
76
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS_RAW, "new-org");
|
|
77
|
+
assert.equal(cfg.GITLAB_ALLOWED_GROUPS_RAW, "old-org");
|
|
78
|
+
});
|
|
79
|
+
test("comma-separated values are split and trimmed", () => {
|
|
80
|
+
const cfg = loadConfig({
|
|
81
|
+
GITLAB_OAUTH_ALLOWED_GROUPS: "my-org/team-a , my-org/team-b , my-org",
|
|
82
|
+
});
|
|
83
|
+
assert.deepEqual(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, [
|
|
84
|
+
"my-org/team-a",
|
|
85
|
+
"my-org/team-b",
|
|
86
|
+
"my-org",
|
|
87
|
+
]);
|
|
88
|
+
});
|
|
89
|
+
test("empty string resolves to null", () => {
|
|
90
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: "" });
|
|
91
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
92
|
+
});
|
|
93
|
+
test("whitespace-only entries are filtered out", () => {
|
|
94
|
+
const cfg = loadConfig({ GITLAB_OAUTH_ALLOWED_GROUPS: " , , " });
|
|
95
|
+
assert.equal(cfg.GITLAB_OAUTH_ALLOWED_GROUPS, null);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -17,6 +17,16 @@ const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
|
17
17
|
const TEST_PROJECT_ID = '123';
|
|
18
18
|
const TEST_JOB_ID = '456';
|
|
19
19
|
const TEST_SECRET = 'testsecret';
|
|
20
|
+
const FORWARDED_BASE_URL = 'https://gitlab.mcp.example.test/gitlab-mcp';
|
|
21
|
+
const FORWARDED_HEADERS = {
|
|
22
|
+
'X-Forwarded-Proto': 'https',
|
|
23
|
+
'X-Forwarded-Host': 'gitlab.mcp.example.test',
|
|
24
|
+
'X-Forwarded-Prefix': '/gitlab-mcp',
|
|
25
|
+
};
|
|
26
|
+
const RFC_FORWARDED_HEADERS = {
|
|
27
|
+
'Forwarded': 'for=192.0.2.43;proto=http;host=attacker.example.test, for="[2001:db8:cafe::17]";proto="https";host="gitlab.mcp.example.test"',
|
|
28
|
+
'X-Forwarded-Prefix': '/gitlab-mcp',
|
|
29
|
+
};
|
|
20
30
|
// Minimal 1x1 transparent PNG
|
|
21
31
|
const MINIMAL_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', 'base64');
|
|
22
32
|
const LARGE_FILE_TOKEN = 'glpat-largefile-test-token';
|
|
@@ -92,6 +102,8 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
92
102
|
env: {
|
|
93
103
|
STREAMABLE_HTTP: 'true',
|
|
94
104
|
REMOTE_AUTHORIZATION: 'true',
|
|
105
|
+
MCP_TRUST_PROXY: 'false',
|
|
106
|
+
MCP_SERVER_URL: '',
|
|
95
107
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
96
108
|
USE_PIPELINE: 'true',
|
|
97
109
|
MAX_REQUESTS_PER_MINUTE: '2',
|
|
@@ -148,6 +160,61 @@ describe('Remote Downloads - Download Proxy Endpoint', { timeout: 30_000 }, () =
|
|
|
148
160
|
}
|
|
149
161
|
assert.ok(got429, 'Should have received 429 within 10 requests (rate limit is 2/min)');
|
|
150
162
|
});
|
|
163
|
+
test('ignores forwarded headers unless MCP_TRUST_PROXY is enabled', async () => {
|
|
164
|
+
const initRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: {
|
|
167
|
+
'Content-Type': 'application/json',
|
|
168
|
+
'Accept': 'application/json, text/event-stream',
|
|
169
|
+
'Private-Token': MOCK_TOKEN,
|
|
170
|
+
},
|
|
171
|
+
body: JSON.stringify({
|
|
172
|
+
jsonrpc: '2.0',
|
|
173
|
+
id: 20,
|
|
174
|
+
method: 'initialize',
|
|
175
|
+
params: {
|
|
176
|
+
protocolVersion: '2025-03-26',
|
|
177
|
+
capabilities: {},
|
|
178
|
+
clientInfo: { name: 'test-remote-downloads-no-trust-proxy', version: '1.0' },
|
|
179
|
+
},
|
|
180
|
+
}),
|
|
181
|
+
});
|
|
182
|
+
assert.strictEqual(initRes.status, 200, 'Initialize should succeed');
|
|
183
|
+
const sessionId = initRes.headers.get('mcp-session-id');
|
|
184
|
+
assert.ok(sessionId, 'Should receive a session ID');
|
|
185
|
+
const toolRes = await fetch(`http://${HOST}:${serverPort}/mcp`, {
|
|
186
|
+
method: 'POST',
|
|
187
|
+
headers: {
|
|
188
|
+
'Content-Type': 'application/json',
|
|
189
|
+
'Accept': 'application/json, text/event-stream',
|
|
190
|
+
...FORWARDED_HEADERS,
|
|
191
|
+
'Private-Token': MOCK_TOKEN,
|
|
192
|
+
'mcp-session-id': sessionId,
|
|
193
|
+
},
|
|
194
|
+
body: JSON.stringify({
|
|
195
|
+
jsonrpc: '2.0',
|
|
196
|
+
id: 21,
|
|
197
|
+
method: 'tools/call',
|
|
198
|
+
params: {
|
|
199
|
+
name: 'download_job_artifacts',
|
|
200
|
+
arguments: {
|
|
201
|
+
project_id: TEST_PROJECT_ID,
|
|
202
|
+
job_id: TEST_JOB_ID,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
assert.strictEqual(toolRes.status, 200, 'Tool call should return 200');
|
|
208
|
+
const responses = parseSSE(await toolRes.text());
|
|
209
|
+
const result = responses.find(r => r.id === 21);
|
|
210
|
+
assert.ok(result?.result, 'Should have a result');
|
|
211
|
+
const textBlock = result.result.content.find(c => c.type === 'text');
|
|
212
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
213
|
+
const parsed = JSON.parse(textBlock.text);
|
|
214
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
215
|
+
assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address when MCP_TRUST_PROXY is disabled, got ${parsed.download_url}`);
|
|
216
|
+
assert.ok(!parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), 'URL should not use forwarded public base URL when MCP_TRUST_PROXY is disabled');
|
|
217
|
+
});
|
|
151
218
|
});
|
|
152
219
|
describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000 }, () => {
|
|
153
220
|
let mockGitLab;
|
|
@@ -189,6 +256,8 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
|
|
|
189
256
|
env: {
|
|
190
257
|
STREAMABLE_HTTP: 'true',
|
|
191
258
|
REMOTE_AUTHORIZATION: 'true',
|
|
259
|
+
MCP_TRUST_PROXY: 'true',
|
|
260
|
+
MCP_SERVER_URL: '',
|
|
192
261
|
GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
|
|
193
262
|
USE_PIPELINE: 'true',
|
|
194
263
|
},
|
|
@@ -222,12 +291,13 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
|
|
|
222
291
|
if (mockGitLab)
|
|
223
292
|
await mockGitLab.stop();
|
|
224
293
|
});
|
|
225
|
-
async function callTool(id, name, args) {
|
|
294
|
+
async function callTool(id, name, args, extraHeaders = {}) {
|
|
226
295
|
const res = await fetch(`http://${HOST}:${serverPort}/mcp`, {
|
|
227
296
|
method: 'POST',
|
|
228
297
|
headers: {
|
|
229
298
|
'Content-Type': 'application/json',
|
|
230
299
|
'Accept': 'application/json, text/event-stream',
|
|
300
|
+
...extraHeaders,
|
|
231
301
|
'Private-Token': MOCK_TOKEN,
|
|
232
302
|
'mcp-session-id': sessionId,
|
|
233
303
|
},
|
|
@@ -269,6 +339,97 @@ describe('Remote Downloads - Tool Behavior via MCP Protocol', { timeout: 60_000
|
|
|
269
339
|
assert.ok(buf.length > 0, 'Downloaded content should not be empty');
|
|
270
340
|
assert.ok(buf.includes(Buffer.from('PK')), 'Should contain zip magic bytes');
|
|
271
341
|
});
|
|
342
|
+
test('download_job_artifacts ignores forwarded hosts containing URL components', async () => {
|
|
343
|
+
const result = await callTool(14, 'download_job_artifacts', {
|
|
344
|
+
project_id: TEST_PROJECT_ID,
|
|
345
|
+
job_id: TEST_JOB_ID,
|
|
346
|
+
}, {
|
|
347
|
+
...FORWARDED_HEADERS,
|
|
348
|
+
'X-Forwarded-Host': 'gitlab.mcp.example.test@attacker.example.test',
|
|
349
|
+
});
|
|
350
|
+
assert.ok(result.result, 'Should have a result');
|
|
351
|
+
const content = result.result.content;
|
|
352
|
+
assert.ok(content && content.length > 0, 'Should have content');
|
|
353
|
+
const textBlock = content.find(c => c.type === 'text');
|
|
354
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
355
|
+
const parsed = JSON.parse(textBlock.text);
|
|
356
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
357
|
+
assert.ok(parsed.download_url.startsWith(`http://${HOST}:${serverPort}/downloads/job-artifacts?`), `URL should fall back to local server address for unsafe forwarded host, got ${parsed.download_url}`);
|
|
358
|
+
assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain unsafe forwarded host, got ${parsed.download_url}`);
|
|
359
|
+
assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
|
|
360
|
+
});
|
|
361
|
+
test('download_job_artifacts ignores authority-style forwarded prefixes', async () => {
|
|
362
|
+
const result = await callTool(15, 'download_job_artifacts', {
|
|
363
|
+
project_id: TEST_PROJECT_ID,
|
|
364
|
+
job_id: TEST_JOB_ID,
|
|
365
|
+
}, {
|
|
366
|
+
...FORWARDED_HEADERS,
|
|
367
|
+
'X-Forwarded-Prefix': '//attacker.example.test',
|
|
368
|
+
});
|
|
369
|
+
assert.ok(result.result, 'Should have a result');
|
|
370
|
+
const content = result.result.content;
|
|
371
|
+
assert.ok(content && content.length > 0, 'Should have content');
|
|
372
|
+
const textBlock = content.find(c => c.type === 'text');
|
|
373
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
374
|
+
const parsed = JSON.parse(textBlock.text);
|
|
375
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
376
|
+
assert.ok(parsed.download_url.startsWith('https://gitlab.mcp.example.test/downloads/job-artifacts?'), `URL should keep forwarded proto/host while ignoring unsafe prefix, got ${parsed.download_url}`);
|
|
377
|
+
assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not contain authority-style prefix, got ${parsed.download_url}`);
|
|
378
|
+
assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
|
|
379
|
+
assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
|
|
380
|
+
});
|
|
381
|
+
test('download_job_artifacts uses rightmost forwarded header values', async () => {
|
|
382
|
+
const result = await callTool(16, 'download_job_artifacts', {
|
|
383
|
+
project_id: TEST_PROJECT_ID,
|
|
384
|
+
job_id: TEST_JOB_ID,
|
|
385
|
+
}, {
|
|
386
|
+
'X-Forwarded-Proto': 'http, https',
|
|
387
|
+
'X-Forwarded-Host': 'attacker.example.test, gitlab.mcp.example.test',
|
|
388
|
+
'X-Forwarded-Prefix': '/attacker, /gitlab-mcp',
|
|
389
|
+
});
|
|
390
|
+
assert.ok(result.result, 'Should have a result');
|
|
391
|
+
const content = result.result.content;
|
|
392
|
+
assert.ok(content && content.length > 0, 'Should have content');
|
|
393
|
+
const textBlock = content.find(c => c.type === 'text');
|
|
394
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
395
|
+
const parsed = JSON.parse(textBlock.text);
|
|
396
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
397
|
+
assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use the rightmost forwarded header values, got ${parsed.download_url}`);
|
|
398
|
+
assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted forwarded header value, got ${parsed.download_url}`);
|
|
399
|
+
assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
|
|
400
|
+
});
|
|
401
|
+
test('download_job_artifacts derives download_url from RFC 7239 Forwarded header', async () => {
|
|
402
|
+
const result = await callTool(18, 'download_job_artifacts', {
|
|
403
|
+
project_id: TEST_PROJECT_ID,
|
|
404
|
+
job_id: TEST_JOB_ID,
|
|
405
|
+
}, RFC_FORWARDED_HEADERS);
|
|
406
|
+
assert.ok(result.result, 'Should have a result');
|
|
407
|
+
const content = result.result.content;
|
|
408
|
+
assert.ok(content && content.length > 0, 'Should have content');
|
|
409
|
+
const textBlock = content.find(c => c.type === 'text');
|
|
410
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
411
|
+
const parsed = JSON.parse(textBlock.text);
|
|
412
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
413
|
+
assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use RFC 7239 Forwarded header values, got ${parsed.download_url}`);
|
|
414
|
+
assert.ok(!parsed.download_url.includes('attacker.example.test'), `URL should not use the leftmost untrusted Forwarded value, got ${parsed.download_url}`);
|
|
415
|
+
assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
|
|
416
|
+
});
|
|
417
|
+
test('download_job_artifacts derives download_url from forwarded headers when MCP_SERVER_URL is unset', async () => {
|
|
418
|
+
const result = await callTool(17, 'download_job_artifacts', {
|
|
419
|
+
project_id: TEST_PROJECT_ID,
|
|
420
|
+
job_id: TEST_JOB_ID,
|
|
421
|
+
}, FORWARDED_HEADERS);
|
|
422
|
+
assert.ok(result.result, 'Should have a result');
|
|
423
|
+
const content = result.result.content;
|
|
424
|
+
assert.ok(content && content.length > 0, 'Should have content');
|
|
425
|
+
const textBlock = content.find(c => c.type === 'text');
|
|
426
|
+
assert.ok(textBlock?.text, 'Should have text content');
|
|
427
|
+
const parsed = JSON.parse(textBlock.text);
|
|
428
|
+
assert.ok(parsed.download_url, 'Should contain download_url');
|
|
429
|
+
assert.ok(parsed.download_url.startsWith(`${FORWARDED_BASE_URL}/downloads/job-artifacts?`), `URL should use forwarded public base URL, got ${parsed.download_url}`);
|
|
430
|
+
assert.ok(!parsed.download_url.startsWith(`http://${HOST}:${serverPort}`), 'URL should not fall back to local server address');
|
|
431
|
+
assert.ok(parsed.download_url.includes('_token='), 'URL should contain embedded auth token');
|
|
432
|
+
});
|
|
272
433
|
test('download_attachment for non-image returns download_url', async () => {
|
|
273
434
|
const result = await callTool(11, 'download_attachment', {
|
|
274
435
|
project_id: TEST_PROJECT_ID,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.22",
|
|
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/streamable-http-static-token-auth.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-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-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-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/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/mcp-oauth-tests.ts && node --import tsx/esm --test test/streamable-http-static-token-auth.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-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-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/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",
|