@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.
@@ -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
+ });
@@ -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/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/notes`);
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/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
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/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
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/${encodeURIComponent(PROJECT_ID)}/issues/${ISSUE_IID}/discussions/${DISCUSSION_ID}/notes/${NOTE_ID}`);
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: {
@@ -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 that permanently delete resources — blocked in "modify" permission mode.
1448
- // Narrower than destructiveTools: merge/protect/push are modifications, not deletions.
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
- const DELETE_FIELD_PATTERN = /delete|destroy|remove|prune|purge/i;
92
- // Collects top-level selection field names (and aliases) of every mutation operation.
93
- // Content inside parentheses (arguments) is skipped so argument names like
94
- // removeSourceBranch do not count as delete fields. Returns null when a top-level
95
- // fragment spread is present, since the spread could hide a delete field.
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 = /(?:^|[};]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;
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
- fields.push(current);
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 || !/(?:^|[};]\s*)mutation\b/.test(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 => DELETE_FIELD_PATTERN.test(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
+ }
@@ -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
- if (Array.isArray(value)) {
69
- return value.length > 0;
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 Boolean(value);
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.