@zereight/mcp-gitlab 2.1.29 → 2.1.38
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 +72 -20
- package/README.md +120 -82
- package/README.zh-CN.md +72 -20
- package/build/config.js +13 -0
- package/build/downloads/proxy.js +199 -0
- package/build/index.js +850 -490
- package/build/schemas.js +115 -28
- package/build/scripts/check-skill-sync.js +137 -0
- package/build/scripts/generate-tool-docs.js +8 -3
- package/build/server/metrics.js +84 -0
- package/build/server/transport-mode.js +25 -0
- package/build/test/callback-proxy-tests.js +1 -1
- package/build/test/client-pool-test.js +1 -1
- package/build/test/dynamic-api-url-allowlist.test.js +2 -2
- package/build/test/dynamic-api-url-test.js +4 -4
- package/build/test/dynamic-routing-tests.js +4 -4
- package/build/test/group-milestone-schema.test.js +45 -0
- package/build/test/mcp-oauth-tests.js +29 -4
- package/build/test/mcp-server-name.test.js +87 -0
- package/build/test/multi-server-test.js +3 -3
- package/build/test/no-proxy-integration-test.js +1 -1
- package/build/test/nullable-gitlab-response-fields.test.js +22 -0
- package/build/test/path-segment-encoding.test.js +11 -0
- package/build/test/remote-auth-simple-test.js +226 -199
- package/build/test/server/metrics.test.js +47 -0
- package/build/test/sse-auth-guard.test.js +2 -2
- package/build/test/stateless/session-id-integration.test.js +3 -3
- package/build/test/streamable-http-concurrent-session.test.js +64 -1
- package/build/test/streamable-http-dns-rebinding.test.js +185 -0
- package/build/test/streamable-http-static-token-auth.test.js +25 -22
- package/build/test/streamable-http-unauthenticated-discovery.test.js +28 -15
- package/build/test/test-ci-catalog.js +1 -1
- package/build/test/test-ci-lint.js +1 -1
- package/build/test/test-ci-variables.js +8 -5
- package/build/test/test-dependency-proxy.js +11 -7
- package/build/test/test-deployment-tools.js +16 -2
- package/build/test/test-download-attachment.js +1 -1
- package/build/test/test-get-file-blame.js +1 -1
- package/build/test/test-geteffectiveprojectid.js +221 -8
- package/build/test/test-issue-description-patch.js +1 -1
- package/build/test/test-job-artifacts.js +1 -1
- package/build/test/test-list-issues.js +1 -1
- package/build/test/test-list-merge-requests.js +1 -1
- package/build/test/test-list-project-members.js +1 -1
- package/build/test/test-merge-request-approval-state-tools.js +1 -1
- package/build/test/test-merge-request-pipelines.js +1 -1
- package/build/test/test-mr-diffs-filter.js +1 -1
- package/build/test/test-mr-file-diffs.js +2 -2
- package/build/test/test-oauth-proxy-rate-limit.js +1 -1
- package/build/test/test-permission-mode.js +216 -0
- package/build/test/test-protected-branches.js +23 -4
- package/build/test/test-remote-downloads.js +2 -2
- package/build/test/test-search-code.js +1 -1
- package/build/test/test-tags.js +1 -1
- package/build/test/test-todos.js +1 -1
- package/build/test/test-token-optimizations.js +3 -3
- package/build/test/test-toolset-filtering.js +3 -3
- package/build/test/test-update-issue-slim.js +141 -0
- package/build/test/test-upload-markdown.js +1 -1
- package/build/test/utils/download-token.test.js +35 -0
- package/build/test/utils/forwarded-public-base-url.test.js +9 -1
- package/build/test/utils/graphql-query.test.js +64 -1
- package/build/test/utils/mock-gitlab-server.js +24 -31
- package/build/test/utils/server-launcher.js +1 -2
- package/build/test/utils/version-check.test.js +52 -0
- package/build/tools/registry.js +93 -5
- package/build/utils/download-token.js +71 -0
- package/build/utils/forwarded-public-base-url.js +22 -0
- package/build/utils/graphql-query.js +109 -12
- package/build/utils/version-check.js +40 -0
- package/package.json +5 -4
|
@@ -25,7 +25,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
|
|
|
25
25
|
let servers = [];
|
|
26
26
|
before(async () => {
|
|
27
27
|
// Start first mock GitLab server
|
|
28
|
-
const mockPort1 = await findMockServerPort(
|
|
28
|
+
const mockPort1 = await findMockServerPort();
|
|
29
29
|
mockGitLab1 = new MockGitLabServer({
|
|
30
30
|
port: mockPort1,
|
|
31
31
|
validTokens: [MOCK_TOKEN_1]
|
|
@@ -33,7 +33,7 @@ describe('Dynamic API URL - Multiple GitLab Instances', () => {
|
|
|
33
33
|
await mockGitLab1.start();
|
|
34
34
|
mockGitLabUrl1 = mockGitLab1.getUrl();
|
|
35
35
|
// Start second mock GitLab server
|
|
36
|
-
const mockPort2 = await findMockServerPort(
|
|
36
|
+
const mockPort2 = await findMockServerPort();
|
|
37
37
|
mockGitLab2 = new MockGitLabServer({
|
|
38
38
|
port: mockPort2,
|
|
39
39
|
validTokens: [MOCK_TOKEN_2]
|
|
@@ -203,14 +203,14 @@ describe('Dynamic API URL - Connection Pool', () => {
|
|
|
203
203
|
let servers = [];
|
|
204
204
|
before(async () => {
|
|
205
205
|
// Start mock GitLab servers
|
|
206
|
-
const mockPort1 = await findMockServerPort(
|
|
206
|
+
const mockPort1 = await findMockServerPort();
|
|
207
207
|
mockGitLab1 = new MockGitLabServer({
|
|
208
208
|
port: mockPort1,
|
|
209
209
|
validTokens: [MOCK_TOKEN_1]
|
|
210
210
|
});
|
|
211
211
|
await mockGitLab1.start();
|
|
212
212
|
mockGitLabUrl1 = mockGitLab1.getUrl();
|
|
213
|
-
const mockPort2 = await findMockServerPort(
|
|
213
|
+
const mockPort2 = await findMockServerPort();
|
|
214
214
|
mockGitLab2 = new MockGitLabServer({
|
|
215
215
|
port: mockPort2,
|
|
216
216
|
validTokens: [MOCK_TOKEN_2]
|
|
@@ -29,7 +29,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
|
|
|
29
29
|
// to avoid launchServer overwriting GITLAB_PERSONAL_ACCESS_TOKEN with a different value
|
|
30
30
|
process.env.GITLAB_TOKEN_TEST = MOCK_TOKEN_DEFAULT;
|
|
31
31
|
process.env.TEST_PROJECT_ID = '1';
|
|
32
|
-
const mockPort = await findMockServerPort(
|
|
32
|
+
const mockPort = await findMockServerPort();
|
|
33
33
|
mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_DEFAULT] });
|
|
34
34
|
await mockServer.start();
|
|
35
35
|
const mcpPort = await findAvailablePort(3021);
|
|
@@ -82,7 +82,7 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
|
|
|
82
82
|
let mcpUrl;
|
|
83
83
|
let mockServer;
|
|
84
84
|
before(async () => {
|
|
85
|
-
const mockPort = await findMockServerPort(
|
|
85
|
+
const mockPort = await findMockServerPort();
|
|
86
86
|
mockServer = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN_HEADER] });
|
|
87
87
|
await mockServer.start();
|
|
88
88
|
const mcpPort = await findAvailablePort(3022);
|
|
@@ -146,10 +146,10 @@ describe('Dynamic Routing and Authentication Scenarios', () => {
|
|
|
146
146
|
let defaultMockServer;
|
|
147
147
|
let headerMockServer;
|
|
148
148
|
before(async () => {
|
|
149
|
-
const defaultPort = await findMockServerPort(
|
|
149
|
+
const defaultPort = await findMockServerPort();
|
|
150
150
|
defaultMockServer = new MockGitLabServer({ port: defaultPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
|
|
151
151
|
await defaultMockServer.start();
|
|
152
|
-
const headerPort = await findMockServerPort(
|
|
152
|
+
const headerPort = await findMockServerPort();
|
|
153
153
|
headerMockServer = new MockGitLabServer({ port: headerPort, validTokens: [MOCK_TOKEN_DEFAULT, MOCK_TOKEN_HEADER] });
|
|
154
154
|
await headerMockServer.start();
|
|
155
155
|
const mcpPort = await findAvailablePort(3024);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { GitLabGroupMilestonesSchema, GitLabMilestonesSchema, GetGroupMilestoneIssuesSchema, } from "../schemas.js";
|
|
4
|
+
test("group milestone response keeps group_id and rejects missing group_id", () => {
|
|
5
|
+
const parsed = GitLabGroupMilestonesSchema.parse({
|
|
6
|
+
id: 12,
|
|
7
|
+
iid: 3,
|
|
8
|
+
group_id: 16,
|
|
9
|
+
title: "10.0",
|
|
10
|
+
description: null,
|
|
11
|
+
due_date: null,
|
|
12
|
+
start_date: null,
|
|
13
|
+
state: "active",
|
|
14
|
+
updated_at: "2013-10-02T09:24:18Z",
|
|
15
|
+
created_at: "2013-10-02T09:24:18Z",
|
|
16
|
+
expired: false,
|
|
17
|
+
});
|
|
18
|
+
assert.equal(parsed.group_id, "16");
|
|
19
|
+
assert.equal("project_id" in parsed, false);
|
|
20
|
+
// Project schema coerces missing project_id to "undefined" — why group schema exists
|
|
21
|
+
const coerced = GitLabMilestonesSchema.parse({
|
|
22
|
+
id: 12,
|
|
23
|
+
iid: 3,
|
|
24
|
+
group_id: 16,
|
|
25
|
+
title: "10.0",
|
|
26
|
+
description: null,
|
|
27
|
+
due_date: null,
|
|
28
|
+
start_date: null,
|
|
29
|
+
state: "active",
|
|
30
|
+
updated_at: "2013-10-02T09:24:18Z",
|
|
31
|
+
created_at: "2013-10-02T09:24:18Z",
|
|
32
|
+
expired: false,
|
|
33
|
+
});
|
|
34
|
+
assert.equal(coerced.project_id, "undefined");
|
|
35
|
+
});
|
|
36
|
+
test("group milestone issues schema accepts page and per_page", () => {
|
|
37
|
+
const parsed = GetGroupMilestoneIssuesSchema.parse({
|
|
38
|
+
group_id: "16",
|
|
39
|
+
milestone_id: "12",
|
|
40
|
+
page: 2,
|
|
41
|
+
per_page: 50,
|
|
42
|
+
});
|
|
43
|
+
assert.equal(parsed.page, 2);
|
|
44
|
+
assert.equal(parsed.per_page, 50);
|
|
45
|
+
});
|
|
@@ -83,7 +83,7 @@ describe("MCP OAuth — Discovery Endpoints", () => {
|
|
|
83
83
|
let mockGitLab;
|
|
84
84
|
let servers = [];
|
|
85
85
|
before(async () => {
|
|
86
|
-
const mockPort = await findMockServerPort(
|
|
86
|
+
const mockPort = await findMockServerPort();
|
|
87
87
|
mockGitLab = new MockGitLabServer({
|
|
88
88
|
port: mockPort,
|
|
89
89
|
validTokens: [MOCK_OAUTH_TOKEN],
|
|
@@ -137,8 +137,20 @@ describe("MCP OAuth — Discovery Endpoints", () => {
|
|
|
137
137
|
assert.ok(body.resource, "Should have resource field");
|
|
138
138
|
console.log(" ✓ Protected resource metadata returned");
|
|
139
139
|
});
|
|
140
|
+
test("unprefixed MCP_SERVER_URL serves /mcp resource discovery at /.well-known/oauth-protected-resource/mcp", async () => {
|
|
141
|
+
// When MCP_SERVER_URL has no path, the server registers a dedicated route
|
|
142
|
+
// at /.well-known/oauth-protected-resource/mcp (RFC 9728 path-suffixed
|
|
143
|
+
// discovery) so clients connecting to /mcp can discover the resource.
|
|
144
|
+
const issuerUrl = new URL(mcpBaseUrl);
|
|
145
|
+
const res = await fetch(`${mcpBaseUrl}/.well-known/oauth-protected-resource/mcp`);
|
|
146
|
+
assert.strictEqual(res.status, 200, "Should return 200");
|
|
147
|
+
const body = (await res.json());
|
|
148
|
+
assert.strictEqual(body.resource, `${issuerUrl.origin}/mcp`, "resource should be ${origin}/mcp");
|
|
149
|
+
assert.deepStrictEqual(body.authorization_servers, [issuerUrl.href], "authorization_servers should contain the issuer URL");
|
|
150
|
+
console.log(" ✓ Unprefixed MCP_SERVER_URL: /mcp resource discovery returned correct metadata");
|
|
151
|
+
});
|
|
140
152
|
test("path-prefixed MCP_SERVER_URL serves path-aware discovery metadata", async () => {
|
|
141
|
-
const mockPort = await findMockServerPort(
|
|
153
|
+
const mockPort = await findMockServerPort();
|
|
142
154
|
const prefixedMockGitLab = new MockGitLabServer({
|
|
143
155
|
port: mockPort,
|
|
144
156
|
validTokens: [MOCK_OAUTH_TOKEN],
|
|
@@ -196,7 +208,7 @@ describe("MCP OAuth — /mcp Auth Enforcement", () => {
|
|
|
196
208
|
let mockGitLab;
|
|
197
209
|
let servers = [];
|
|
198
210
|
before(async () => {
|
|
199
|
-
const mockPort = await findMockServerPort(
|
|
211
|
+
const mockPort = await findMockServerPort();
|
|
200
212
|
mockGitLab = new MockGitLabServer({
|
|
201
213
|
port: mockPort,
|
|
202
214
|
validTokens: [MOCK_OAUTH_TOKEN],
|
|
@@ -492,7 +504,7 @@ describe("MCP OAuth — Header Auth Fallback", () => {
|
|
|
492
504
|
let mockGitLab;
|
|
493
505
|
let servers = [];
|
|
494
506
|
before(async () => {
|
|
495
|
-
const mockPort = await findMockServerPort(
|
|
507
|
+
const mockPort = await findMockServerPort();
|
|
496
508
|
mockGitLab = new MockGitLabServer({
|
|
497
509
|
port: mockPort,
|
|
498
510
|
// Include both OAuth token and raw tokens so GitLab API calls succeed
|
|
@@ -566,6 +578,19 @@ describe("MCP OAuth — Header Auth Fallback", () => {
|
|
|
566
578
|
assert.notStrictEqual(res.status, 403, "Should not return 403 with valid JOB-TOKEN");
|
|
567
579
|
console.log(` ✓ JOB-TOKEN header accepted (status: ${res.status})`);
|
|
568
580
|
});
|
|
581
|
+
test("POST /mcp with invalid Private-Token is rejected before session creation", async () => {
|
|
582
|
+
const res = await fetch(mcpUrl, {
|
|
583
|
+
method: "POST",
|
|
584
|
+
headers: {
|
|
585
|
+
"Content-Type": "application/json",
|
|
586
|
+
Accept: "application/json, text/event-stream",
|
|
587
|
+
"Private-Token": "glpat-invalid-token-0000",
|
|
588
|
+
},
|
|
589
|
+
body: initBody,
|
|
590
|
+
});
|
|
591
|
+
assert.strictEqual(res.status, 401);
|
|
592
|
+
assert.strictEqual(res.headers.get("mcp-session-id"), null);
|
|
593
|
+
});
|
|
569
594
|
test("POST /mcp with valid OAuth Bearer token still works normally", async () => {
|
|
570
595
|
const res = await fetch(mcpUrl, {
|
|
571
596
|
method: "POST",
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { after, describe, test } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { cleanupServers, findAvailablePort, HOST, launchServer, TransportMode, } from "./utils/server-launcher.js";
|
|
4
|
+
async function rawMcpRequest(url, body, headers = {}) {
|
|
5
|
+
const response = await fetch(url, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: {
|
|
8
|
+
"Content-Type": "application/json",
|
|
9
|
+
Accept: "application/json, text/event-stream",
|
|
10
|
+
...headers,
|
|
11
|
+
},
|
|
12
|
+
body: JSON.stringify(body),
|
|
13
|
+
});
|
|
14
|
+
const text = await response.text();
|
|
15
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
16
|
+
if (contentType.includes("text/event-stream")) {
|
|
17
|
+
const dataLines = text
|
|
18
|
+
.split("\n")
|
|
19
|
+
.filter(line => line.startsWith("data: "))
|
|
20
|
+
.map(line => line.slice(6));
|
|
21
|
+
return {
|
|
22
|
+
status: response.status,
|
|
23
|
+
data: dataLines.length > 0 ? JSON.parse(dataLines.at(-1)) : null,
|
|
24
|
+
text,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
status: response.status,
|
|
29
|
+
data: text ? JSON.parse(text) : null,
|
|
30
|
+
text,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
const initializeBody = {
|
|
34
|
+
jsonrpc: "2.0",
|
|
35
|
+
id: 1,
|
|
36
|
+
method: "initialize",
|
|
37
|
+
params: {
|
|
38
|
+
protocolVersion: "2025-03-26",
|
|
39
|
+
capabilities: {},
|
|
40
|
+
clientInfo: { name: "server-name-test", version: "1.0.0" },
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
let portOffset = 0;
|
|
44
|
+
async function launchWithServerName(mcpServerName) {
|
|
45
|
+
const port = await findAvailablePort(3700 + portOffset++ * 10);
|
|
46
|
+
const server = await launchServer({
|
|
47
|
+
mode: TransportMode.STREAMABLE_HTTP,
|
|
48
|
+
port,
|
|
49
|
+
timeout: 10_000,
|
|
50
|
+
env: {
|
|
51
|
+
STREAMABLE_HTTP: "true",
|
|
52
|
+
REMOTE_AUTHORIZATION: "true",
|
|
53
|
+
GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY: "true",
|
|
54
|
+
GITLAB_API_URL: "https://gitlab.example.com/api/v4",
|
|
55
|
+
...(mcpServerName !== undefined ? { MCP_SERVER_NAME: mcpServerName } : {}),
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
return { server, mcpUrl: `http://${HOST}:${port}/mcp` };
|
|
59
|
+
}
|
|
60
|
+
describe("MCP_SERVER_NAME", { timeout: 20_000 }, () => {
|
|
61
|
+
let servers = [];
|
|
62
|
+
after(() => {
|
|
63
|
+
cleanupServers(servers);
|
|
64
|
+
servers = [];
|
|
65
|
+
});
|
|
66
|
+
test("defaults to zereight-gitlab-mcp-server when unset", async () => {
|
|
67
|
+
const { server, mcpUrl } = await launchWithServerName();
|
|
68
|
+
servers.push(server);
|
|
69
|
+
const response = await rawMcpRequest(mcpUrl, initializeBody);
|
|
70
|
+
assert.strictEqual(response.status, 200, response.text);
|
|
71
|
+
assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
|
|
72
|
+
});
|
|
73
|
+
test("reports the overridden name when MCP_SERVER_NAME is set", async () => {
|
|
74
|
+
const { server, mcpUrl } = await launchWithServerName("gitlab-selfhosted-readonly");
|
|
75
|
+
servers.push(server);
|
|
76
|
+
const response = await rawMcpRequest(mcpUrl, initializeBody);
|
|
77
|
+
assert.strictEqual(response.status, 200, response.text);
|
|
78
|
+
assert.strictEqual(response.data?.result?.serverInfo?.name, "gitlab-selfhosted-readonly");
|
|
79
|
+
});
|
|
80
|
+
test("treats a whitespace-only MCP_SERVER_NAME as unset", async () => {
|
|
81
|
+
const { server, mcpUrl } = await launchWithServerName(" ");
|
|
82
|
+
servers.push(server);
|
|
83
|
+
const response = await rawMcpRequest(mcpUrl, initializeBody);
|
|
84
|
+
assert.strictEqual(response.status, 200, response.text);
|
|
85
|
+
assert.strictEqual(response.data?.result?.serverInfo?.name, "zereight-gitlab-mcp-server");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -45,7 +45,7 @@ describe("Single Client Mode (ENABLE_DYNAMIC_API_URL=false)", () => {
|
|
|
45
45
|
let mcpUrl;
|
|
46
46
|
let mockServer1;
|
|
47
47
|
before(async () => {
|
|
48
|
-
const mockPort = await findMockServerPort(
|
|
48
|
+
const mockPort = await findMockServerPort();
|
|
49
49
|
mockServer1 = new MockGitLabServer({ port: mockPort, validTokens: [MOCK_TOKEN] });
|
|
50
50
|
mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
|
|
51
51
|
await mockServer1.start();
|
|
@@ -105,8 +105,8 @@ describe("Dynamic Client Mode (ENABLE_DYNAMIC_API_URL=true)", () => {
|
|
|
105
105
|
let mockServer1;
|
|
106
106
|
let mockServer2;
|
|
107
107
|
before(async () => {
|
|
108
|
-
const port1 = await findMockServerPort(
|
|
109
|
-
const port2 = await findMockServerPort(
|
|
108
|
+
const port1 = await findMockServerPort();
|
|
109
|
+
const port2 = await findMockServerPort();
|
|
110
110
|
mockServer1 = new MockGitLabServer({ port: port1, validTokens: [MOCK_TOKEN] });
|
|
111
111
|
mockServer2 = new MockGitLabServer({ port: port2, validTokens: [MOCK_TOKEN] });
|
|
112
112
|
mockServer1.addMockHandler('get', '/projects/1', (req, res) => { res.json(project1); });
|
|
@@ -22,7 +22,7 @@ describe('NO_PROXY Integration Tests', () => {
|
|
|
22
22
|
let mockGitLabHost;
|
|
23
23
|
before(async () => {
|
|
24
24
|
// Start mock GitLab server
|
|
25
|
-
const mockPort = await findMockServerPort(
|
|
25
|
+
const mockPort = await findMockServerPort();
|
|
26
26
|
mockGitLab = new MockGitLabServer({
|
|
27
27
|
port: mockPort,
|
|
28
28
|
validTokens: [MOCK_TOKEN]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { GitLabCompareResultSchema, GitLabProtectedBranchAccessLevelSchema, } from "../schemas.js";
|
|
4
|
+
test("role-based protected-branch access levels accept null user_id/group_id", () => {
|
|
5
|
+
// #575: GitLab returns null for role-based levels, not omitted fields
|
|
6
|
+
const parsed = GitLabProtectedBranchAccessLevelSchema.parse({
|
|
7
|
+
access_level: 40,
|
|
8
|
+
access_level_description: "Maintainers",
|
|
9
|
+
user_id: null,
|
|
10
|
+
group_id: null,
|
|
11
|
+
});
|
|
12
|
+
assert.equal(parsed.user_id, null);
|
|
13
|
+
assert.equal(parsed.group_id, null);
|
|
14
|
+
});
|
|
15
|
+
test("branch compare accepts null commit", () => {
|
|
16
|
+
const parsed = GitLabCompareResultSchema.parse({
|
|
17
|
+
commit: null,
|
|
18
|
+
commits: [],
|
|
19
|
+
diffs: [],
|
|
20
|
+
});
|
|
21
|
+
assert.equal(parsed.commit, null);
|
|
22
|
+
});
|
|
@@ -11,6 +11,9 @@ const rawPathSegmentPatterns = [
|
|
|
11
11
|
"/draft_notes/${draftNoteId}",
|
|
12
12
|
"/versions/${versionId}",
|
|
13
13
|
"/groups/${groupId}",
|
|
14
|
+
"/jobs/${jobId}",
|
|
15
|
+
"/pipelines/${pipelineId}",
|
|
16
|
+
"/milestones/${milestoneId}",
|
|
14
17
|
];
|
|
15
18
|
test("GitLab URL path IDs are encoded before interpolation", () => {
|
|
16
19
|
for (const pattern of rawPathSegmentPatterns) {
|
|
@@ -22,6 +25,14 @@ test("encoding keeps path traversal payloads inside one URL segment", () => {
|
|
|
22
25
|
const url = new URL(`https://gitlab.com/api/v4/projects/42/issues/${encodeURIComponent(payload)}/notes`);
|
|
23
26
|
assert.equal(url.pathname, "/api/v4/projects/42/issues/1%2F..%2F..%2F..%2F..%2Fadmin%2Fci%2Fvariables/notes");
|
|
24
27
|
});
|
|
28
|
+
test("job_id path traversal stays under /jobs/", () => {
|
|
29
|
+
// GHSA-7c3w-fxgh-frc7 / #587: unencoded job_id with ../ escaped to /api/v4/user
|
|
30
|
+
const payload = "../../../user";
|
|
31
|
+
const url = new URL(`https://gitlab.com/api/v4/projects/1/jobs/${encodeURIComponent(payload)}/trace`);
|
|
32
|
+
assert.equal(url.pathname, "/api/v4/projects/1/jobs/..%2F..%2F..%2Fuser/trace");
|
|
33
|
+
assert.match(url.pathname, /\/jobs\/[^/]+\/trace$/);
|
|
34
|
+
assert.equal(url.pathname.includes("/api/v4/user"), false);
|
|
35
|
+
});
|
|
25
36
|
test("malformed percent-encoded segments can still be encoded", () => {
|
|
26
37
|
assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath");
|
|
27
38
|
});
|