@zereight/mcp-gitlab 2.1.64 → 2.1.65
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 +20 -5
- package/README.md +19 -6
- package/README.zh-CN.md +19 -6
- package/build/downloads/proxy.js +14 -7
- package/build/index.js +329 -240
- package/build/scripts/generate-tool-docs.js +3 -1
- package/build/test/path-segment-encoding.test.js +73 -17
- package/build/test/sse-session-limits.test.js +273 -0
- package/build/test/test-download-attachment.js +11 -0
- package/build/test/test-geteffectiveprojectid.js +1 -1
- package/build/test/test-job-artifacts.js +22 -0
- package/build/test/test-permission-mode.js +103 -12
- package/build/test/test-remote-downloads.js +14 -1
- package/build/test/utils/graphql-query.test.js +72 -0
- package/build/test/utils/safe-redirect-fetch.test.js +463 -0
- package/build/test/utils/tool-args.test.js +32 -0
- package/build/test/utils/url.test.js +128 -0
- package/build/test-note.js +2 -1
- package/build/test-resolve-issue-note.js +4 -3
- package/build/tools/registry.js +16 -2
- package/build/utils/graphql-query.js +31 -9
- package/build/utils/safe-redirect-fetch.js +321 -0
- package/build/utils/tool-args.js +12 -3
- package/build/utils/url.js +102 -0
- package/package.json +1 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { encodeGitLabPath, encodeGitLabPathSegment, normalizeGitLabApiUrl, } from "../../utils/url.js";
|
|
4
|
+
describe("When normalizeGitLabApiUrl runs", () => {
|
|
5
|
+
test("should default to gitlab.com", () => {
|
|
6
|
+
assert.equal(normalizeGitLabApiUrl(""), "https://gitlab.com/api/v4");
|
|
7
|
+
});
|
|
8
|
+
test("should append the API path", () => {
|
|
9
|
+
assert.equal(normalizeGitLabApiUrl("https://gitlab.example.com"), "https://gitlab.example.com/api/v4");
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
describe("When encodeGitLabPathSegment runs", () => {
|
|
13
|
+
describe("with ordinary values", () => {
|
|
14
|
+
test("should keep plain segments", () => {
|
|
15
|
+
assert.equal(encodeGitLabPathSegment("feature-branch"), "feature-branch");
|
|
16
|
+
});
|
|
17
|
+
test("should encode spaces and reserved characters", () => {
|
|
18
|
+
assert.equal(encodeGitLabPathSegment("a b"), "a%20b");
|
|
19
|
+
assert.equal(encodeGitLabPathSegment("a?b#c"), "a%3Fb%23c");
|
|
20
|
+
});
|
|
21
|
+
test("should keep an encoded separator inside one segment", () => {
|
|
22
|
+
// GitLab accepts percent-encoded slashes for branch names such as release/1.0
|
|
23
|
+
assert.equal(encodeGitLabPathSegment("release%2F1.0"), "release%2F1.0");
|
|
24
|
+
});
|
|
25
|
+
test("should keep values that only look like dot segments", () => {
|
|
26
|
+
assert.equal(encodeGitLabPathSegment("..."), "...");
|
|
27
|
+
assert.equal(encodeGitLabPathSegment("v1.0.0"), "v1.0.0");
|
|
28
|
+
});
|
|
29
|
+
test("should encode a project path as one segment", () => {
|
|
30
|
+
assert.equal(encodeGitLabPathSegment("group/project"), "group%2Fproject");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("with traversal payloads", () => {
|
|
34
|
+
const payloads = [
|
|
35
|
+
".",
|
|
36
|
+
"..",
|
|
37
|
+
"../user",
|
|
38
|
+
"..%2F..%2Fuser",
|
|
39
|
+
"%2E%2E%2Fuser",
|
|
40
|
+
"..%5C..%5Cuser",
|
|
41
|
+
"/etc/passwd",
|
|
42
|
+
"a/../b",
|
|
43
|
+
];
|
|
44
|
+
for (const payload of payloads) {
|
|
45
|
+
test(`should reject ${JSON.stringify(payload)}`, () => {
|
|
46
|
+
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
describe("with payloads that are not visible after one decode", () => {
|
|
51
|
+
// One decode is not enough to recognize these: "%252E" only becomes "." on the
|
|
52
|
+
// second pass, so a single-decode guard passes the payload through and the
|
|
53
|
+
// receiving server sees a traversal sequence.
|
|
54
|
+
const payloads = [
|
|
55
|
+
"..%252Fuser",
|
|
56
|
+
"%252E%252E%252Fuser",
|
|
57
|
+
"%252E%252E%252F%252E%252E%252Fuser",
|
|
58
|
+
"..%25252Fuser",
|
|
59
|
+
"..%252F..%252F..%252Fuser",
|
|
60
|
+
];
|
|
61
|
+
for (const payload of payloads) {
|
|
62
|
+
test(`should reject ${JSON.stringify(payload)}`, () => {
|
|
63
|
+
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
describe("with control characters", () => {
|
|
68
|
+
const payloads = ["..%00", "%00../user", "a%0Ab", "%7F..%2Fuser"];
|
|
69
|
+
for (const payload of payloads) {
|
|
70
|
+
test(`should reject ${JSON.stringify(payload)}`, () => {
|
|
71
|
+
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
describe("with a literal percent sign", () => {
|
|
76
|
+
test("should encode a percent that starts no escape instead of rejecting it", () => {
|
|
77
|
+
// decodeURIComponent throws for these too, but a percent sign is valid in a
|
|
78
|
+
// file or branch name. Only a broken escape next to a valid one is a payload
|
|
79
|
+
// the guard cannot inspect.
|
|
80
|
+
assert.equal(encodeGitLabPathSegment("report-100%.pdf"), "report-100%25.pdf");
|
|
81
|
+
assert.equal(encodeGitLabPathSegment("100%"), "100%25");
|
|
82
|
+
assert.equal(encodeGitLabPathSegment("bad%zz/path"), "bad%25zz%2Fpath");
|
|
83
|
+
// The encoded form of such a name decodes back to the literal sign.
|
|
84
|
+
assert.equal(encodeGitLabPathSegment("save%20100%25.txt"), "save%20100%25.txt");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe("with a value that cannot be decoded", () => {
|
|
88
|
+
test("should reject a malformed escape sequence next to a valid one", () => {
|
|
89
|
+
// These must not be treated as safe just because decodeURIComponent threw:
|
|
90
|
+
// the valid escape may still hide a separator or a dot segment.
|
|
91
|
+
for (const payload of ["%2E%2E%2F%ZZ", "%2E%2E%2F%..", "%2E%GG"]) {
|
|
92
|
+
assert.throws(() => encodeGitLabPathSegment(payload), /Cannot use value as a GitLab URL path segment/, payload);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
test("should reject a value with more encoding layers than it will unwrap", () => {
|
|
96
|
+
assert.throws(() => encodeGitLabPathSegment("%25252525252F"), /Cannot use value as a GitLab URL path segment/);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("When encodeGitLabPath runs", () => {
|
|
101
|
+
test("should encode every slash-separated segment", () => {
|
|
102
|
+
assert.equal(encodeGitLabPath("docs/read me.txt"), "docs/read%20me.txt");
|
|
103
|
+
});
|
|
104
|
+
test("should keep an encoded separator", () => {
|
|
105
|
+
assert.equal(encodeGitLabPath("release%2F1.0"), "release%2F1.0");
|
|
106
|
+
});
|
|
107
|
+
test("should reject a traversal payload", () => {
|
|
108
|
+
for (const payload of [
|
|
109
|
+
"../../../user",
|
|
110
|
+
"1/../../../../admin/ci/variables",
|
|
111
|
+
"..%2F..%2Fuser",
|
|
112
|
+
"a/..%2F..%2Fb",
|
|
113
|
+
"a/..%252F..%252Fb",
|
|
114
|
+
]) {
|
|
115
|
+
assert.throws(() => encodeGitLabPath(payload), /Cannot use value as a GitLab URL path segment/, payload);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
test("should reject an absolute path instead of collapsing the leading segment", () => {
|
|
119
|
+
for (const payload of ["/etc/passwd", "/", "/project/-/raw/main/x"]) {
|
|
120
|
+
assert.throws(() => encodeGitLabPath(payload), /Cannot use value as a GitLab URL path/, payload);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
test("should reject empty and empty-segment paths", () => {
|
|
124
|
+
for (const payload of ["", "a//b", "a/"]) {
|
|
125
|
+
assert.throws(() => encodeGitLabPath(payload), /Cannot use value as a GitLab URL path/, payload);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
});
|
package/build/test-note.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* with the fixed endpoint URL construction that uses plural resource names
|
|
4
4
|
* (issues instead of issue, merge_requests instead of merge_request).
|
|
5
5
|
*/
|
|
6
|
+
import { encodeGitLabPathSegment } from "./utils/url.js";
|
|
6
7
|
// GitLab API configuration (replace with actual values when testing)
|
|
7
8
|
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
|
|
8
9
|
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_TOKEN || "";
|
|
@@ -11,7 +12,7 @@ const ISSUE_IID = Number(process.env.ISSUE_IID || "1");
|
|
|
11
12
|
async function testCreateIssueNote() {
|
|
12
13
|
try {
|
|
13
14
|
// Using plural form "issues" in the URL
|
|
14
|
-
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${
|
|
15
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeGitLabPathSegment(PROJECT_ID)}/issues/${ISSUE_IID}/notes`);
|
|
15
16
|
const response = await fetch(url.toString(), {
|
|
16
17
|
method: "POST",
|
|
17
18
|
headers: {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* It shows how to use the update_issue_note tool to resolve or unresolve
|
|
4
4
|
* issue discussion threads.
|
|
5
5
|
*/
|
|
6
|
+
import { encodeGitLabPathSegment } from "./utils/url.js";
|
|
6
7
|
// GitLab API configuration (replace with actual values when testing)
|
|
7
8
|
const GITLAB_API_URL = process.env.GITLAB_API_URL || "https://gitlab.com";
|
|
8
9
|
const GITLAB_PERSONAL_ACCESS_TOKEN = process.env.GITLAB_TOKEN || "";
|
|
@@ -15,7 +16,7 @@ const NOTE_ID = process.env.NOTE_ID || "your-note-id";
|
|
|
15
16
|
*/
|
|
16
17
|
async function testResolveIssueNote() {
|
|
17
18
|
try {
|
|
18
|
-
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${
|
|
19
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeGitLabPathSegment(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
19
20
|
const response = await fetch(url.toString(), {
|
|
20
21
|
method: "PUT",
|
|
21
22
|
headers: {
|
|
@@ -44,7 +45,7 @@ async function testResolveIssueNote() {
|
|
|
44
45
|
*/
|
|
45
46
|
async function testUnresolveIssueNote() {
|
|
46
47
|
try {
|
|
47
|
-
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${
|
|
48
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeGitLabPathSegment(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
48
49
|
const response = await fetch(url.toString(), {
|
|
49
50
|
method: "PUT",
|
|
50
51
|
headers: {
|
|
@@ -73,7 +74,7 @@ async function testUnresolveIssueNote() {
|
|
|
73
74
|
*/
|
|
74
75
|
async function testUpdateIssueNoteBody() {
|
|
75
76
|
try {
|
|
76
|
-
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${
|
|
77
|
+
const url = new URL(`${GITLAB_API_URL}/api/v4/projects/${encodeGitLabPathSegment(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
|
|
77
78
|
const response = await fetch(url.toString(), {
|
|
78
79
|
method: "PUT",
|
|
79
80
|
headers: {
|
package/build/tools/registry.js
CHANGED
|
@@ -1408,9 +1408,14 @@ export const readOnlyTools = new Set([
|
|
|
1408
1408
|
export const destructiveTools = new Set([
|
|
1409
1409
|
"delete_pipeline",
|
|
1410
1410
|
"erase_pipeline_job",
|
|
1411
|
+
// Teardown verbs without a `delete_` prefix tear down live pipelines/environments too.
|
|
1412
|
+
"cancel_pipeline",
|
|
1413
|
+
"cancel_pipeline_job",
|
|
1411
1414
|
"delete_deployment",
|
|
1412
1415
|
"approve_deployment",
|
|
1413
1416
|
"delete_environment",
|
|
1417
|
+
"stop_environment",
|
|
1418
|
+
"stop_stale_environments",
|
|
1414
1419
|
"delete_review_app_environments",
|
|
1415
1420
|
"delete_pipeline_trigger",
|
|
1416
1421
|
"delete_issue",
|
|
@@ -1444,8 +1449,10 @@ export const destructiveTools = new Set([
|
|
|
1444
1449
|
"delete_pipeline_schedule_variable",
|
|
1445
1450
|
"purge_dependency_proxy_cache",
|
|
1446
1451
|
]);
|
|
1447
|
-
// Tools
|
|
1448
|
-
//
|
|
1452
|
+
// Tools blocked in "modify" permission mode: permanent deletions plus destructive
|
|
1453
|
+
// teardown verbs that do not start with `delete_` (see the comment inside).
|
|
1454
|
+
// Invariant: deleteTools is a subset of destructiveTools — every blocked tool is also
|
|
1455
|
+
// annotated with `destructiveHint`. Add new blocked tools to both sets.
|
|
1449
1456
|
export const deleteTools = new Set([
|
|
1450
1457
|
"delete_pipeline",
|
|
1451
1458
|
"erase_pipeline_job",
|
|
@@ -1478,6 +1485,13 @@ export const deleteTools = new Set([
|
|
|
1478
1485
|
"delete_work_item_emoji_reaction",
|
|
1479
1486
|
"delete_work_item_note_emoji_reaction",
|
|
1480
1487
|
"purge_dependency_proxy_cache",
|
|
1488
|
+
// Destructive teardown operations whose names do not start with `delete_`:
|
|
1489
|
+
// stopping/cancelling live pipelines and environments, and removing branch protection.
|
|
1490
|
+
"cancel_pipeline",
|
|
1491
|
+
"cancel_pipeline_job",
|
|
1492
|
+
"stop_environment",
|
|
1493
|
+
"stop_stale_environments",
|
|
1494
|
+
"unprotect_branch",
|
|
1481
1495
|
]);
|
|
1482
1496
|
// Define which tools are related to wiki and can be toggled by USE_GITLAB_WIKI
|
|
1483
1497
|
export const wikiToolNames = new Set([
|
|
@@ -88,14 +88,30 @@ export function graphqlQueryContainsWriteOperation(query) {
|
|
|
88
88
|
}
|
|
89
89
|
return false;
|
|
90
90
|
}
|
|
91
|
-
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
91
|
+
// Verbs that mark a mutation as destructive for GITLAB_PERMISSION_MODE=modify.
|
|
92
|
+
// GitLab exposes many destructive mutations whose names do not contain "delete"
|
|
93
|
+
// (environmentStop, pipelineCancel, clusterAgentTokenRevoke, jobUnschedule, ...), so
|
|
94
|
+
// the ban covers teardown verbs as well as deletion verbs.
|
|
95
|
+
const DESTRUCTIVE_FIELD_PATTERN = /delete|destroy|remove|prune|purge|erase|revoke|cancel|stop|terminate|unprotect|disable|deactivate|drop|unschedule/i;
|
|
96
|
+
// GraphQL treats whitespace and commas as insignificant, including between an
|
|
97
|
+
// alias and its colon (`stop : field` and `stop,: field` are both aliases).
|
|
98
|
+
function skipInsignificantGraphQL(source, index) {
|
|
99
|
+
while (index < source.length && /[\s,]/.test(source[index])) {
|
|
100
|
+
index++;
|
|
101
|
+
}
|
|
102
|
+
return index;
|
|
103
|
+
}
|
|
104
|
+
// Collects top-level selection field names of every mutation operation. Aliases are
|
|
105
|
+
// skipped: `stop: environmentStop(...)` must be judged by the field name, so a
|
|
106
|
+
// harmless label on a harmless field (`stop : issueSetSeverity(...)`) is not mistaken
|
|
107
|
+
// for a destructive mutation. Content inside parentheses (arguments) is skipped so
|
|
108
|
+
// argument names like removeSourceBranch do not count as delete fields. Returns null
|
|
109
|
+
// when a top-level fragment spread is present, since the spread could hide a delete
|
|
110
|
+
// field. Commas between operations are insignificant in GraphQL, so they are accepted
|
|
111
|
+
// as operation separators alongside `;` and `}`.
|
|
96
112
|
function extractTopLevelMutationFields(normalized) {
|
|
97
113
|
const fields = [];
|
|
98
|
-
const mutationRegex = /(?:^|[}
|
|
114
|
+
const mutationRegex = /(?:^|[;},]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;
|
|
99
115
|
let match;
|
|
100
116
|
while ((match = mutationRegex.exec(normalized)) !== null) {
|
|
101
117
|
let i = mutationRegex.lastIndex;
|
|
@@ -123,7 +139,11 @@ function extractTopLevelMutationFields(normalized) {
|
|
|
123
139
|
}
|
|
124
140
|
}
|
|
125
141
|
if (current) {
|
|
126
|
-
|
|
142
|
+
// Only the actual field name is tested. Skip insignificant tokens so a
|
|
143
|
+
// teardown-verb alias does not look like a destructive mutation.
|
|
144
|
+
if (normalized[skipInsignificantGraphQL(normalized, i)] !== ":") {
|
|
145
|
+
fields.push(current);
|
|
146
|
+
}
|
|
127
147
|
current = "";
|
|
128
148
|
}
|
|
129
149
|
i++;
|
|
@@ -134,9 +154,11 @@ function extractTopLevelMutationFields(normalized) {
|
|
|
134
154
|
}
|
|
135
155
|
return fields;
|
|
136
156
|
}
|
|
157
|
+
// Kept as `...DeleteOperation` for callers, but the guard now covers every
|
|
158
|
+
// destructive mutation verb, not only delete-named ones.
|
|
137
159
|
export function graphqlQueryContainsDeleteOperation(query) {
|
|
138
160
|
const normalized = stripGraphQLCommentsAndStrings(query).trim();
|
|
139
|
-
if (!normalized || !/(?:^|[}
|
|
161
|
+
if (!normalized || !/(?:^|[;},]\s*)mutation\b/.test(normalized)) {
|
|
140
162
|
return false;
|
|
141
163
|
}
|
|
142
164
|
const fields = extractTopLevelMutationFields(normalized);
|
|
@@ -145,5 +167,5 @@ export function graphqlQueryContainsDeleteOperation(query) {
|
|
|
145
167
|
// could not be located (exotic syntax): be conservative and treat as delete
|
|
146
168
|
return true;
|
|
147
169
|
}
|
|
148
|
-
return fields.some(field =>
|
|
170
|
+
return fields.some(field => DESTRUCTIVE_FIELD_PATTERN.test(field));
|
|
149
171
|
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { fetch as undiciFetch } from "undici";
|
|
4
|
+
/** Maximum number of redirect hops an outbound download request may follow. */
|
|
5
|
+
export const DEFAULT_MAX_REDIRECTS = 5;
|
|
6
|
+
/**
|
|
7
|
+
* Request headers that carry GitLab credentials. They are sent to the origin the
|
|
8
|
+
* request started on and to operator-declared GitLab hosts, but never to any
|
|
9
|
+
* other redirect target (object storage, CDNs, arbitrary public hosts).
|
|
10
|
+
*/
|
|
11
|
+
export const CREDENTIAL_HEADERS = [
|
|
12
|
+
"authorization",
|
|
13
|
+
"private-token",
|
|
14
|
+
"job-token",
|
|
15
|
+
"proxy-authorization",
|
|
16
|
+
"cookie",
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Thrown when an outbound request would follow a redirect to a destination the
|
|
20
|
+
* server refuses to reach (non-public address, unsupported scheme, too many hops).
|
|
21
|
+
*/
|
|
22
|
+
export class UnsafeRedirectError extends Error {
|
|
23
|
+
constructor(message) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "UnsafeRedirectError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
29
|
+
/** Splits a dotted-quad into its four octets, or null when it is not one. */
|
|
30
|
+
function parseIpv4Octets(value) {
|
|
31
|
+
const parts = value.split(".");
|
|
32
|
+
if (parts.length !== 4)
|
|
33
|
+
return null;
|
|
34
|
+
const octets = [];
|
|
35
|
+
for (const part of parts) {
|
|
36
|
+
if (!/^\d{1,3}$/.test(part))
|
|
37
|
+
return null;
|
|
38
|
+
const octet = Number.parseInt(part, 10);
|
|
39
|
+
if (octet > 255)
|
|
40
|
+
return null;
|
|
41
|
+
octets.push(octet);
|
|
42
|
+
}
|
|
43
|
+
return octets;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Expands an IPv6 literal into its eight 16-bit groups, handling `::` compression
|
|
47
|
+
* and a trailing dotted-quad. Returns null when the literal cannot be expanded.
|
|
48
|
+
*/
|
|
49
|
+
function parseIpv6Groups(value) {
|
|
50
|
+
const normalized = value.toLowerCase().replace(/^\[/, "").replace(/\]$/, "");
|
|
51
|
+
if (!normalized.includes(":"))
|
|
52
|
+
return null;
|
|
53
|
+
const halves = normalized.split("::");
|
|
54
|
+
if (halves.length > 2)
|
|
55
|
+
return null;
|
|
56
|
+
const toGroups = (text) => {
|
|
57
|
+
if (!text)
|
|
58
|
+
return [];
|
|
59
|
+
const tokens = text.split(":");
|
|
60
|
+
const groups = [];
|
|
61
|
+
for (const [index, token] of tokens.entries()) {
|
|
62
|
+
if (token.includes(".")) {
|
|
63
|
+
// A dotted-quad is only valid as the trailing token of the literal.
|
|
64
|
+
if (index !== tokens.length - 1)
|
|
65
|
+
return null;
|
|
66
|
+
const octets = parseIpv4Octets(token);
|
|
67
|
+
if (!octets)
|
|
68
|
+
return null;
|
|
69
|
+
groups.push((octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!/^[0-9a-f]{1,4}$/.test(token))
|
|
73
|
+
return null;
|
|
74
|
+
groups.push(Number.parseInt(token, 16));
|
|
75
|
+
}
|
|
76
|
+
return groups;
|
|
77
|
+
};
|
|
78
|
+
const head = toGroups(halves[0]);
|
|
79
|
+
const tail = halves.length === 2 ? toGroups(halves[1]) : [];
|
|
80
|
+
if (!head || !tail)
|
|
81
|
+
return null;
|
|
82
|
+
if (halves.length === 2) {
|
|
83
|
+
const zeroGroups = 8 - head.length - tail.length;
|
|
84
|
+
// `::` stands for at least one group.
|
|
85
|
+
if (zeroGroups < 1)
|
|
86
|
+
return null;
|
|
87
|
+
return [...head, ...new Array(zeroGroups).fill(0), ...tail];
|
|
88
|
+
}
|
|
89
|
+
return head.length === 8 ? head : null;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The IPv4 address embedded in an IPv6 transition/translation format, or null when
|
|
93
|
+
* the address does not carry one.
|
|
94
|
+
*
|
|
95
|
+
* These formats are unwrapped so the IPv4 policy below applies to them: the WHATWG
|
|
96
|
+
* URL parser canonicalizes `http://[::ffff:169.254.169.254]/` to the hex form
|
|
97
|
+
* `::ffff:a9fe:a9fe`, which a dotted-quad match alone would miss.
|
|
98
|
+
*/
|
|
99
|
+
function embeddedIpv4Octets(groups) {
|
|
100
|
+
const [g0, g1, g2, g3, g4, g5, g6, g7] = groups;
|
|
101
|
+
const low32 = () => [(g6 >> 8) & 0xff, g6 & 0xff, (g7 >> 8) & 0xff, g7 & 0xff];
|
|
102
|
+
const leadingZeroGroups = g0 === 0 && g1 === 0 && g2 === 0 && g3 === 0;
|
|
103
|
+
// IPv4-mapped `::ffff:a.b.c.d` and IPv4-translated `::ffff:0:a.b.c.d`.
|
|
104
|
+
if (leadingZeroGroups && g4 === 0 && g5 === 0xffff)
|
|
105
|
+
return low32();
|
|
106
|
+
if (leadingZeroGroups && g4 === 0xffff && g5 === 0)
|
|
107
|
+
return low32();
|
|
108
|
+
// IPv4-compatible `::a.b.c.d` (deprecated; `::` and `::1` land here too).
|
|
109
|
+
if (leadingZeroGroups && g4 === 0 && g5 === 0)
|
|
110
|
+
return low32();
|
|
111
|
+
// NAT64 well-known prefix `64:ff9b::/96` and local-use prefix `64:ff9b:1::/48`.
|
|
112
|
+
if (g0 === 0x64 && g1 === 0xff9b && (g2 === 0 || g2 === 1))
|
|
113
|
+
return low32();
|
|
114
|
+
// 6to4 `2002::/16`, which tunnels to the IPv4 address held by the next two groups.
|
|
115
|
+
if (g0 === 0x2002) {
|
|
116
|
+
return [(g1 >> 8) & 0xff, g1 & 0xff, (g2 >> 8) & 0xff, g2 & 0xff];
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
/** True for the IPv4 ranges that must never be reached by following a redirect. */
|
|
121
|
+
function isNonPublicIpv4Octets(octets) {
|
|
122
|
+
const [first, second] = octets;
|
|
123
|
+
return (first === 0 ||
|
|
124
|
+
first === 10 ||
|
|
125
|
+
first === 127 ||
|
|
126
|
+
(first === 100 && second >= 64 && second <= 127) ||
|
|
127
|
+
(first === 169 && second === 254) ||
|
|
128
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
129
|
+
(first === 192 && second === 168) ||
|
|
130
|
+
(first === 192 && second === 0) ||
|
|
131
|
+
(first === 198 && (second === 18 || second === 19)) ||
|
|
132
|
+
first >= 224);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Classifies by parsed bits rather than by matching prefixes of the compressed
|
|
136
|
+
* string, so `fe80::/10` covers `fea0::` and `febf::`, and every address format
|
|
137
|
+
* carrying an embedded IPv4 address is judged by that address.
|
|
138
|
+
*/
|
|
139
|
+
function isNonPublicIpv6Groups(groups) {
|
|
140
|
+
const embedded = embeddedIpv4Octets(groups);
|
|
141
|
+
if (embedded)
|
|
142
|
+
return isNonPublicIpv4Octets(embedded);
|
|
143
|
+
const [first] = groups;
|
|
144
|
+
return ((first & 0xffc0) === 0xfe80 || // fe80::/10 link-local
|
|
145
|
+
(first & 0xffc0) === 0xfec0 || // fec0::/10 site-local (deprecated)
|
|
146
|
+
(first & 0xfe00) === 0xfc00 || // fc00::/7 unique local
|
|
147
|
+
(first & 0xff00) === 0xff00 || // ff00::/8 multicast
|
|
148
|
+
(first & 0xff00) === 0x0000 // 0000::/8 reserved
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* True for addresses that must never be reached by following a redirect:
|
|
153
|
+
* unspecified, loopback, private, link-local (including cloud instance metadata),
|
|
154
|
+
* CGNAT, benchmarking, multicast and IPv6 unique-local ranges.
|
|
155
|
+
*/
|
|
156
|
+
export function isNonPublicAddress(address) {
|
|
157
|
+
const version = isIP(address);
|
|
158
|
+
if (version === 4) {
|
|
159
|
+
const octets = parseIpv4Octets(address);
|
|
160
|
+
return octets ? isNonPublicIpv4Octets(octets) : true;
|
|
161
|
+
}
|
|
162
|
+
if (version === 6) {
|
|
163
|
+
const groups = parseIpv6Groups(address);
|
|
164
|
+
// Unparseable addresses fail closed.
|
|
165
|
+
return groups ? isNonPublicIpv6Groups(groups) : true;
|
|
166
|
+
}
|
|
167
|
+
// Not a literal address we understand — treat as unsafe.
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Whether a hop's destination is an operator-declared GitLab host. Such a host is
|
|
172
|
+
* reachable even when it resolves to a private address and keeps receiving the
|
|
173
|
+
* request credentials unless an earlier untrusted hop withheld them.
|
|
174
|
+
*/
|
|
175
|
+
function isTrustedRedirectTarget(target, options) {
|
|
176
|
+
return options.isTrustedRedirectHost?.(target.host) === true;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The request headers stripped of every credential header. Used for redirect hops
|
|
180
|
+
* that leave the origin that issued the credentials, so a GitLab 302 to object
|
|
181
|
+
* storage (or any other host) cannot leak a token.
|
|
182
|
+
*/
|
|
183
|
+
function withoutCredentialHeaders(options) {
|
|
184
|
+
const credentialHeaders = new Set((options.credentialHeaders ?? CREDENTIAL_HEADERS).map(name => name.toLowerCase()));
|
|
185
|
+
return Object.fromEntries(Object.entries(options.headers).filter(([name]) => !credentialHeaders.has(name.toLowerCase())));
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Refuses a hop whose address the server must never reach: anything that resolves
|
|
189
|
+
* to a non-public address, and any name that does not resolve at all. Trusted hosts
|
|
190
|
+
* are exempt, because an operator-declared GitLab instance may live on a private
|
|
191
|
+
* network.
|
|
192
|
+
*
|
|
193
|
+
* Runs on every redirect hop, so an upstream cannot steer the request to loopback,
|
|
194
|
+
* link-local instance metadata, or a private-range host.
|
|
195
|
+
*/
|
|
196
|
+
async function assertRedirectTargetAllowed(target, trusted, options) {
|
|
197
|
+
if (trusted) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const host = target.hostname.replace(/^\[/, "").replace(/\]$/, "");
|
|
201
|
+
if (isIP(host)) {
|
|
202
|
+
if (isNonPublicAddress(host)) {
|
|
203
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect to non-public address: ${host}`);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let records;
|
|
208
|
+
try {
|
|
209
|
+
records = await lookup(host, { all: true, verbatim: true });
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect to unresolvable host: ${host}`);
|
|
213
|
+
}
|
|
214
|
+
if (records.length === 0) {
|
|
215
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect to unresolvable host: ${host}`);
|
|
216
|
+
}
|
|
217
|
+
const blocked = records.find(record => isNonPublicAddress(record.address));
|
|
218
|
+
if (blocked) {
|
|
219
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect to non-public address ${blocked.address} for host ${host}. ` +
|
|
220
|
+
"Add the host to GITLAB_ALLOWED_HOSTS if the redirect target is trusted.");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Perform a GET request that validates every redirect hop before following it.
|
|
225
|
+
*
|
|
226
|
+
* The HTTP client default follows redirects without re-validating the destination,
|
|
227
|
+
* which lets an upstream response redirect the server to an arbitrary host
|
|
228
|
+
* (including loopback and link-local addresses). This helper fetches with
|
|
229
|
+
* `redirect: "manual"` and re-applies the destination check on each `Location`
|
|
230
|
+
* value, to every address form the URL parser can produce (compressed IPv6,
|
|
231
|
+
* IPv4-mapped, NAT64, ...).
|
|
232
|
+
*
|
|
233
|
+
* Credential headers are withheld from any hop that leaves the initial origin
|
|
234
|
+
* unless the destination is a trusted host, so redirects to object storage or any
|
|
235
|
+
* other host cannot exfiltrate the GitLab token. Once withheld they stay off every
|
|
236
|
+
* later hop, including a hop to a trusted host or back to the initial origin: the
|
|
237
|
+
* caller reads that response as the downloaded file, so an authenticated request there
|
|
238
|
+
* would be one the redirect target chose. A hop that would downgrade HTTPS to cleartext HTTP
|
|
239
|
+
* is refused before the credentials are considered at all.
|
|
240
|
+
*
|
|
241
|
+
* Residual risk: the destination host is resolved twice — once for the check and
|
|
242
|
+
* again when the connection is opened — so a DNS name with a short TTL can answer
|
|
243
|
+
* with a public address first and a non-public address on connect. Closing that
|
|
244
|
+
* window needs the connection to use the address that was validated, which is not
|
|
245
|
+
* reachable from here: rewriting the request origin to an IP literal drops the TLS
|
|
246
|
+
* `servername` (`undici` derives it from the request host) and would also bypass the
|
|
247
|
+
* proxy routing that `GitLabClientPool` applies per origin, so downloads through a
|
|
248
|
+
* proxy would break or lose hostname verification. The durable fix is a validating
|
|
249
|
+
* `connect.lookup` on the pool's own `Agent`s, which keeps the hostname as the
|
|
250
|
+
* origin; that covers every outbound request rather than this helper alone. The
|
|
251
|
+
* outbound request stays otherwise unrestricted towards public addresses, which
|
|
252
|
+
* GitLab object storage requires.
|
|
253
|
+
*/
|
|
254
|
+
export async function fetchWithValidatedRedirects(url, options) {
|
|
255
|
+
const authenticatedFetch = options.fetchImpl ?? undiciFetch;
|
|
256
|
+
// Not `authenticatedFetch`: a caller that passes a client able to add credentials —
|
|
257
|
+
// and forgets this option — must not hand it a hop with the credentials stripped.
|
|
258
|
+
const unauthenticatedFetch = options.unauthenticatedFetchImpl ?? undiciFetch;
|
|
259
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
260
|
+
const initialOrigin = new URL(url).origin;
|
|
261
|
+
let currentUrl = url;
|
|
262
|
+
let currentProtocol = new URL(url).protocol;
|
|
263
|
+
let currentHeaders = options.headers;
|
|
264
|
+
// Set once a hop outside the origin has been followed without the credentials, so a
|
|
265
|
+
// later hop back to the origin cannot re-attach them.
|
|
266
|
+
let credentialsWithheld = false;
|
|
267
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
268
|
+
// A stripped hop must not go through a client that can add credentials again.
|
|
269
|
+
const hopFetch = credentialsWithheld ? unauthenticatedFetch : authenticatedFetch;
|
|
270
|
+
const response = await hopFetch(currentUrl, {
|
|
271
|
+
method: "GET",
|
|
272
|
+
headers: currentHeaders,
|
|
273
|
+
dispatcher: options.dispatcher,
|
|
274
|
+
signal: options.signal,
|
|
275
|
+
redirect: "manual",
|
|
276
|
+
});
|
|
277
|
+
if (!REDIRECT_STATUSES.has(response.status)) {
|
|
278
|
+
return response;
|
|
279
|
+
}
|
|
280
|
+
const location = response.headers.get("location");
|
|
281
|
+
if (!location) {
|
|
282
|
+
return response;
|
|
283
|
+
}
|
|
284
|
+
// Release the redirect response so the connection can be reused.
|
|
285
|
+
try {
|
|
286
|
+
await response.body?.cancel();
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
// A body that is already closed needs no cleanup.
|
|
290
|
+
}
|
|
291
|
+
const target = new URL(location, currentUrl);
|
|
292
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
293
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect to unsupported protocol: ${target.protocol}`);
|
|
294
|
+
}
|
|
295
|
+
// A downgrade would put the request — including any credential a trusted host
|
|
296
|
+
// keeps — on the wire in cleartext, so it is refused before the destination is
|
|
297
|
+
// evaluated and before any trust decision can re-enable the credentials.
|
|
298
|
+
if (currentProtocol === "https:" && target.protocol === "http:") {
|
|
299
|
+
throw new UnsafeRedirectError(`Refusing to follow redirect from ${currentUrl} to cleartext ${target.toString()}`);
|
|
300
|
+
}
|
|
301
|
+
// A hop that stays on the originating origin needs no destination check. It keeps
|
|
302
|
+
// the credentials only when they were not withheld on the way here: a target that
|
|
303
|
+
// sends the request back to the origin would otherwise receive an authenticated
|
|
304
|
+
// response — for a URL it chose — that the caller of this helper then reads as the
|
|
305
|
+
// downloaded body. A hop that returns after only *trusted* hops keeps them.
|
|
306
|
+
if (target.origin === initialOrigin) {
|
|
307
|
+
currentHeaders = credentialsWithheld ? withoutCredentialHeaders(options) : options.headers;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const trusted = isTrustedRedirectTarget(target, options);
|
|
311
|
+
await assertRedirectTargetAllowed(target, trusted, options);
|
|
312
|
+
// Trust exempts the destination check but cannot undo a withhold: the untrusted
|
|
313
|
+
// hop chose this URL, and the caller reads its response as the download.
|
|
314
|
+
credentialsWithheld ||= !trusted;
|
|
315
|
+
currentHeaders = credentialsWithheld ? withoutCredentialHeaders(options) : options.headers;
|
|
316
|
+
}
|
|
317
|
+
currentUrl = target.toString();
|
|
318
|
+
currentProtocol = target.protocol;
|
|
319
|
+
}
|
|
320
|
+
throw new UnsafeRedirectError(`Too many redirects (limit ${maxRedirects})`);
|
|
321
|
+
}
|
package/build/utils/tool-args.js
CHANGED
|
@@ -64,11 +64,20 @@ export const LIST_MERGE_REQUESTS_ID_USERNAME_PAIRS = [
|
|
|
64
64
|
...LIST_ISSUES_ID_USERNAME_PAIRS,
|
|
65
65
|
["reviewer_id", "reviewer_username"],
|
|
66
66
|
];
|
|
67
|
+
/**
|
|
68
|
+
* Whether a username filter actually selects anything.
|
|
69
|
+
*
|
|
70
|
+
* Judged with the same helpers the list query serializers use, so the two agree: a blank
|
|
71
|
+
* value is not a value. Treating `[""]` or `" "` as one here would drop the id filter and
|
|
72
|
+
* then have the blank guard drop the username too, leaving no filter at all.
|
|
73
|
+
*/
|
|
67
74
|
function hasUsernameFilterValue(value) {
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
// null never reaches here from tools (sanitizeToolArguments strips it);
|
|
76
|
+
// keep the old Boolean(null) === false behavior.
|
|
77
|
+
if (value === null) {
|
|
78
|
+
return false;
|
|
70
79
|
}
|
|
71
|
-
return
|
|
80
|
+
return !isBlankFilterValue(dropBlankArrayEntries(value));
|
|
72
81
|
}
|
|
73
82
|
/**
|
|
74
83
|
* When both id and username filters are set, GitLab returns 400. Prefer username and drop id.
|