@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.
@@ -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.getEffectiveProjectId(decodeURIComponent(project_id));
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.getEffectiveProjectId(decodeURIComponent(project_id));
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.getEffectiveProjectId(decodeURIComponent(project_id));
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(params)) {
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", { project_id, job_id });
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.149",
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": [