@zereight/mcp-gitlab 2.1.46 → 2.1.48
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 +138 -111
- package/build/oauth-proxy.js +151 -87
- package/build/schemas.js +4 -2
- package/build/scripts/generate-server-card.js +39 -0
- package/build/server/request-helpers.js +75 -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 +38 -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/streamable-http-sse-stream.test.js +350 -0
- package/build/test/test-all-transport-server.js +9 -3
- 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
|
@@ -216,11 +216,17 @@ describe('GitLab MCP Server - Streamable HTTP Transport', () => {
|
|
|
216
216
|
cleanup();
|
|
217
217
|
console.log('Client disconnected from Streamable HTTP server');
|
|
218
218
|
});
|
|
219
|
-
test('should return
|
|
219
|
+
test('should return 406 for GET /mcp without Accept: text/event-stream', async () => {
|
|
220
220
|
const response = await fetch(`http://${HOST}:${port}/mcp`);
|
|
221
|
-
assert.strictEqual(response.status,
|
|
221
|
+
assert.strictEqual(response.status, 406, 'GET /mcp should respond with 406 when Accept header is missing text/event-stream');
|
|
222
222
|
const body = await response.json();
|
|
223
|
-
assert.strictEqual(body?.error, '
|
|
223
|
+
assert.strictEqual(body?.error, 'Not Acceptable');
|
|
224
|
+
});
|
|
225
|
+
test('should return 405 for unsupported HTTP methods like PUT /mcp', async () => {
|
|
226
|
+
const response = await fetch(`http://${HOST}:${port}/mcp`, { method: 'PUT' });
|
|
227
|
+
assert.strictEqual(response.status, 405, 'PUT /mcp should respond with 405');
|
|
228
|
+
const allowHeader = response.headers.get('allow');
|
|
229
|
+
assert.ok(allowHeader?.includes('GET') && allowHeader?.includes('POST') && allowHeader?.includes('DELETE'), 'Allow header should contain GET, POST, DELETE');
|
|
224
230
|
});
|
|
225
231
|
test('should list tools via Streamable HTTP', async () => {
|
|
226
232
|
const tools = await client.listTools();
|
|
@@ -2,6 +2,7 @@ import { describe, test, before, after } from 'node:test';
|
|
|
2
2
|
import assert from 'node:assert';
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
5
6
|
import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';
|
|
6
7
|
const MOCK_TOKEN = 'glpat-mock-token-12345';
|
|
7
8
|
const TEST_PROJECT_ID = '123';
|
|
@@ -125,6 +126,32 @@ describe('download_attachment', () => {
|
|
|
125
126
|
raw.result.content.some(c => typeof c.text === 'string' && c.text.toLowerCase().includes('traversal'));
|
|
126
127
|
assert.ok(isRpcError || isContentError, 'Should return an error mentioning directory traversal');
|
|
127
128
|
});
|
|
129
|
+
test('local_path through a symlink directory is rejected', async () => {
|
|
130
|
+
const linkDir = `omc-test-symlink-${RUN_ID}`;
|
|
131
|
+
fs.rmSync(linkDir, { recursive: true, force: true });
|
|
132
|
+
fs.symlinkSync(os.tmpdir(), linkDir);
|
|
133
|
+
try {
|
|
134
|
+
const raw = await callDownloadAttachment({
|
|
135
|
+
project_id: TEST_PROJECT_ID,
|
|
136
|
+
secret: TEST_SECRET,
|
|
137
|
+
filename: 'image.png',
|
|
138
|
+
local_path: linkDir,
|
|
139
|
+
}, env);
|
|
140
|
+
const msg = (typeof raw.error?.message === 'string' ? raw.error.message : '') +
|
|
141
|
+
(Array.isArray(raw.result?.content)
|
|
142
|
+
? raw.result.content.map(c => c.text || '').join(' ')
|
|
143
|
+
: '');
|
|
144
|
+
const lower = msg.toLowerCase();
|
|
145
|
+
assert.ok(lower.includes('symbolic') ||
|
|
146
|
+
lower.includes('symlink') ||
|
|
147
|
+
lower.includes('escapes') ||
|
|
148
|
+
lower.includes('traversal') ||
|
|
149
|
+
lower.includes('invalid'), `Expected symlink rejection, got: ${msg}`);
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
fs.rmSync(linkDir, { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
128
155
|
test('filename with directory traversal is rejected before writing to disk', async () => {
|
|
129
156
|
const raw = await callDownloadAttachment({ project_id: TEST_PROJECT_ID, secret: TEST_SECRET, filename: 'image.png#../../owned.txt' }, env);
|
|
130
157
|
const isRpcError = typeof raw.error?.message === 'string' &&
|
|
@@ -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
|
+
});
|