@zereight/mcp-gitlab 2.3.8-next.149 → 2.3.8-next.150
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/build/downloads/proxy.js +17 -3
- package/build/index.js +15 -10
- package/build/utils/download-token.js +12 -1
- package/package.json +1 -1
package/build/downloads/proxy.js
CHANGED
|
@@ -11,6 +11,18 @@ function canonicalizeQueryParams(params) {
|
|
|
11
11
|
}
|
|
12
12
|
return JSON.stringify(normalized);
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Check the requested project against the env allowlist (getEffectiveProjectId) and
|
|
16
|
+
* against the allowlist sealed into the download token, since the proxy runs
|
|
17
|
+
* outside the session that issued it.
|
|
18
|
+
*/
|
|
19
|
+
function resolveDownloadProjectId(deps, projectId, sealedProjectIds) {
|
|
20
|
+
const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(projectId));
|
|
21
|
+
if (sealedProjectIds.length > 0 && !sealedProjectIds.includes(effectiveProjectId)) {
|
|
22
|
+
throw new Error(`Access denied: Project ${effectiveProjectId} is outside the project scope of this download token`);
|
|
23
|
+
}
|
|
24
|
+
return effectiveProjectId;
|
|
25
|
+
}
|
|
14
26
|
/**
|
|
15
27
|
* Register the /downloads/:type proxy endpoint on an Express app.
|
|
16
28
|
* Streams GitLab API responses directly to the client. Auth is read from
|
|
@@ -46,6 +58,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
46
58
|
// Try embedded encrypted token first (self-contained URL), then headers
|
|
47
59
|
const encryptedToken = req.query._token;
|
|
48
60
|
let tokenApiUrl;
|
|
61
|
+
let sealedProjectIds = [];
|
|
49
62
|
if (encryptedToken) {
|
|
50
63
|
const decrypted = decryptDownloadToken(encryptedToken);
|
|
51
64
|
if (!decrypted) {
|
|
@@ -70,6 +83,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
70
83
|
headers[decrypted.header] = decrypted.token;
|
|
71
84
|
rateLimitKey = decrypted.token;
|
|
72
85
|
tokenApiUrl = decrypted.apiUrl;
|
|
86
|
+
sealedProjectIds = decrypted.allowedProjectIds;
|
|
73
87
|
}
|
|
74
88
|
else {
|
|
75
89
|
const privateToken = req.headers["private-token"];
|
|
@@ -119,7 +133,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
119
133
|
res.status(400).json({ error: "project_id and job_id are required" });
|
|
120
134
|
return;
|
|
121
135
|
}
|
|
122
|
-
const effectiveProjectId = deps
|
|
136
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
123
137
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
|
|
124
138
|
break;
|
|
125
139
|
}
|
|
@@ -129,7 +143,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
129
143
|
res.status(400).json({ error: "project_id, secret, and filename are required" });
|
|
130
144
|
return;
|
|
131
145
|
}
|
|
132
|
-
const effectiveProjectId = deps
|
|
146
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
133
147
|
// The uploads route takes one filename segment; a slash inside the name must
|
|
134
148
|
// stay inside that segment instead of turning into another route level.
|
|
135
149
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPathSegment(filename)}`;
|
|
@@ -143,7 +157,7 @@ export function registerDownloadProxy(app, deps) {
|
|
|
143
157
|
.json({ error: "project_id, tag_name, and direct_asset_path are required" });
|
|
144
158
|
return;
|
|
145
159
|
}
|
|
146
|
-
const effectiveProjectId = deps
|
|
160
|
+
const effectiveProjectId = resolveDownloadProjectId(deps, project_id, sealedProjectIds);
|
|
147
161
|
gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/releases/${deps.encodeGitLabPathSegment(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
|
|
148
162
|
break;
|
|
149
163
|
}
|
package/build/index.js
CHANGED
|
@@ -8,25 +8,32 @@ const STREAMABLE_HTTP_AUTH_TOKEN = getConfig("streamable-http-auth-token", "STRE
|
|
|
8
8
|
* Build a URL pointing to the download proxy endpoint.
|
|
9
9
|
* Embeds an encrypted auth token (and API URL for dynamic routing)
|
|
10
10
|
* from the current session so the URL works standalone.
|
|
11
|
+
* @throws {ProjectAccessDeniedError} If the project is outside the effective allowlist
|
|
11
12
|
*/
|
|
12
|
-
function buildDownloadUrl(type, params) {
|
|
13
|
+
function buildDownloadUrl(type, projectId, params) {
|
|
14
|
+
// Decoded once, like the /downloads proxy does, so both check the same value
|
|
15
|
+
const resourceParams = {
|
|
16
|
+
...params,
|
|
17
|
+
project_id: getEffectiveProjectId(decodeURIComponent(projectId)),
|
|
18
|
+
};
|
|
13
19
|
const base = MCP_SERVER_URL || sessionAuthStore.getStore()?.publicBaseUrl || `http://${HOST}:${PORT}`;
|
|
14
20
|
const baseUrl = new URL(base);
|
|
15
21
|
// Preserve any path prefix (e.g. /gitlab-mcp) from the base URL
|
|
16
22
|
const basePath = baseUrl.pathname.replace(/\/+$/, "");
|
|
17
23
|
const safeBasePath = basePath ? `/${basePath.replace(/^\/+/, "")}` : "";
|
|
18
24
|
const url = new URL(`${safeBasePath}/downloads/${type}`, baseUrl.origin);
|
|
19
|
-
for (const [key, value] of Object.entries(
|
|
25
|
+
for (const [key, value] of Object.entries(resourceParams)) {
|
|
20
26
|
url.searchParams.set(key, value);
|
|
21
27
|
}
|
|
22
28
|
// Embed auth (and apiUrl when dynamic routing is active) from current session or static config
|
|
23
29
|
// Token is bound to the specific resource (type + params) to prevent URL tampering
|
|
24
|
-
const resource = { type, params };
|
|
30
|
+
const resource = { type, params: resourceParams };
|
|
31
|
+
const allowedProjectIds = getEffectiveAllowedProjectIds();
|
|
25
32
|
const ctx = sessionAuthStore.getStore();
|
|
26
33
|
if (ctx?.token) {
|
|
27
34
|
const headerValue = ctx.header === "Authorization" ? `Bearer ${ctx.token}` : ctx.token;
|
|
28
35
|
const apiUrl = ENABLE_DYNAMIC_API_URL && ctx.apiUrl !== GITLAB_API_URL ? ctx.apiUrl : undefined;
|
|
29
|
-
url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, apiUrl, resource));
|
|
36
|
+
url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, allowedProjectIds, apiUrl, resource));
|
|
30
37
|
}
|
|
31
38
|
else {
|
|
32
39
|
// Fallback for SSE/static-token mode (no session auth context)
|
|
@@ -48,7 +55,7 @@ function buildDownloadUrl(type, params) {
|
|
|
48
55
|
header = "Authorization";
|
|
49
56
|
headerValue = `Bearer ${staticToken}`;
|
|
50
57
|
}
|
|
51
|
-
url.searchParams.set("_token", createDownloadToken(header, headerValue, undefined, resource));
|
|
58
|
+
url.searchParams.set("_token", createDownloadToken(header, headerValue, allowedProjectIds, undefined, resource));
|
|
52
59
|
}
|
|
53
60
|
}
|
|
54
61
|
return url.toString();
|
|
@@ -10095,7 +10102,7 @@ async function handleToolCall(params) {
|
|
|
10095
10102
|
if (local_path) {
|
|
10096
10103
|
throw new Error("local_path cannot be used in remote mode — use the returned download_url instead");
|
|
10097
10104
|
}
|
|
10098
|
-
const downloadUrl = buildDownloadUrl("job-artifacts",
|
|
10105
|
+
const downloadUrl = buildDownloadUrl("job-artifacts", project_id, { job_id });
|
|
10099
10106
|
return {
|
|
10100
10107
|
content: [
|
|
10101
10108
|
{
|
|
@@ -10614,8 +10621,7 @@ async function handleToolCall(params) {
|
|
|
10614
10621
|
// In remote mode for non-image files, return proxy URL
|
|
10615
10622
|
const mimeType = getImageMimeType(args.filename);
|
|
10616
10623
|
if (IS_REMOTE && !mimeType) {
|
|
10617
|
-
const downloadUrl = buildDownloadUrl("attachment", {
|
|
10618
|
-
project_id: args.project_id,
|
|
10624
|
+
const downloadUrl = buildDownloadUrl("attachment", args.project_id, {
|
|
10619
10625
|
secret: args.secret,
|
|
10620
10626
|
filename: args.filename,
|
|
10621
10627
|
});
|
|
@@ -10753,8 +10759,7 @@ async function handleToolCall(params) {
|
|
|
10753
10759
|
case "download_release_asset": {
|
|
10754
10760
|
const args = DownloadReleaseAssetSchema.parse(params.arguments);
|
|
10755
10761
|
if (IS_REMOTE) {
|
|
10756
|
-
const downloadUrl = buildDownloadUrl("release-asset", {
|
|
10757
|
-
project_id: args.project_id,
|
|
10762
|
+
const downloadUrl = buildDownloadUrl("release-asset", args.project_id, {
|
|
10758
10763
|
tag_name: args.tag_name,
|
|
10759
10764
|
direct_asset_path: args.direct_asset_path,
|
|
10760
10765
|
});
|
|
@@ -21,14 +21,17 @@ const DOWNLOAD_TOKEN_TTL = Number.isFinite(parsedDownloadTokenTtl) && parsedDown
|
|
|
21
21
|
: DEFAULT_DOWNLOAD_TOKEN_TTL;
|
|
22
22
|
/**
|
|
23
23
|
* Create a self-contained encrypted download token for a specific GitLab resource.
|
|
24
|
+
* `allowedProjectIds` is the project allowlist of the issuing request (session scope
|
|
25
|
+
* or GITLAB_ALLOWED_PROJECT_IDS), because the download proxy runs outside that request.
|
|
24
26
|
*/
|
|
25
|
-
export function createDownloadToken(header, token, apiUrl, resource) {
|
|
27
|
+
export function createDownloadToken(header, token, allowedProjectIds, apiUrl, resource) {
|
|
26
28
|
const iv = randomBytes(12);
|
|
27
29
|
const cipher = createCipheriv("aes-256-gcm", DOWNLOAD_TOKEN_KEY, iv);
|
|
28
30
|
const payload = JSON.stringify({
|
|
29
31
|
h: header,
|
|
30
32
|
t: token,
|
|
31
33
|
e: Math.floor(Date.now() / 1000) + DOWNLOAD_TOKEN_TTL,
|
|
34
|
+
s: allowedProjectIds,
|
|
32
35
|
...(apiUrl ? { u: apiUrl } : {}),
|
|
33
36
|
...(resource ? { r: resource.type, p: resource.params } : {}),
|
|
34
37
|
});
|
|
@@ -36,8 +39,13 @@ export function createDownloadToken(header, token, apiUrl, resource) {
|
|
|
36
39
|
const tag = cipher.getAuthTag();
|
|
37
40
|
return Buffer.concat([iv, tag, encrypted]).toString("base64url");
|
|
38
41
|
}
|
|
42
|
+
function isStringArray(value) {
|
|
43
|
+
return Array.isArray(value) && value.every(item => typeof item === "string");
|
|
44
|
+
}
|
|
39
45
|
/**
|
|
40
46
|
* Decrypt and validate a download token, returning null when it is invalid or expired.
|
|
47
|
+
* A token without a sealed project allowlist was issued by an older release, which
|
|
48
|
+
* did not check the session scope, so it is invalid too.
|
|
41
49
|
*/
|
|
42
50
|
export function decryptDownloadToken(tokenStr) {
|
|
43
51
|
try {
|
|
@@ -58,9 +66,12 @@ export function decryptDownloadToken(tokenStr) {
|
|
|
58
66
|
return null;
|
|
59
67
|
if (typeof payload.t !== "string" || payload.t.length === 0)
|
|
60
68
|
return null;
|
|
69
|
+
if (!isStringArray(payload.s))
|
|
70
|
+
return null;
|
|
61
71
|
return {
|
|
62
72
|
header: payload.h,
|
|
63
73
|
token: payload.t,
|
|
74
|
+
allowedProjectIds: payload.s,
|
|
64
75
|
...(payload.u ? { apiUrl: payload.u } : {}),
|
|
65
76
|
...(payload.r ? { resourceType: payload.r, resourceParams: payload.p } : {}),
|
|
66
77
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zereight/mcp-gitlab",
|
|
3
|
-
"version": "2.3.8-next.
|
|
3
|
+
"version": "2.3.8-next.150",
|
|
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": [
|