@zereight/mcp-gitlab 2.1.64 → 2.1.66

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.
@@ -289,9 +289,11 @@ function buildIndexPage(groupedToolsList) {
289
289
  "Permission modes control which tools are exposed:",
290
290
  "",
291
291
  "- `GITLAB_PERMISSION_MODE=readonly` — hides every write tool regardless of toggles.",
292
- "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks all `delete_*` tools, plus `push_files` `delete`/`move` actions.",
292
+ "- `GITLAB_PERMISSION_MODE=modify` — allows create/update but blocks delete and teardown tools: every `delete_*` tool, `erase_pipeline_job`, `purge_dependency_proxy_cache`, the destructive teardown verbs `cancel_pipeline`, `cancel_pipeline_job`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`, plus `push_files` `delete`/`move` actions.",
293
293
  "- `GITLAB_READ_ONLY_MODE=true` (deprecated) — same as `readonly`; prefer `GITLAB_PERMISSION_MODE=readonly`.",
294
294
  "",
295
+ "The `modify` guard applies to typed tools (`tools/list` and `tools/call`) and to destructive mutations sent through `execute_graphql`: any top-level mutation field whose name contains a deletion verb (`delete`, `destroy`, `remove`, `prune`, `purge`, `erase`) or a teardown verb (`revoke`, `cancel`, `stop`, `terminate`, `unprotect`, `disable`, `deactivate`, `drop`, `unschedule`). See [Environment Variables](../configuration/environment-variables.md#gitlab_permission_mode).",
296
+ "",
295
297
  "See [Environment Variables](../configuration/environment-variables.md)",
296
298
  "and [CLI Arguments](../getting-started/cli-arguments.md) for the full list.",
297
299
  "",
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { test } from "node:test";
4
- const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
4
+ import { encodeGitLabPath, encodeGitLabPathSegment } from "../utils/url.js";
5
+ const readSource = (relativePath) => readFileSync(new URL(relativePath, import.meta.url), "utf8");
6
+ const indexSource = readSource("../index.ts");
5
7
  const rawPathSegmentPatterns = [
6
8
  "/issues/${issueIid}",
7
9
  "/merge_requests/${mergeRequestIid}",
@@ -21,22 +23,76 @@ const rawPathSegmentPatterns = [
21
23
  ];
22
24
  test("GitLab URL path IDs are encoded before interpolation", () => {
23
25
  for (const pattern of rawPathSegmentPatterns) {
24
- assert.equal(source.includes(pattern), false, `${pattern} must use encodeGitLabPathSegment`);
26
+ assert.equal(indexSource.includes(pattern), false, `${pattern} must use encodeGitLabPathSegment`);
25
27
  }
26
28
  });
27
- test("encoding keeps path traversal payloads inside one URL segment", () => {
28
- const payload = "1/../../../../admin/ci/variables";
29
- const url = new URL(`https://gitlab.com/api/v4/projects/42/issues/${encodeURIComponent(payload)}/notes`);
30
- assert.equal(url.pathname, "/api/v4/projects/42/issues/1%2F..%2F..%2F..%2F..%2Fadmin%2Fci%2Fvariables/notes");
31
- });
32
- test("job_id path traversal stays under /jobs/", () => {
33
- // GHSA-7c3w-fxgh-frc7 / #587: unencoded job_id with ../ escaped to /api/v4/user
34
- const payload = "../../../user";
35
- const url = new URL(`https://gitlab.com/api/v4/projects/1/jobs/${encodeURIComponent(payload)}/trace`);
36
- assert.equal(url.pathname, "/api/v4/projects/1/jobs/..%2F..%2F..%2Fuser/trace");
37
- assert.match(url.pathname, /\/jobs\/[^/]+\/trace$/);
38
- assert.equal(url.pathname.includes("/api/v4/user"), false);
39
- });
40
- test("malformed percent-encoded segments can still be encoded", () => {
41
- assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath");
29
+ /**
30
+ * encodeURIComponent() does not validate what it encodes: "../../user" becomes
31
+ * "..%2F..%2Fuser", which a server that decodes the separator turns back into a
32
+ * traversal. Every value that lands in a URL path goes through
33
+ * encodeGitLabPathSegment()/encodeGitLabPath() instead; plain encoding stays only
34
+ * for query-string values, where a "/" is data and not a path separator.
35
+ */
36
+ const queryStringEncoders = [
37
+ "?source_branch=${encodeURIComponent(branchName)}",
38
+ "?access_token=${encodeURIComponent(token)}",
39
+ ];
40
+ const serverSources = [
41
+ ["index.ts", "../index.ts"],
42
+ ["downloads/proxy.ts", "../downloads/proxy.ts"],
43
+ ["oauth-proxy.ts", "../oauth-proxy.ts"],
44
+ ];
45
+ for (const [name, relativePath] of serverSources) {
46
+ test(`${name} validates values before they reach a URL path`, () => {
47
+ for (const line of readSource(relativePath).split("\n")) {
48
+ if (!line.includes("encodeURIComponent("))
49
+ continue;
50
+ assert.ok(queryStringEncoders.some(allowed => line.includes(allowed)), `${name} encodes a URL value without validating it: ${line.trim()}`);
51
+ }
52
+ });
53
+ }
54
+ test("plain encoding of a project path would leave the traversal visible", () => {
55
+ // The payload this guard exists for: the old encoder emits the separators, so a
56
+ // server that decodes them routes the request to /api/v4/user.
57
+ const payload = "../../user";
58
+ const legacy = `https://gitlab.com/api/v4/projects/${encodeURIComponent(payload)}`;
59
+ assert.equal(new URL(legacy).pathname, "/api/v4/projects/..%2F..%2Fuser");
60
+ });
61
+ test("the validated encoder keeps an ordinary project path inside one segment", () => {
62
+ const url = new URL(`https://gitlab.com/api/v4/projects/${encodeGitLabPathSegment("group/project")}/issues/${encodeGitLabPathSegment("1")}/notes`);
63
+ assert.equal(url.pathname, "/api/v4/projects/group%2Fproject/issues/1/notes");
64
+ });
65
+ test("path traversal payloads are rejected instead of encoded into one segment", () => {
66
+ // GHSA-7c3w-fxgh-frc7 / #587: an unencoded job_id with ../ escaped to /api/v4/user
67
+ for (const payload of [
68
+ "../../../user",
69
+ "1/../../../../admin/ci/variables",
70
+ "..%2F..%2Fuser",
71
+ "..%252F..%252Fuser",
72
+ "/etc/passwd",
73
+ ]) {
74
+ assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/, payload);
75
+ }
76
+ });
77
+ test("a slash-separated path cannot start outside its route prefix", () => {
78
+ assert.throws(() => encodeGitLabPath("/etc/passwd"), /Cannot use value as a GitLab URL path/);
79
+ assert.throws(() => encodeGitLabPath("1/../../../../admin/ci/variables"), /Cannot use value as a GitLab URL path segment/);
80
+ });
81
+ test("a broken escape next to a valid one is rejected, not passed through", () => {
82
+ // A guard cannot inspect a value it fails to decode: "%2E%2E%2F%ZZ" still holds a
83
+ // real escape that may hide a separator, so it must not be forwarded unvalidated.
84
+ assert.throws(() => encodeGitLabPathSegment("%2E%2E%2F%ZZ"), /Cannot use value as a GitLab URL path segment/);
85
+ });
86
+ test("the artifact path goes through the shared multi-segment validator", () => {
87
+ // getJobArtifactFile() was the last call site that split the path itself. The
88
+ // hand-rolled version kept empty segments, so "a//b" reached the URL as an extra
89
+ // separator instead of being rejected like every other call site.
90
+ assert.ok(indexSource.includes("const encodedArtifactPath = encodeGitLabPath(artifactPath);"), "artifact paths must go through encodeGitLabPath");
91
+ assert.equal(indexSource.includes(".map(segment => encodeGitLabPathSegment(segment))"), false, "artifact paths must not be split into segments by hand");
92
+ });
93
+ test("a percent sign that starts no escape stays a literal character", () => {
94
+ // "bad%zz/path" cannot be decoded either, but it holds no escape at all, so the
95
+ // guard sees the entire value and the percent sign is encoded as data. Rejecting
96
+ // it would break every file or branch name containing "%".
97
+ assert.equal(encodeGitLabPathSegment("bad%zz/path"), "bad%25zz%2Fpath");
42
98
  });
@@ -188,6 +188,39 @@ describe("managed response masking at the MCP boundary", { timeout: 30_000 }, ()
188
188
  "/api/v4/projects/12/merge_requests",
189
189
  ]);
190
190
  });
191
+ test("applies jmespath to JSON tool results at the MCP boundary", async (t) => {
192
+ const { client } = await startMaskingServer(t, {
193
+ env: { ENABLE_STRICT_PROJECT_SCOPE: "true", GITLAB_ALLOWED_PROJECT_IDS: "12" },
194
+ });
195
+ const { tools } = await client.listTools();
196
+ const listMrs = tools.find(tool => tool.name === "list_merge_requests");
197
+ const jmespathSchema = listMrs?.inputSchema?.properties?.jmespath;
198
+ assert.ok(jmespathSchema, "tools/list should inject optional jmespath on every tool schema");
199
+ assert.ok((jmespathSchema.description?.length ?? 0) < 120, "jmespath schema description should stay short for tools/list token cost");
200
+ const result = await client.callTool({
201
+ name: "list_issues",
202
+ arguments: { jmespath: "[].title" },
203
+ });
204
+ const text = result.content[0]?.text;
205
+ assert.deepEqual(JSON.parse(text), ["[one]"]);
206
+ const probe = await client.callTool({
207
+ name: "list_issues",
208
+ arguments: { jmespath: "[].starts_with(title, `ProjectOne`)" },
209
+ });
210
+ const probeText = probe.content[0]?.text;
211
+ assert.deepEqual(JSON.parse(probeText), [false], "jmespath must run on masked JSON so callers cannot probe hidden substrings");
212
+ });
213
+ test("rejects invalid jmespath before the handler reaches GitLab", async (t) => {
214
+ const { client, requests } = await startMaskingServer(t, {
215
+ env: { ENABLE_STRICT_PROJECT_SCOPE: "true", GITLAB_ALLOWED_PROJECT_IDS: "12" },
216
+ });
217
+ const result = await client.callTool({
218
+ name: "list_issues",
219
+ arguments: { jmespath: "[[[" },
220
+ });
221
+ assert.equal(result.isError, true);
222
+ assert.deepEqual(requests, []);
223
+ });
191
224
  test("keeps optional default-project handlers available with their managed policy", async (t) => {
192
225
  const { client, requests } = await startMaskingServer(t);
193
226
  const fileResult = await client.callTool({
@@ -0,0 +1,273 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import http from "node:http";
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 LOOPBACK = "127.0.0.1";
8
+ const SSE_TOKEN = "mcp_sse_secret";
9
+ const SERVER_PATH = path.resolve(process.cwd(), "build/index.js");
10
+ // Keeps connection churn from tripping the per-IP creation limit in tests that
11
+ // are not about rate limiting.
12
+ const UNTHROTTLED = "1000";
13
+ const running = new Set();
14
+ function startSseServer(env, port) {
15
+ const child = spawn("node", [SERVER_PATH], {
16
+ env: {
17
+ ...process.env,
18
+ GITLAB_API_URL: "https://gitlab.example.com/api/v4",
19
+ HOST: "0.0.0.0",
20
+ PORT: String(port),
21
+ SSE: "true",
22
+ STREAMABLE_HTTP: "false",
23
+ REMOTE_AUTHORIZATION: "false",
24
+ GITLAB_MCP_OAUTH: "false",
25
+ GITLAB_USE_OAUTH: "false",
26
+ GITLAB_PERSONAL_ACCESS_TOKEN: "glpat_test",
27
+ GITLAB_JOB_TOKEN: "",
28
+ GITLAB_AUTH_COOKIE_PATH: "",
29
+ SSE_AUTH_TOKEN: SSE_TOKEN,
30
+ ...env,
31
+ },
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ });
34
+ running.add(child);
35
+ child.once("exit", () => running.delete(child));
36
+ return child;
37
+ }
38
+ async function waitForHealth(port, timeoutMs = 10000) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ let lastError;
41
+ while (Date.now() < deadline) {
42
+ try {
43
+ const response = await fetch(`http://${LOOPBACK}:${port}/health`);
44
+ if (response.ok)
45
+ return;
46
+ }
47
+ catch (error) {
48
+ lastError = error;
49
+ }
50
+ await new Promise(resolve => setTimeout(resolve, 100));
51
+ }
52
+ throw new Error(`server did not become healthy: ${String(lastError)}`);
53
+ }
54
+ function openSseConnection(port) {
55
+ let statusCode;
56
+ let text = "";
57
+ let resolveEnded = () => { };
58
+ const ended = new Promise(resolve => {
59
+ resolveEnded = resolve;
60
+ });
61
+ const request = http.get({
62
+ host: LOOPBACK,
63
+ port,
64
+ path: "/sse",
65
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
66
+ }, response => {
67
+ statusCode = response.statusCode;
68
+ response.on("data", chunk => {
69
+ text += chunk.toString();
70
+ });
71
+ response.on("end", () => resolveEnded());
72
+ response.on("close", () => resolveEnded());
73
+ });
74
+ request.on("error", () => resolveEnded());
75
+ return {
76
+ get statusCode() {
77
+ return statusCode;
78
+ },
79
+ streamText: () => text,
80
+ close: () => {
81
+ request.destroy();
82
+ resolveEnded();
83
+ },
84
+ ended,
85
+ };
86
+ }
87
+ async function waitFor(predicate, timeoutMs = 5000, stepMs = 50) {
88
+ const deadline = Date.now() + timeoutMs;
89
+ while (Date.now() < deadline) {
90
+ if (predicate())
91
+ return true;
92
+ await new Promise(resolve => setTimeout(resolve, stepMs));
93
+ }
94
+ return predicate();
95
+ }
96
+ /** Resolves true when the server ends the stream within the window. */
97
+ async function endedWithin(connection, ms) {
98
+ return Promise.race([
99
+ connection.ended.then(() => true),
100
+ new Promise(resolve => setTimeout(() => resolve(false), ms)),
101
+ ]);
102
+ }
103
+ /** Reads the session id the transport advertises in its opening `endpoint` event. */
104
+ async function readSessionId(connection, timeoutMs = 5000) {
105
+ const deadline = Date.now() + timeoutMs;
106
+ while (Date.now() < deadline) {
107
+ const match = /data: \/messages\?sessionId=([^\s]+)/.exec(connection.streamText());
108
+ if (match)
109
+ return match[1];
110
+ await new Promise(resolve => setTimeout(resolve, 25));
111
+ }
112
+ throw new Error(`SSE stream did not advertise a session id: ${connection.streamText()}`);
113
+ }
114
+ async function postSseMessage(port, sessionId, body) {
115
+ const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=${sessionId}`, {
116
+ method: "POST",
117
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" },
118
+ body: JSON.stringify(body),
119
+ });
120
+ await response.arrayBuffer();
121
+ return response.status;
122
+ }
123
+ async function getSseStatus(port) {
124
+ const response = await fetch(`http://${LOOPBACK}:${port}/sse`, {
125
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
126
+ });
127
+ await response.arrayBuffer();
128
+ return response.status;
129
+ }
130
+ /**
131
+ * Connection attempts are rate-limited per IP, so a capacity slot released
132
+ * asynchronously has to be polled for instead of raced with a single request.
133
+ */
134
+ async function waitForAcceptedConnection(port, timeoutMs = 5000) {
135
+ const deadline = Date.now() + timeoutMs;
136
+ let connection = openSseConnection(port);
137
+ for (;;) {
138
+ await waitFor(() => connection.statusCode !== undefined, 2000, 25);
139
+ if (connection.statusCode === 200)
140
+ return connection;
141
+ connection.close();
142
+ if (Date.now() >= deadline)
143
+ return connection;
144
+ await new Promise(resolve => setTimeout(resolve, 100));
145
+ connection = openSseConnection(port);
146
+ }
147
+ }
148
+ afterEach(() => {
149
+ for (const child of running) {
150
+ if (!child.killed)
151
+ child.kill("SIGTERM");
152
+ }
153
+ running.clear();
154
+ });
155
+ describe("SSE session limits", { concurrency: 1 }, () => {
156
+ test("rejects new SSE connections once MAX_SESSIONS is reached", async () => {
157
+ const port = await findAvailablePort(4500);
158
+ startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
159
+ await waitForHealth(port);
160
+ const first = openSseConnection(port);
161
+ const second = openSseConnection(port);
162
+ const bothConnected = await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000);
163
+ assert.ok(bothConnected, "expected the first two SSE connections to be accepted");
164
+ const rejected = await fetch(`http://${LOOPBACK}:${port}/sse`, {
165
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
166
+ });
167
+ assert.equal(rejected.status, 503);
168
+ const body = (await rejected.json());
169
+ assert.match(body.error ?? "", /capacity/i);
170
+ first.close();
171
+ second.close();
172
+ });
173
+ test("frees the capacity slot when a client closes the stream", async () => {
174
+ const port = await findAvailablePort(4510);
175
+ startSseServer({ MAX_SESSIONS: "2", MAX_REQUESTS_PER_MINUTE: UNTHROTTLED }, port);
176
+ await waitForHealth(port);
177
+ const first = openSseConnection(port);
178
+ const second = openSseConnection(port);
179
+ assert.ok(await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000), "expected the first two SSE connections to be accepted");
180
+ assert.equal(await getSseStatus(port), 503, "expected the server to be at capacity");
181
+ first.close();
182
+ // SESSION_TIMEOUT_SECONDS is left at its 3600s default, so the slot can
183
+ // only come back through the response "close" handler - not the idle sweep.
184
+ const reclaimed = await waitForAcceptedConnection(port);
185
+ assert.equal(reclaimed.statusCode, 200, "closing a client connection should free its capacity slot");
186
+ reclaimed.close();
187
+ second.close();
188
+ });
189
+ test("rate-limits SSE connection creation per client IP", async () => {
190
+ const port = await findAvailablePort(4520);
191
+ startSseServer({ MAX_REQUESTS_PER_MINUTE: "2" }, port);
192
+ await waitForHealth(port);
193
+ const first = openSseConnection(port);
194
+ const second = openSseConnection(port);
195
+ assert.ok(await waitFor(() => first.statusCode === 200 && second.statusCode === 200, 5000), "expected the first two SSE connections to be accepted");
196
+ const rejected = await fetch(`http://${LOOPBACK}:${port}/sse`, {
197
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, Accept: "text/event-stream" },
198
+ });
199
+ assert.equal(rejected.status, 429);
200
+ const body = (await rejected.json());
201
+ assert.equal(body.error, "Rate limit exceeded");
202
+ assert.match(body.message ?? "", /per minute/i);
203
+ first.close();
204
+ second.close();
205
+ });
206
+ test("closes SSE sessions that stay idle", async () => {
207
+ const port = await findAvailablePort(4530);
208
+ startSseServer({ SESSION_TIMEOUT_SECONDS: "2" }, port);
209
+ await waitForHealth(port);
210
+ const connection = openSseConnection(port);
211
+ assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
212
+ const closedByIdleTimeout = await Promise.race([
213
+ connection.ended.then(() => true),
214
+ new Promise(resolve => setTimeout(() => resolve(false), 12000)),
215
+ ]);
216
+ assert.equal(closedByIdleTimeout, true, "idle SSE session should be closed by the server");
217
+ connection.close();
218
+ });
219
+ test("POST /messages keeps an SSE session alive past the idle timeout", async () => {
220
+ const port = await findAvailablePort(4540);
221
+ startSseServer({ SESSION_TIMEOUT_SECONDS: "2" }, port);
222
+ await waitForHealth(port);
223
+ const connection = openSseConnection(port);
224
+ assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
225
+ const sessionId = await readSessionId(connection);
226
+ // Ping across several idle windows; every POST /messages must reset activity.
227
+ const pingStatuses = [];
228
+ const keepAlive = setInterval(() => {
229
+ void postSseMessage(port, sessionId, { jsonrpc: "2.0", id: 1, method: "ping" })
230
+ .then(status => pingStatuses.push(status))
231
+ .catch(() => undefined);
232
+ }, 400);
233
+ try {
234
+ await new Promise(resolve => setTimeout(resolve, 6000));
235
+ }
236
+ finally {
237
+ clearInterval(keepAlive);
238
+ }
239
+ assert.ok(pingStatuses.length >= 8, `expected keep-alive pings to be sent, saw ${pingStatuses.length}`);
240
+ assert.ok(pingStatuses.every(status => status === 202), `expected every ping to be accepted, saw ${JSON.stringify(pingStatuses)}`);
241
+ assert.match(connection.streamText(), /"result"/, "expected a ping response on the stream");
242
+ assert.equal(await endedWithin(connection, 300), false, "POST /messages activity should keep the SSE session open");
243
+ assert.equal(await endedWithin(connection, 8000), true, "the session should close once POST /messages activity stops");
244
+ connection.close();
245
+ });
246
+ test("treats inherited object keys as unknown SSE sessions", async () => {
247
+ const port = await findAvailablePort(4560);
248
+ startSseServer({}, port);
249
+ await waitForHealth(port);
250
+ const response = await fetch(`http://${LOOPBACK}:${port}/messages?sessionId=constructor`, {
251
+ method: "POST",
252
+ headers: { Authorization: `Bearer ${SSE_TOKEN}`, "Content-Type": "application/json" },
253
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "ping" }),
254
+ signal: AbortSignal.timeout(3000),
255
+ });
256
+ assert.equal(response.status, 400);
257
+ assert.match(await response.text(), /No transport found/);
258
+ });
259
+ test("reports degraded health while SSE capacity is exhausted", async () => {
260
+ const port = await findAvailablePort(4550);
261
+ startSseServer({ MAX_SESSIONS: "1" }, port);
262
+ await waitForHealth(port);
263
+ const connection = openSseConnection(port);
264
+ assert.ok(await waitFor(() => connection.statusCode === 200, 5000), "expected the SSE connection to be accepted");
265
+ const degraded = await fetch(`http://${LOOPBACK}:${port}/health`);
266
+ const body = (await degraded.json());
267
+ assert.equal(degraded.status, 503);
268
+ assert.equal(body.status, "degraded");
269
+ assert.equal(body.activeSessions, 1);
270
+ assert.equal(body.maxSessions, 1);
271
+ connection.close();
272
+ });
273
+ });
@@ -72,6 +72,9 @@ describe('download_attachment', () => {
72
72
  mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/image.png`, (_req, res) => { res.set('Content-Type', 'image/png').send(MINIMAL_PNG_BUF); });
73
73
  // Plain-text upload endpoint
74
74
  mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/document.txt`, (_req, res) => { res.set('Content-Type', 'text/plain').send('hello world'); });
75
+ // GitLab redirects uploads to its object storage, so the hop after this response
76
+ // leaves the origin. The destination is a non-public address here.
77
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/uploads/${TEST_SECRET}/redirected.txt`, (_req, res) => { res.redirect('http://169.254.169.254/latest/meta-data/'); });
75
78
  });
76
79
  after(async () => {
77
80
  await mockGitLab.stop();
@@ -126,6 +129,14 @@ describe('download_attachment', () => {
126
129
  raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
127
130
  assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
128
131
  });
132
+ test('a redirect to a non-public address is refused', async () => {
133
+ const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'redirected.txt' }, env);
134
+ const mentionsRefusal = (text) => text.toLowerCase().includes('non-public address');
135
+ const isRpcError = typeof raw.error?.message === 'string' && mentionsRefusal(raw.error.message);
136
+ const isContentError = Array.isArray(raw.result?.content) &&
137
+ raw.result.content.some(c => typeof c.text === 'string' && mentionsRefusal(c.text));
138
+ assert.ok(isRpcError || isContentError, `Should refuse the redirect instead of following it, got ${JSON.stringify(raw)}`);
139
+ });
129
140
  test('local_path through a symlink directory is rejected', async () => {
130
141
  const linkDir = `omc-test-symlink-${RUN_ID}`;
131
142
  fs.rmSync(linkDir, { recursive: true, force: true });
@@ -680,7 +680,7 @@ describe('getEffectiveProjectId', { concurrency: 1 }, () => {
680
680
  },
681
681
  ];
682
682
  for (const { name, args } of calls) {
683
- await assert.rejects(client.callTool(name, args), /path segments/);
683
+ await assert.rejects(client.callTool(name, args), /Cannot use value as a GitLab URL path segment/);
684
684
  }
685
685
  });
686
686
  });
@@ -9,6 +9,8 @@ const MOCK_TOKEN = 'glpat-mock-token-12345';
9
9
  const TEST_PROJECT_ID = '123';
10
10
  const TEST_JOB_ID = '456';
11
11
  const NULL_MODE_JOB_ID = '457';
12
+ const REDIRECT_JOB_ID = '789';
13
+ const REDIRECT_TARGET = 'http://169.254.169.254/latest/meta-data/';
12
14
  const TEST_ENCODED_ARTIFACT_PATH = 'reports/report#1.txt';
13
15
  // Helper to run an MCP tool via the built server
14
16
  async function callTool(toolName, args, env) {
@@ -141,6 +143,14 @@ describe('job artifacts tools', () => {
141
143
  mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/999/artifacts/tree`, (req, res) => {
142
144
  res.status(404).json({ message: 'Not Found' });
143
145
  });
146
+ // GitLab redirects artifact downloads to its object storage, so the hop after this
147
+ // response leaves the origin. The destination is a non-public address here.
148
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${REDIRECT_JOB_ID}/artifacts`, (req, res) => {
149
+ res.redirect(REDIRECT_TARGET);
150
+ });
151
+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${REDIRECT_JOB_ID}/artifacts/report.xml`, (req, res) => {
152
+ res.redirect(REDIRECT_TARGET);
153
+ });
144
154
  await mockGitLab.start();
145
155
  mockGitLabUrl = mockGitLab.getUrl();
146
156
  // Relative temp dir — absolute local_path is rejected as traversal
@@ -250,4 +260,16 @@ describe('job artifacts tools', () => {
250
260
  });
251
261
  assert.strictEqual(result, 'encoded artifact content');
252
262
  });
263
+ test('download_job_artifacts refuses a redirect to a non-public address', async () => {
264
+ await assert.rejects(() => callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: REDIRECT_JOB_ID, local_path: tmpDir }, {
265
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
266
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
267
+ }), (err) => String(err.message || err).toLowerCase().includes('non-public address'));
268
+ });
269
+ test('get_job_artifact_file refuses a redirect to a non-public address', async () => {
270
+ await assert.rejects(() => callTool('get_job_artifact_file', { project_id: TEST_PROJECT_ID, job_id: REDIRECT_JOB_ID, artifact_path: 'report.xml' }, {
271
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
272
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
273
+ }), (err) => String(err.message || err).toLowerCase().includes('non-public address'));
274
+ });
253
275
  });