@zereight/mcp-gitlab 2.1.46 → 2.1.47

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.
@@ -0,0 +1,128 @@
1
+ import assert from "node:assert";
2
+ import { after, before, describe, test } from "node:test";
3
+ import { Buffer } from "node:buffer";
4
+ import { CustomHeaderClient } from "./clients/custom-header-client.js";
5
+ import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
6
+ import { findMockServerPort, MockGitLabServer } from "./utils/mock-gitlab-server.js";
7
+ const MOCK_TOKEN = "glpat-get-file-contents-test-token";
8
+ const TEST_PROJECT_ID = "123";
9
+ describe("get_file_contents", { timeout: 20_000 }, () => {
10
+ let mockGitLab;
11
+ let server;
12
+ let mcpUrl;
13
+ before(async () => {
14
+ const binaryContent = Buffer.from([
15
+ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0xc7, 0xff, 0xd8, 0x00,
16
+ ]).toString("base64");
17
+ const mockPort = await findMockServerPort();
18
+ mockGitLab = new MockGitLabServer({
19
+ port: mockPort,
20
+ validTokens: [MOCK_TOKEN],
21
+ });
22
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/sample.pdf`, (_req, res) => {
23
+ res.json({
24
+ file_name: "sample.pdf",
25
+ file_path: "sample.pdf",
26
+ encoding: "base64",
27
+ content: binaryContent,
28
+ });
29
+ });
30
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/README.md`, (_req, res) => {
31
+ res.json({
32
+ file_name: "README.md",
33
+ file_path: "README.md",
34
+ encoding: "base64",
35
+ content: Buffer.from("Hello, 世界\n", "utf8").toString("base64"),
36
+ });
37
+ });
38
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/repository/files/empty.txt`, (_req, res) => {
39
+ res.json({
40
+ file_name: "empty.txt",
41
+ file_path: "empty.txt",
42
+ encoding: "base64",
43
+ content: "",
44
+ });
45
+ });
46
+ await mockGitLab.start();
47
+ const port = await findAvailablePort(3480);
48
+ server = await launchServer({
49
+ mode: TransportMode.STREAMABLE_HTTP,
50
+ port,
51
+ timeout: 10_000,
52
+ env: {
53
+ STREAMABLE_HTTP: "true",
54
+ REMOTE_AUTHORIZATION: "true",
55
+ GITLAB_API_URL: `${mockGitLab.getUrl()}/api/v4`,
56
+ },
57
+ });
58
+ mcpUrl = `http://${HOST}:${port}/mcp`;
59
+ });
60
+ after(async () => {
61
+ cleanupServers([server]);
62
+ if (mockGitLab)
63
+ await mockGitLab.stop();
64
+ });
65
+ test("preserves non-UTF-8 file content as base64", async () => {
66
+ const expectedContent = Buffer.from([
67
+ 0x25, 0x50, 0x44, 0x46, 0x2d, 0x31, 0x2e, 0x37, 0x0a, 0xc7, 0xff, 0xd8, 0x00,
68
+ ]).toString("base64");
69
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
70
+ await client.connect(mcpUrl);
71
+ try {
72
+ const result = await Promise.race([
73
+ client.callTool("get_file_contents", {
74
+ project_id: TEST_PROJECT_ID,
75
+ file_path: "sample.pdf",
76
+ ref: "main",
77
+ }),
78
+ new Promise((_, reject) => {
79
+ const timer = setTimeout(() => reject(new Error("get_file_contents timed out")), 5_000);
80
+ timer.unref();
81
+ }),
82
+ ]);
83
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
84
+ const file = JSON.parse(responseText);
85
+ assert.strictEqual(file.content, expectedContent);
86
+ assert.strictEqual(file.encoding, "base64");
87
+ }
88
+ finally {
89
+ await client.disconnect();
90
+ }
91
+ });
92
+ test("decodes valid UTF-8 file content", async () => {
93
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
94
+ await client.connect(mcpUrl);
95
+ try {
96
+ const result = await client.callTool("get_file_contents", {
97
+ project_id: TEST_PROJECT_ID,
98
+ file_path: "README.md",
99
+ ref: "main",
100
+ });
101
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
102
+ const file = JSON.parse(responseText);
103
+ assert.strictEqual(file.content, "Hello, 世界\n");
104
+ assert.strictEqual(file.encoding, "utf8");
105
+ }
106
+ finally {
107
+ await client.disconnect();
108
+ }
109
+ });
110
+ test("decodes empty file content as UTF-8", async () => {
111
+ const client = new CustomHeaderClient({ Authorization: `Bearer ${MOCK_TOKEN}` });
112
+ await client.connect(mcpUrl);
113
+ try {
114
+ const result = await client.callTool("get_file_contents", {
115
+ project_id: TEST_PROJECT_ID,
116
+ file_path: "empty.txt",
117
+ ref: "main",
118
+ });
119
+ const responseText = result.content?.[0]?.type === "text" ? result.content[0].text : "";
120
+ const file = JSON.parse(responseText);
121
+ assert.strictEqual(file.content, "");
122
+ assert.strictEqual(file.encoding, "utf8");
123
+ }
124
+ finally {
125
+ await client.disconnect();
126
+ }
127
+ });
128
+ });
@@ -123,8 +123,9 @@ describe('job artifacts tools', () => {
123
123
  });
124
124
  await mockGitLab.start();
125
125
  mockGitLabUrl = mockGitLab.getUrl();
126
- // Create a temp directory for download tests
127
- tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitlab-mcp-test-'));
126
+ // Relative temp dir — absolute local_path is rejected as traversal
127
+ tmpDir = `gitlab-mcp-test-artifacts-${process.pid}`;
128
+ fs.mkdirSync(tmpDir, { recursive: true });
128
129
  });
129
130
  after(async () => {
130
131
  await mockGitLab.stop();
@@ -167,6 +168,30 @@ describe('job artifacts tools', () => {
167
168
  assert.ok(fs.existsSync(result.file_path), `File should exist at ${result.file_path}`);
168
169
  assert.ok(fs.existsSync(nestedLocalPath), `Directory should be created at ${nestedLocalPath}`);
169
170
  });
171
+ test('download_job_artifacts rejects local_path with directory traversal', async () => {
172
+ await assert.rejects(() => callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: '../../../tmp' }, {
173
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
174
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
175
+ }), (err) => {
176
+ const msg = String(err.message || err).toLowerCase();
177
+ return msg.includes('traversal') || msg.includes('invalid');
178
+ });
179
+ });
180
+ test('download_job_artifacts rejects symlink local_path escape', async () => {
181
+ const linkName = path.join(tmpDir, 'symlink-escape');
182
+ fs.symlinkSync(os.tmpdir(), linkName);
183
+ await assert.rejects(() => callTool('download_job_artifacts', { project_id: TEST_PROJECT_ID, job_id: TEST_JOB_ID, local_path: linkName }, {
184
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
185
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
186
+ }), (err) => {
187
+ const msg = String(err.message || err).toLowerCase();
188
+ return (msg.includes('symbolic') ||
189
+ msg.includes('symlink') ||
190
+ msg.includes('escapes') ||
191
+ msg.includes('traversal') ||
192
+ msg.includes('invalid'));
193
+ });
194
+ });
170
195
  test('get_job_artifact_file returns file content', async () => {
171
196
  const result = await callTool('get_job_artifact_file', {
172
197
  project_id: TEST_PROJECT_ID,
@@ -6,6 +6,7 @@ const MOCK_TOKEN = "glpat-mock-token-approval";
6
6
  const TEST_PROJECT_ID = "123";
7
7
  const TEST_MR_IID_WITH_FALLBACK = "88";
8
8
  const TEST_MR_IID_WITH_APPROVAL_STATE = "89";
9
+ const TEST_MR_IID_WITHOUT_RULES = "90";
9
10
  async function callTool(toolName, args, env) {
10
11
  return new Promise((resolve, reject) => {
11
12
  const proc = spawn("node", ["build/index.js"], {
@@ -133,6 +134,33 @@ describe("merge request approval state tools", () => {
133
134
  ],
134
135
  });
135
136
  });
137
+ // A project without approval rules answers 200 with an empty rules array, while
138
+ // the approval itself is only visible on /approvals.
139
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITHOUT_RULES}/approval_state`, (_req, res) => {
140
+ res.json({
141
+ approval_rules_overwritten: false,
142
+ rules: [],
143
+ });
144
+ });
145
+ mockGitLab.addMockHandler("get", `/projects/${TEST_PROJECT_ID}/merge_requests/${TEST_MR_IID_WITHOUT_RULES}/approvals`, (_req, res) => {
146
+ res.json({
147
+ approved: true,
148
+ user_has_approved: false,
149
+ user_can_approve: true,
150
+ approved_by: [
151
+ {
152
+ user: {
153
+ id: "35",
154
+ username: "sergey.kravchenya",
155
+ name: "Sergey Kravchenya",
156
+ state: "active",
157
+ avatar_url: "https://gitlab.mock/uploads/avatar.png",
158
+ web_url: "https://gitlab.mock/sergey.kravchenya",
159
+ },
160
+ },
161
+ ],
162
+ });
163
+ });
136
164
  await mockGitLab.start();
137
165
  mockGitLabUrl = mockGitLab.getUrl();
138
166
  });
@@ -153,6 +181,20 @@ describe("merge request approval state tools", () => {
153
181
  assert.ok(Array.isArray(result.approved_by));
154
182
  assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
155
183
  });
184
+ test("finds approvers via approvals when approval_state reports no rules", async () => {
185
+ const result = await callTool("get_merge_request_approval_state", {
186
+ project_id: TEST_PROJECT_ID,
187
+ merge_request_iid: TEST_MR_IID_WITHOUT_RULES,
188
+ }, {
189
+ GITLAB_API_URL: `${mockGitLabUrl}/api/v4`,
190
+ GITLAB_PERSONAL_ACCESS_TOKEN: MOCK_TOKEN,
191
+ });
192
+ assert.strictEqual(result.source_endpoint, "approvals");
193
+ assert.strictEqual(result.approved, true);
194
+ assert.deepStrictEqual(result.approved_by_usernames, ["sergey.kravchenya"]);
195
+ assert.ok(Array.isArray(result.approved_by));
196
+ assert.strictEqual(result.approved_by[0].username, "sergey.kravchenya");
197
+ });
156
198
  test("returns deduplicated approvers from approval_state rules", async () => {
157
199
  const result = await callTool("get_merge_request_approval_state", {
158
200
  project_id: TEST_PROJECT_ID,
@@ -89,7 +89,7 @@ describe('upload_markdown', () => {
89
89
  await mockGitLab.stop();
90
90
  });
91
91
  test('Content-Type is multipart/form-data with a boundary', async () => {
92
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-ct-test.txt');
92
+ const tmpFile = `mcp-upload-ct-test-${process.pid}.txt`;
93
93
  fs.writeFileSync(tmpFile, 'content-type test');
94
94
  try {
95
95
  await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -102,7 +102,7 @@ describe('upload_markdown', () => {
102
102
  }
103
103
  });
104
104
  test('multipart body contains a "file" field with the file content', async () => {
105
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-body-test.txt');
105
+ const tmpFile = `mcp-upload-body-test-${process.pid}.txt`;
106
106
  const fileContent = 'hello from multipart upload test';
107
107
  fs.writeFileSync(tmpFile, fileContent);
108
108
  try {
@@ -116,19 +116,19 @@ describe('upload_markdown', () => {
116
116
  }
117
117
  });
118
118
  test('multipart body includes the original filename', async () => {
119
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-filename-check.txt');
119
+ const tmpFile = `mcp-upload-filename-check-${process.pid}.txt`;
120
120
  fs.writeFileSync(tmpFile, 'filename check');
121
121
  try {
122
122
  await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
123
123
  assert.ok(lastRawBody, 'Request body must be captured');
124
- assert.ok(lastRawBody.includes('mcp-upload-filename-check.txt'), 'Multipart body should include the original filename');
124
+ assert.ok(lastRawBody.includes(`mcp-upload-filename-check-${process.pid}.txt`), 'Multipart body should include the original filename');
125
125
  }
126
126
  finally {
127
127
  fs.unlinkSync(tmpFile);
128
128
  }
129
129
  });
130
130
  test('returns markdown, url, alt, and full_path from upload response', async () => {
131
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-response-test.txt');
131
+ const tmpFile = `mcp-upload-response-test-${process.pid}.txt`;
132
132
  fs.writeFileSync(tmpFile, 'response field test');
133
133
  try {
134
134
  const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -148,7 +148,7 @@ describe('upload_markdown', () => {
148
148
  test('accepts upload responses without id from older self-hosted GitLab', async () => {
149
149
  const { id: _id, ...idlessUploadResponse } = MOCK_UPLOAD_RESPONSE;
150
150
  uploadResponse = idlessUploadResponse;
151
- const tmpFile = path.join(os.tmpdir(), 'mcp-upload-idless-response-test.txt');
151
+ const tmpFile = `mcp-upload-idless-response-test-${process.pid}.txt`;
152
152
  fs.writeFileSync(tmpFile, 'idless response field test');
153
153
  try {
154
154
  const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: tmpFile }, env);
@@ -167,9 +167,40 @@ describe('upload_markdown', () => {
167
167
  }
168
168
  });
169
169
  test('returns an error when the file does not exist', async () => {
170
- const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/nonexistent/no-such-file.txt' }, env);
170
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: `mcp-upload-missing-${process.pid}.txt` }, env);
171
171
  const hasError = typeof raw.error?.message === 'string' ||
172
172
  raw.result?.content?.some(c => c.text && (c.text.toLowerCase().includes('not found') || c.text.toLowerCase().includes('error')));
173
173
  assert.ok(hasError, 'Should return an error for a nonexistent file path');
174
174
  });
175
+ test('rejects absolute file_path (path traversal / arbitrary read)', async () => {
176
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '/etc/passwd' }, env);
177
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
178
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
179
+ assert.ok(msg.toLowerCase().includes('traversal') || msg.toLowerCase().includes('invalid'), `Expected traversal rejection, got: ${msg}`);
180
+ });
181
+ test('rejects file_path with ".." traversal', async () => {
182
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: '../../../etc/passwd' }, env);
183
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
184
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
185
+ assert.ok(msg.toLowerCase().includes('traversal') || msg.toLowerCase().includes('invalid'), `Expected traversal rejection, got: ${msg}`);
186
+ });
187
+ test('rejects file_path that is a symlink escape', async () => {
188
+ const linkName = `mcp-upload-symlink-${process.pid}.txt`;
189
+ const target = path.join(os.tmpdir(), `mcp-upload-secret-${process.pid}.txt`);
190
+ fs.writeFileSync(target, 'secret-content');
191
+ fs.symlinkSync(target, linkName);
192
+ try {
193
+ const raw = await callUploadMarkdown({ project_id: TEST_PROJECT_ID, file_path: linkName }, env);
194
+ const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
195
+ (raw.result?.content?.map(c => c.text || '').join(' ') || '');
196
+ assert.ok(msg.toLowerCase().includes('symbolic') ||
197
+ msg.toLowerCase().includes('symlink') ||
198
+ msg.toLowerCase().includes('escapes') ||
199
+ msg.toLowerCase().includes('invalid'), `Expected symlink rejection, got: ${msg}`);
200
+ }
201
+ finally {
202
+ fs.rmSync(linkName, { force: true });
203
+ fs.rmSync(target, { force: true });
204
+ }
205
+ });
175
206
  });
@@ -0,0 +1,104 @@
1
+ import { describe, test } from "node:test";
2
+ import assert from "node:assert";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { assertSafeRelativePath, openSafeOutputWriteStream, readSafeExistingFile, resolveSafeExistingPath, resolveSafeOutputDir, resolveSafeOutputFile, } from "../../utils/helpers.js";
7
+ describe("assertSafeRelativePath", () => {
8
+ test("allows relative paths", () => {
9
+ assert.strictEqual(assertSafeRelativePath("uploads"), "uploads");
10
+ assert.strictEqual(assertSafeRelativePath("uploads/nested"), path.normalize("uploads/nested"));
11
+ });
12
+ test("rejects absolute paths", () => {
13
+ assert.throws(() => assertSafeRelativePath("/etc/passwd", "file_path"), /traversal/);
14
+ });
15
+ test("rejects parent-directory traversal", () => {
16
+ assert.throws(() => assertSafeRelativePath("../../../tmp", "local_path"), /traversal/);
17
+ assert.throws(() => assertSafeRelativePath("foo/../../etc", "local_path"), /traversal/);
18
+ });
19
+ });
20
+ describe("resolveSafeExistingPath / resolveSafeOutputDir", () => {
21
+ let base;
22
+ test("setup base dir", () => {
23
+ base = fs.mkdtempSync(path.join(os.tmpdir(), "safe-path-"));
24
+ });
25
+ test("resolves an existing file inside the base", () => {
26
+ const file = path.join(base, "ok.txt");
27
+ fs.writeFileSync(file, "hello");
28
+ const resolved = resolveSafeExistingPath("ok.txt", "file_path", base);
29
+ assert.strictEqual(resolved, fs.realpathSync(file));
30
+ });
31
+ test("rejects symlink file escape on read", () => {
32
+ const target = path.join(os.tmpdir(), `outside-target-${process.pid}.txt`);
33
+ fs.writeFileSync(target, "secret");
34
+ fs.symlinkSync(target, path.join(base, "link.txt"));
35
+ try {
36
+ assert.throws(() => resolveSafeExistingPath("link.txt", "file_path", base), /symbolic links|escapes/);
37
+ }
38
+ finally {
39
+ fs.unlinkSync(target);
40
+ }
41
+ });
42
+ test("rejects symlink directory escape on write", () => {
43
+ fs.symlinkSync(os.tmpdir(), path.join(base, "escape-dir"));
44
+ assert.throws(() => resolveSafeOutputDir("escape-dir", "local_path", base), /symbolic links|escapes/);
45
+ });
46
+ test("creates nested output dirs that stay inside the base", () => {
47
+ const out = resolveSafeOutputDir(path.join("artifacts", "run-1"), "local_path", base);
48
+ const baseReal = fs.realpathSync(base);
49
+ const rel = path.relative(baseReal, out);
50
+ assert.ok(rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel)));
51
+ assert.ok(fs.statSync(out).isDirectory());
52
+ });
53
+ test("resolveSafeOutputFile rejects destination file symlink", () => {
54
+ const outDir = path.join(base, "out");
55
+ fs.mkdirSync(outDir, { recursive: true });
56
+ const target = path.join(os.tmpdir(), `outside-write-${process.pid}.txt`);
57
+ fs.writeFileSync(target, "before");
58
+ fs.symlinkSync(target, path.join(outDir, "payload.bin"));
59
+ try {
60
+ assert.throws(() => resolveSafeOutputFile("payload.bin", "out", "local_path", base), /symbolic links/);
61
+ }
62
+ finally {
63
+ fs.unlinkSync(target);
64
+ }
65
+ });
66
+ test("resolveSafeOutputFile returns contained path for new files", () => {
67
+ const dest = resolveSafeOutputFile("fresh.bin", "downloads", "local_path", base);
68
+ assert.ok(dest.startsWith(fs.realpathSync(base) + path.sep));
69
+ assert.ok(dest.endsWith(`${path.sep}fresh.bin`));
70
+ });
71
+ test("anchors to cwd snapshot when baseDir is omitted", () => {
72
+ const nested = path.join(base, "anchored");
73
+ fs.mkdirSync(nested, { recursive: true });
74
+ const file = path.join(nested, "data.txt");
75
+ fs.writeFileSync(file, "anchored");
76
+ const originalCwd = process.cwd();
77
+ try {
78
+ process.chdir(nested);
79
+ const resolved = resolveSafeExistingPath("data.txt", "file_path");
80
+ assert.strictEqual(resolved, fs.realpathSync(file));
81
+ }
82
+ finally {
83
+ process.chdir(originalCwd);
84
+ }
85
+ });
86
+ test("readSafeExistingFile reads without returning a reusable path", () => {
87
+ const file = path.join(base, "inline.txt");
88
+ fs.writeFileSync(file, "payload");
89
+ const { buffer, basename } = readSafeExistingFile("inline.txt", "file_path", base);
90
+ assert.strictEqual(buffer.toString(), "payload");
91
+ assert.strictEqual(basename, "inline.txt");
92
+ });
93
+ test("openSafeOutputWriteStream writes without a separate createWriteStream call", async () => {
94
+ const { stream, path: dest } = openSafeOutputWriteStream("streamed.bin", "downloads", "local_path", base);
95
+ await new Promise((resolve, reject) => {
96
+ stream.on("error", reject);
97
+ stream.end("streamed", () => resolve());
98
+ });
99
+ assert.strictEqual(fs.readFileSync(dest, "utf8"), "streamed");
100
+ });
101
+ test("cleanup base dir", () => {
102
+ fs.rmSync(base, { recursive: true, force: true });
103
+ });
104
+ });
@@ -1,3 +1,208 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * Reject absolute paths and directory-traversal sequences for user-supplied
5
+ * local filesystem paths (e.g. local_path / file_path tool arguments).
6
+ *
7
+ * Returns the normalized relative path on success. This is a lexical check
8
+ * only — callers that touch the filesystem must also use
9
+ * {@link resolveSafeExistingPath}, {@link resolveSafeOutputDir}, or
10
+ * {@link resolveSafeOutputFile}.
11
+ */
12
+ export function assertSafeRelativePath(inputPath, label = "path") {
13
+ const normalized = path.normalize(inputPath);
14
+ if (path.isAbsolute(normalized) ||
15
+ normalized === ".." ||
16
+ normalized.startsWith(".." + path.sep) ||
17
+ normalized.includes(path.sep + ".." + path.sep)) {
18
+ throw new Error(`Invalid ${label}: directory traversal is not allowed.`);
19
+ }
20
+ return normalized;
21
+ }
22
+ /** Snapshot and canonicalize the trusted base directory once per operation. */
23
+ function resolveTrustedBaseDir(baseDir) {
24
+ const anchor = baseDir ?? process.cwd();
25
+ return fs.realpathSync(anchor);
26
+ }
27
+ function isInsideBase(candidate, baseReal) {
28
+ const relative = path.relative(baseReal, candidate);
29
+ // "" = same path; absolute relative = different Windows volume root.
30
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
31
+ }
32
+ /**
33
+ * Walk each path component under baseReal and reject symbolic links so a
34
+ * repository-controlled symlink cannot escape the trusted base directory.
35
+ */
36
+ function assertNoSymlinkComponents(baseReal, relative, label) {
37
+ const parts = relative.split(path.sep).filter(part => part.length > 0 && part !== ".");
38
+ let current = baseReal;
39
+ for (const part of parts) {
40
+ const next = path.join(current, part);
41
+ let st;
42
+ try {
43
+ st = fs.lstatSync(next);
44
+ }
45
+ catch (err) {
46
+ const code = err.code;
47
+ if (code === "ENOENT") {
48
+ // Remaining components do not exist yet (typical for mkdir output dirs).
49
+ return;
50
+ }
51
+ throw err;
52
+ }
53
+ if (st.isSymbolicLink()) {
54
+ throw new Error(`Invalid ${label}: symbolic links are not allowed.`);
55
+ }
56
+ current = next;
57
+ }
58
+ }
59
+ /**
60
+ * Resolve a user-supplied relative path to an existing file/dir that is
61
+ * guaranteed to stay inside `baseDir` after symlink resolution.
62
+ */
63
+ export function resolveSafeExistingPath(inputPath, label = "path", baseDir) {
64
+ const relative = assertSafeRelativePath(inputPath, label);
65
+ const baseReal = resolveTrustedBaseDir(baseDir);
66
+ assertNoSymlinkComponents(baseReal, relative, label);
67
+ const absolute = path.resolve(baseReal, relative);
68
+ if (!isInsideBase(absolute, baseReal)) {
69
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
70
+ }
71
+ if (!fs.existsSync(absolute)) {
72
+ throw new Error(`File not found: ${relative}`);
73
+ }
74
+ const real = fs.realpathSync(absolute);
75
+ if (!isInsideBase(real, baseReal)) {
76
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
77
+ }
78
+ return real;
79
+ }
80
+ /**
81
+ * Resolve a user-supplied relative output directory under `baseDir`, creating
82
+ * it if needed. Rejects symlink components and verifies the final real path
83
+ * remains inside the base.
84
+ */
85
+ export function resolveSafeOutputDir(inputDir, label = "local_path", baseDir) {
86
+ const relative = assertSafeRelativePath(inputDir, label);
87
+ const baseReal = resolveTrustedBaseDir(baseDir);
88
+ assertNoSymlinkComponents(baseReal, relative, label);
89
+ const absolute = path.resolve(baseReal, relative);
90
+ // Reject escapes before mkdir so we never create dirs outside the base.
91
+ if (!isInsideBase(absolute, baseReal)) {
92
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
93
+ }
94
+ fs.mkdirSync(absolute, { recursive: true });
95
+ const real = fs.realpathSync(absolute);
96
+ if (!isInsideBase(real, baseReal)) {
97
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
98
+ }
99
+ return real;
100
+ }
101
+ /**
102
+ * Build a write destination under a trusted directory. Rejects an existing
103
+ * destination symlink so createWriteStream cannot follow it outside the base.
104
+ *
105
+ * When `inputDir` is omitted, the file is resolved under `baseDir` itself
106
+ * (still rejecting symlink destinations).
107
+ */
108
+ export function resolveSafeOutputFile(filename, inputDir, label = "local_path", baseDir) {
109
+ if (!filename ||
110
+ filename === "." ||
111
+ filename === ".." ||
112
+ filename.includes("/") ||
113
+ filename.includes("\\") ||
114
+ filename.includes(path.sep)) {
115
+ throw new Error(`Invalid ${label}: directory separators are not allowed in filename.`);
116
+ }
117
+ const baseReal = resolveTrustedBaseDir(baseDir);
118
+ const dirReal = inputDir ? resolveSafeOutputDir(inputDir, label, baseReal) : baseReal;
119
+ const dest = path.join(dirReal, filename);
120
+ try {
121
+ const st = fs.lstatSync(dest);
122
+ if (st.isSymbolicLink()) {
123
+ throw new Error(`Invalid ${label}: symbolic links are not allowed.`);
124
+ }
125
+ }
126
+ catch (err) {
127
+ const code = err.code;
128
+ if (code !== "ENOENT") {
129
+ throw err;
130
+ }
131
+ }
132
+ return dest;
133
+ }
134
+ const READ_OPEN_FLAGS = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
135
+ const WRITE_OPEN_FLAGS = fs.constants.O_WRONLY |
136
+ fs.constants.O_CREAT |
137
+ fs.constants.O_TRUNC |
138
+ (fs.constants.O_NOFOLLOW ?? 0);
139
+ /**
140
+ * Validate and read a file in one step so callers never reuse a path string
141
+ * after a symlink swap (final-component TOCTOU mitigation via O_NOFOLLOW).
142
+ */
143
+ export function readSafeExistingFile(inputPath, label = "path", baseDir) {
144
+ const relative = assertSafeRelativePath(inputPath, label);
145
+ const baseReal = resolveTrustedBaseDir(baseDir);
146
+ assertNoSymlinkComponents(baseReal, relative, label);
147
+ const absolute = path.resolve(baseReal, relative);
148
+ if (!isInsideBase(absolute, baseReal)) {
149
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
150
+ }
151
+ if (!fs.existsSync(absolute)) {
152
+ throw new Error(`File not found: ${relative}`);
153
+ }
154
+ const fd = fs.openSync(absolute, READ_OPEN_FLAGS);
155
+ try {
156
+ const st = fs.fstatSync(fd);
157
+ if (st.isSymbolicLink()) {
158
+ throw new Error(`Invalid ${label}: symbolic links are not allowed.`);
159
+ }
160
+ const real = fs.realpathSync(absolute);
161
+ if (!isInsideBase(real, baseReal)) {
162
+ throw new Error(`Invalid ${label}: path escapes the allowed directory.`);
163
+ }
164
+ return { buffer: fs.readFileSync(fd), basename: path.basename(relative) };
165
+ }
166
+ finally {
167
+ fs.closeSync(fd);
168
+ }
169
+ }
170
+ /**
171
+ * Validate an output destination and open a write stream immediately so the
172
+ * path is not handed back for a separate createWriteStream call.
173
+ */
174
+ export function openSafeOutputWriteStream(filename, inputDir, label = "local_path", baseDir) {
175
+ if (!filename ||
176
+ filename === "." ||
177
+ filename === ".." ||
178
+ filename.includes("/") ||
179
+ filename.includes("\\") ||
180
+ filename.includes(path.sep)) {
181
+ throw new Error(`Invalid ${label}: directory separators are not allowed in filename.`);
182
+ }
183
+ const dirReal = inputDir
184
+ ? resolveSafeOutputDir(inputDir, label, baseDir)
185
+ : resolveTrustedBaseDir(baseDir);
186
+ const dest = path.join(dirReal, filename);
187
+ try {
188
+ const st = fs.lstatSync(dest);
189
+ if (st.isSymbolicLink()) {
190
+ throw new Error(`Invalid ${label}: symbolic links are not allowed.`);
191
+ }
192
+ }
193
+ catch (err) {
194
+ const code = err.code;
195
+ if (code !== "ENOENT") {
196
+ throw err;
197
+ }
198
+ }
199
+ const fd = fs.openSync(dest, WRITE_OPEN_FLAGS, 0o666);
200
+ const stream = fs.createWriteStream(null, {
201
+ fd,
202
+ autoClose: true,
203
+ });
204
+ return { stream, path: dest };
205
+ }
1
206
  /**
2
207
  * Estimate the number of merge commits that will be added based on the merge method.
3
208
  */
@@ -18,6 +18,10 @@ const REDACT_PATHS = [
18
18
  "*.sessionId",
19
19
  "ctx.sessionId",
20
20
  "context.sessionId",
21
+ "approval_password",
22
+ "*.approval_password",
23
+ "arguments.approval_password",
24
+ "params.arguments.approval_password",
21
25
  ];
22
26
  /**
23
27
  * Create a pino logger with consistent redaction, level, format, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zereight/mcp-gitlab",
3
- "version": "2.1.46",
3
+ "version": "2.1.47",
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": [
@@ -53,7 +53,7 @@
53
53
  "test": "npm run test:all",
54
54
  "test:all": "npm run build && npm run test:mock && npm run test:live",
55
55
  "test:mock": "bash scripts/run-mock-tests.sh",
56
- "test:stateless": "npm run build && node --import tsx/esm --test --experimental-test-isolation=none 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
+ "test:stateless": "npm run build && node --import tsx/esm --test --experimental-test-isolation=none 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 test/stateless/consumed-proxy-code-cache.test.ts",
57
57
  "test:mcp-oauth": "npm run build && node --import tsx/esm --test test/mcp-oauth-tests.ts",
58
58
  "test:live": "node test/validate-api.js",
59
59
  "test:consumer-smoke": "bash scripts/consumer-install-smoke.sh",