@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.
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/build/config.js +1 -1
- package/build/index.js +46 -103
- package/build/oauth-proxy.js +151 -87
- package/build/schemas.js +4 -2
- package/build/server/request-helpers.js +63 -0
- package/build/stateless/consumed-proxy-code-cache.js +82 -0
- package/build/stateless/index.js +1 -0
- package/build/stateless/stored-tokens.js +4 -2
- package/build/test/mcp-oauth-tests.js +96 -0
- package/build/test/nullable-gitlab-response-fields.test.js +16 -1
- package/build/test/server/request-helpers.test.js +33 -0
- package/build/test/stateless/callback-proxy.test.js +109 -0
- package/build/test/stateless/consumed-proxy-code-cache.test.js +150 -0
- package/build/test/test-download-attachment.js +27 -0
- package/build/test/test-get-file-contents.js +128 -0
- package/build/test/test-job-artifacts.js +27 -2
- package/build/test/test-merge-request-approval-state-tools.js +42 -0
- package/build/test/test-upload-markdown.js +38 -7
- package/build/test/utils/helpers-path.test.js +104 -0
- package/build/utils/helpers.js +205 -0
- package/build/utils/logger.js +4 -0
- package/package.json +2 -2
|
@@ -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
|
-
//
|
|
127
|
-
tmpDir =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
+
});
|
package/build/utils/helpers.js
CHANGED
|
@@ -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
|
*/
|
package/build/utils/logger.js
CHANGED
|
@@ -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.
|
|
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",
|