@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 CHANGED
@@ -110,7 +110,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
110
110
 
111
111
  예시는 기존 `mcp-gitlab`보다 충돌 가능성이 낮은 `zereight-mcp-gitlab` 별칭을 사용합니다. MCP 클라이언트가 찾지 못하면 `which zereight-mcp-gitlab`의 절대 경로를 사용하세요.
112
112
 
113
- 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.63`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
113
+ 전역 설치를 쓰지 않으려면 `npx -y @zereight/mcp-gitlab@2.1.64`처럼 직전 안정 버전(문서가 권장하는 버전)으로 고정하세요. 항상 최신 버전을 원하면 `npx -y @zereight/mcp-gitlab@latest`를 사용하세요. 새 버전이 나오면 서버가 시작 시 stderr로 알려줍니다(`GITLAB_DISABLE_VERSION_CHECK=true`로 비활성화 가능).
114
114
 
115
115
  #### CLI 인자 사용하기(환경 변수 문제가 있는 클라이언트용)
116
116
 
@@ -133,7 +133,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
133
133
  - `--token` - GitLab Personal Access Token (`GITLAB_PERSONAL_ACCESS_TOKEN` 대체)
134
134
  - `--api-url` - GitLab API URL (`GITLAB_API_URL` 대체)
135
135
  - `--read-only=true` - 읽기 전용 모드 활성화 (`GITLAB_READ_ONLY_MODE` 대체, deprecated — `--permission-mode=readonly` 권장)
136
- - `--permission-mode` - 권한 수준: `readonly`, `modify`(삭제 도구 비활성), `full` (`GITLAB_PERMISSION_MODE` 대체, 기본값 `full`)
136
+ - `--permission-mode` - 권한 수준: `readonly`, `modify`(삭제/중단 도구 비활성), `full` (`GITLAB_PERMISSION_MODE` 대체, 기본값 `full`)
137
137
  - `--use-wiki=true` - 위키 API 활성화 (`USE_GITLAB_WIKI` 대체, 레거시 — `GITLAB_TOOLSETS=wiki` 권장)
138
138
  - `--use-milestone=true` - 마일스톤 API 활성화 (`USE_MILESTONE` 대체, 레거시 — `GITLAB_TOOLSETS=milestones` 권장)
139
139
  - `--use-pipeline=true` - 파이프라인 API 활성화 (`USE_PIPELINE` 대체, 레거시 — `GITLAB_TOOLSETS=pipelines` 권장)
@@ -143,8 +143,12 @@ CLI 인자는 환경 변수보다 우선합니다.
143
143
 
144
144
  `zereight-mcp-gitlab auth`는 MCP 서버 플래그가 아니라 서브커맨드입니다. GitLab device flow를 실행한 뒤 종료합니다. [CLI 인자](./docs/getting-started/cli-arguments.md#auth)를 참고하세요.
145
145
 
146
- > **세밀한 도구 필터링:** `GITLAB_PERMISSION_MODE=modify`로 생성/수정은 허용하고 모든 삭제 도구를
147
- > 차단하거나(`execute_graphql` 삭제 mutation과 `push_files`의 `delete`/`move` 포함), `GITLAB_PERMISSION_MODE=readonly`로 읽기 전용으로 운영할 수 있습니다. 또한
146
+ > **세밀한 도구 필터링:** `GITLAB_PERMISSION_MODE=modify`로 생성/수정은 허용하고 모든 삭제 도구와
147
+ > 파괴적인 중단(teardown) 도구(`cancel_pipeline`, `cancel_pipeline_job`, `stop_environment`,
148
+ > `stop_stale_environments`, `unprotect_branch`)를 차단하거나(`execute_graphql`을 통한 파괴적
149
+ > mutation — 삭제·중단 동사 — 과 `push_files`의 `delete`/`move` 포함),
150
+ > `GITLAB_PERMISSION_MODE=readonly`로 읽기 전용으로 운영할 수
151
+ > 있습니다. 또한
148
152
  > `GITLAB_TOOLSETS=<group,…>`로 도구 그룹을 활성화하고, `GITLAB_TOOLS=<tool,…>`로 개별 도구만
149
153
  > 허용하며(예: 읽기 도구 + 특정 쓰기 도구 몇 개), `GITLAB_DENIED_TOOLS_REGEX`로 패턴 차단할 수
150
154
  > 있습니다. 레거시 `USE_GITLAB_WIKI` / `USE_MILESTONE` / `USE_PIPELINE` 플래그는 하위 호환용으로만
@@ -299,7 +303,7 @@ MCP 클라이언트 설정:
299
303
  | `REMOTE_AUTHORIZATION` | 예 | 활성화하려면 `true` |
300
304
  | `STREAMABLE_HTTP` | 예 | 반드시 `true` |
301
305
  | `ENABLE_DYNAMIC_API_URL` | 선택 | 요청별 `X-GitLab-API-URL` 헤더 허용 |
302
- | `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 `X-GitLab-API-URL` 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용 |
306
+ | `GITLAB_ALLOWED_HOSTS` | 선택 | 허용할 `X-GitLab-API-URL` 호스트의 쉼표 구분 목록; `GITLAB_API_URL` 호스트는 항상 허용. 다운로드 리다이렉트 대상(릴리즈 에셋, 잡 아티팩트, 업로드 첨부)으로도 신뢰되므로 사설망 호스트는 여기에 등록 |
303
307
  | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 선택 | 인증 없이 `initialize`, `notifications/initialized`, `tools/list`, `server/discover`만 허용(도구 호출은 여전히 인증 필요) |
304
308
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | 선택 | DNS rebinding 방지를 위한 허용 `/mcp` 호스트/오리진 값 |
305
309
  | `MCP_TRUST_PROXY` | 선택 | 리버스 프록시 뒤에서 `Forwarded` / `X-Forwarded-*` 헤더 신뢰(다운로드 URL, Express `req.ip`, `/mcp` IP rate limit, OAuth rate limit) |
@@ -364,6 +368,17 @@ Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx
364
368
 
365
369
  콜백 프록시 모드 상세는 [GitLab MCP OAuth Callback Proxy](./docs/auth/oauth-callback-proxy.md)를 참고하세요.
366
370
 
371
+ #### SSE 세션 제한
372
+
373
+ `GET /sse`에는 Streamable HTTP와 동일한 원격 전송 제어가 적용됩니다.
374
+
375
+ - **Capacity:** 동시 SSE 세션은 최대 `MAX_SESSIONS`개(기본 1000)이며, 초과 연결은 `503`을 받습니다.
376
+ - **생성 rate limit:** 새 연결은 클라이언트 IP당 `MAX_REQUESTS_PER_MINUTE`(기본 60)로 제한되며, 초과 연결은 `429`를 받습니다.
377
+ - **Idle timeout:** `SESSION_TIMEOUT_SECONDS`(기본 1시간) 동안 `POST /messages` 요청이 없는 세션은 종료됩니다. 따라서 유휴 클라이언트는 슬롯을 계속 점유하는 대신 다시 연결해야 합니다. Streamable HTTP와 달리 SSE 스트림을 열어 두는 것은 활동으로 간주되지 **않습니다**.
378
+ - 용량이 가득 찬 동안 `/health`는 `503`과 `status: "degraded"`를 반환합니다.
379
+
380
+ 설정은 [`MAX_SESSIONS`](./docs/configuration/environment-variables.md#max_sessions), `MAX_REQUESTS_PER_MINUTE`, `SESSION_TIMEOUT_SECONDS`를 참고하세요.
381
+
367
382
  ### 원격 인증 설정(멀티 유저 지원)
368
383
 
369
384
  `REMOTE_AUTHORIZATION=true`를 사용하면 MCP 서버는 여러 사용자를 지원할 수 있습니다. 각 사용자는 HTTP 헤더로 자신의 GitLab 토큰을 전달합니다. 다음 경우 유용합니다.
package/README.md CHANGED
@@ -115,7 +115,7 @@ The store path is pinned by your lock file; update it with `nix flake update git
115
115
 
116
116
  The examples use `zereight-mcp-gitlab`, a less collision-prone alias for the legacy `mcp-gitlab` binary. If your MCP client cannot find it, use the absolute path from `which zereight-mcp-gitlab`.
117
117
 
118
- No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.63`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
118
+ No global install? Pin `npx` to the previous stable release (the version these docs recommend), for example `npx -y @zereight/mcp-gitlab@2.1.64`. If you always want the newest release, use `npx -y @zereight/mcp-gitlab@latest` instead. The server prints a notice to stderr on startup when a newer version is available (disable with `GITLAB_DISABLE_VERSION_CHECK=true`).
119
119
 
120
120
  #### Using CLI Arguments (for clients with env var issues)
121
121
 
@@ -138,7 +138,7 @@ Some MCP clients (like GitHub Copilot CLI) have issues with environment variable
138
138
  - `--token` - GitLab Personal Access Token (replaces `GITLAB_PERSONAL_ACCESS_TOKEN`)
139
139
  - `--api-url` - GitLab API URL (replaces `GITLAB_API_URL`)
140
140
  - `--read-only=true` - Enable read-only mode (replaces `GITLAB_READ_ONLY_MODE`, deprecated — prefer `--permission-mode=readonly`)
141
- - `--permission-mode` - Permission level: `readonly`, `modify` (no delete tools), or `full` (replaces `GITLAB_PERMISSION_MODE`, default `full`)
141
+ - `--permission-mode` - Permission level: `readonly`, `modify` (no delete or teardown tools), or `full` (replaces `GITLAB_PERMISSION_MODE`, default `full`)
142
142
  - `--use-wiki=true` - Enable wiki API (replaces `USE_GITLAB_WIKI`, legacy — prefer `GITLAB_TOOLSETS=wiki`)
143
143
  - `--use-milestone=true` - Enable milestone API (replaces `USE_MILESTONE`, legacy — prefer `GITLAB_TOOLSETS=milestones`)
144
144
  - `--use-pipeline=true` - Enable pipeline API (replaces `USE_PIPELINE`, legacy — prefer `GITLAB_TOOLSETS=pipelines`)
@@ -153,9 +153,11 @@ CLI arguments take precedence over environment variables.
153
153
  `zereight-mcp-gitlab auth` is a subcommand (not an MCP server flag). It runs GitLab device flow and exits. See [CLI Arguments](./docs/getting-started/cli-arguments.md#auth).
154
154
 
155
155
  > **Fine-grained tool filtering:** use `GITLAB_PERMISSION_MODE=modify` to allow create/update while
156
- > blocking every delete tool (including delete mutations through `execute_graphql` and
157
- > `push_files` `delete`/`move` actions), or
158
- > `GITLAB_PERMISSION_MODE=readonly` for read-only access. You can also
156
+ > blocking every delete tool and the destructive teardown tools (`cancel_pipeline`,
157
+ > `cancel_pipeline_job`, `stop_environment`, `stop_stale_environments`, `unprotect_branch`) —
158
+ > including destructive mutations (deletion and teardown verbs) through `execute_graphql` and
159
+ > `push_files` `delete`/`move` actions — or `GITLAB_PERMISSION_MODE=readonly` for read-only
160
+ > access. You can also
159
161
  > enable toolset groups with `GITLAB_TOOLSETS=<group,…>`, allow-list individual tools with
160
162
  > `GITLAB_TOOLS=<tool,…>` (e.g. read-only groups plus a few specific write tools), and
161
163
  > deny-list by pattern with `GITLAB_DENIED_TOOLS_REGEX`. The legacy `USE_GITLAB_WIKI` /
@@ -331,7 +333,7 @@ the token to GitLab on behalf of the caller.
331
333
  | `REMOTE_AUTHORIZATION` | ✅ | Set to `true` to enable |
332
334
  | `STREAMABLE_HTTP` | ✅ | Must be `true` |
333
335
  | `ENABLE_DYNAMIC_API_URL` | optional | Allow per-request GitLab URL via `X-GitLab-API-URL` header |
334
- | `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed |
336
+ | `GITLAB_ALLOWED_HOSTS` | optional | Comma-separated allowed `X-GitLab-API-URL` hosts; `GITLAB_API_URL` hosts are always allowed. Also trusted as download redirect targets (release assets, job artifacts, uploaded attachments); list private-network hosts here |
335
337
  | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | optional | Allow unauthenticated `initialize`, `notifications/initialized`, `tools/list`, and `server/discover` only (tool calls still require auth) |
336
338
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | optional | Allowed public `/mcp` host/origin values for DNS rebinding protection |
337
339
  | `MCP_TRUST_PROXY` | optional | Trust `Forwarded` / `X-Forwarded-*` headers behind a reverse proxy (download URLs, Express `req.ip`, `/mcp` IP rate limits, OAuth rate limits) |
@@ -406,6 +408,17 @@ The reference document also covers:
406
408
 
407
409
  For callback proxy mode details, see [GitLab MCP OAuth Callback Proxy](./docs/auth/oauth-callback-proxy.md).
408
410
 
411
+ #### SSE session limits
412
+
413
+ `GET /sse` is subject to the same remote-transport controls as Streamable HTTP:
414
+
415
+ - **Capacity:** at most `MAX_SESSIONS` concurrent SSE sessions (default 1000); further connections get `503`.
416
+ - **Creation rate limit:** new connections are limited to `MAX_REQUESTS_PER_MINUTE` per client IP (default 60); excess connections get `429`.
417
+ - **Idle timeout:** a session that receives no `POST /messages` request for `SESSION_TIMEOUT_SECONDS` (default 1 hour) is closed, so an idle client must reconnect instead of holding a capacity slot. Unlike Streamable HTTP, holding the SSE stream open does **not** count as activity.
418
+ - `/health` returns `503` with `status: "degraded"` while the instance is at capacity.
419
+
420
+ Tune these with [`MAX_SESSIONS`](./docs/configuration/environment-variables.md#max_sessions), `MAX_REQUESTS_PER_MINUTE`, and `SESSION_TIMEOUT_SECONDS`.
421
+
409
422
  ### Remote Authorization Setup (Multi-User Support)
410
423
 
411
424
  When using `REMOTE_AUTHORIZATION=true`, the MCP server can support multiple users, each with their own GitLab token passed via HTTP headers. This is useful for:
package/README.zh-CN.md CHANGED
@@ -110,7 +110,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
110
110
 
111
111
  示例使用 `zereight-mcp-gitlab`,这是比旧的 `mcp-gitlab` 更不容易冲突的别名。如果 MCP 客户端找不到它,请使用 `which zereight-mcp-gitlab` 输出的绝对路径。
112
112
 
113
- 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.63`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
113
+ 如果不想全局安装,请将 `npx` 固定到上一个稳定版本(即文档推荐的版本),例如 `npx -y @zereight/mcp-gitlab@2.1.64`。如果始终想使用最新版本,请改用 `npx -y @zereight/mcp-gitlab@latest`。有新版本发布时,服务器会在启动时通过 stderr 提示(可用 `GITLAB_DISABLE_VERSION_CHECK=true` 关闭)。
114
114
 
115
115
  #### 使用 CLI 参数(适用于环境变量有问题的客户端)
116
116
 
@@ -133,7 +133,7 @@ command = lib.getExe inputs.gitlab-mcp.packages.${system}.default;
133
133
  - `--token` - GitLab Personal Access Token(替代 `GITLAB_PERSONAL_ACCESS_TOKEN`)
134
134
  - `--api-url` - GitLab API URL(替代 `GITLAB_API_URL`)
135
135
  - `--read-only=true` - 启用只读模式(替代 `GITLAB_READ_ONLY_MODE`,已弃用 — 推荐 `--permission-mode=readonly`)
136
- - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`)
136
+ - `--permission-mode` - 权限级别:`readonly`、`modify`(禁用删除/拆除工具)或 `full`(替代 `GITLAB_PERMISSION_MODE`,默认 `full`)
137
137
  - `--use-wiki=true` - 启用 Wiki API(替代 `USE_GITLAB_WIKI`,旧版 — 推荐 `GITLAB_TOOLSETS=wiki`)
138
138
  - `--use-milestone=true` - 启用里程碑 API(替代 `USE_MILESTONE`,旧版 — 推荐 `GITLAB_TOOLSETS=milestones`)
139
139
  - `--use-pipeline=true` - 启用流水线 API(替代 `USE_PIPELINE`,旧版 — 推荐 `GITLAB_TOOLSETS=pipelines`)
@@ -147,9 +147,11 @@ CLI 参数优先于环境变量。
147
147
 
148
148
  `zereight-mcp-gitlab auth` 是子命令,不是 MCP 服务器参数。它运行 GitLab device flow 后退出。参见 [CLI 参数](./docs/getting-started/cli-arguments.md#auth)。
149
149
 
150
- > **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新并阻止所有删除工具
151
- > (包括通过 `execute_graphql` 的删除 mutation 以及 `push_files` 的 `delete`/`move`),
152
- > 或使用 `GITLAB_PERMISSION_MODE=readonly` 只读运行。还可以用
150
+ > **细粒度工具过滤:**使用 `GITLAB_PERMISSION_MODE=modify` 允许创建/更新,同时阻止所有删除工具以及
151
+ > 破坏性的拆除(teardown)工具(`cancel_pipeline`、`cancel_pipeline_job`、`stop_environment`、
152
+ > `stop_stale_environments`、`unprotect_branch`)(包括通过 `execute_graphql` 的破坏性 mutation
153
+ > —— 删除与拆除动词 —— 以及 `push_files` 的 `delete`/`move`),或使用
154
+ > `GITLAB_PERMISSION_MODE=readonly` 只读运行。还可以用
153
155
  > `GITLAB_TOOLSETS=<group,…>` 启用工具分组,用 `GITLAB_TOOLS=<tool,…>` 白名单启用单个工具
154
156
  > (例如:只读分组 + 少数几个写工具),用 `GITLAB_DENIED_TOOLS_REGEX` 按正则屏蔽工具。
155
157
  > 旧版 `USE_GITLAB_WIKI` / `USE_MILESTONE` / `USE_PIPELINE` 标志仅为向后兼容保留。
@@ -304,7 +306,7 @@ MCP 客户端配置:
304
306
  | `REMOTE_AUTHORIZATION` | 是 | 设置为 `true` 以启用 |
305
307
  | `STREAMABLE_HTTP` | 是 | 必须为 `true` |
306
308
  | `ENABLE_DYNAMIC_API_URL` | 可选 | 允许按请求通过 `X-GitLab-API-URL` 请求头指定 GitLab URL |
307
- | `GITLAB_ALLOWED_HOSTS` | 可选 | 允许的 `X-GitLab-API-URL` 主机逗号分隔列表;`GITLAB_API_URL` 中的主机始终允许 |
309
+ | `GITLAB_ALLOWED_HOSTS` | 可选 | 允许的 `X-GitLab-API-URL` 主机逗号分隔列表;`GITLAB_API_URL` 中的主机始终允许。也作为下载重定向目标(release 资产、job 工件、上传附件)受信任;私有网络主机请在此列出 |
308
310
  | `GITLAB_ALLOW_UNAUTHENTICATED_TOOL_DISCOVERY` | 可选 | 仅允许未认证的 `initialize`、`notifications/initialized`、`tools/list`、`server/discover`(工具调用仍需认证) |
309
311
  | `MCP_SERVER_URL` / `MCP_ALLOWED_HOSTS` / `MCP_ALLOWED_ORIGINS` | 可选 | 用于 DNS rebinding 防护的允许 `/mcp` 主机/来源值 |
310
312
  | `MCP_TRUST_PROXY` | 可选 | 在反向代理后信任 `Forwarded` / `X-Forwarded-*` 请求头(下载 URL、Express `req.ip`、`/mcp` IP 速率限制、OAuth 速率限制) |
@@ -369,6 +371,17 @@ Authorization: Bearer glpat-xxxxxxxxxxxxxxxxxxxx
369
371
 
370
372
  回调代理模式详情请参阅 [GitLab MCP OAuth Callback Proxy](./docs/auth/oauth-callback-proxy.md)。
371
373
 
374
+ #### SSE 会话限制
375
+
376
+ `GET /sse` 与 Streamable HTTP 受到相同的远程传输控制:
377
+
378
+ - **容量:** 并发 SSE 会话最多为 `MAX_SESSIONS` 个(默认 1000),超出的连接返回 `503`。
379
+ - **创建速率限制:** 新连接按客户端 IP 受 `MAX_REQUESTS_PER_MINUTE` 限制(默认 60),超出的连接返回 `429`。
380
+ - **空闲超时:** 在 `SESSION_TIMEOUT_SECONDS`(默认 1 小时)内没有 `POST /messages` 请求的会话会被关闭,因此空闲客户端必须重新连接,而不能一直占用容量槽位。与 Streamable HTTP 不同,保持 SSE 流打开**不**算作活动。
381
+ - 实例达到容量时,`/health` 返回 `503` 和 `status: "degraded"`。
382
+
383
+ 可通过 [`MAX_SESSIONS`](./docs/configuration/environment-variables.md#max_sessions)、`MAX_REQUESTS_PER_MINUTE` 和 `SESSION_TIMEOUT_SECONDS` 调整这些限制。
384
+
372
385
  ### 远程授权设置(多用户支持)
373
386
 
374
387
  使用 `REMOTE_AUTHORIZATION=true` 时,MCP 服务器可以支持多个用户,每个用户通过 HTTP 请求头传入自己的 GitLab token。适用于:
@@ -1,6 +1,7 @@
1
1
  import { Readable } from "node:stream";
2
2
  import { pipeline } from "node:stream/promises";
3
3
  import { decryptDownloadToken } from "../utils/download-token.js";
4
+ import { fetchWithValidatedRedirects, UnsafeRedirectError } from "../utils/safe-redirect-fetch.js";
4
5
  const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
5
6
  function canonicalizeQueryParams(params) {
6
7
  const sortedKeys = Object.keys(params).sort();
@@ -119,7 +120,7 @@ export function registerDownloadProxy(app, deps) {
119
120
  return;
120
121
  }
121
122
  const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
122
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
123
+ gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/jobs/${deps.encodeGitLabPathSegment(job_id)}/artifacts`;
123
124
  break;
124
125
  }
125
126
  case "attachment": {
@@ -129,7 +130,9 @@ export function registerDownloadProxy(app, deps) {
129
130
  return;
130
131
  }
131
132
  const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
132
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPath(filename)}`;
133
+ // The uploads route takes one filename segment; a slash inside the name must
134
+ // stay inside that segment instead of turning into another route level.
135
+ gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPathSegment(filename)}`;
133
136
  break;
134
137
  }
135
138
  case "release-asset": {
@@ -141,7 +144,7 @@ export function registerDownloadProxy(app, deps) {
141
144
  return;
142
145
  }
143
146
  const effectiveProjectId = deps.getEffectiveProjectId(decodeURIComponent(project_id));
144
- gitlabUrl = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
147
+ gitlabUrl = `${apiUrl}/projects/${deps.encodeGitLabPathSegment(effectiveProjectId)}/releases/${deps.encodeGitLabPathSegment(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`;
145
148
  break;
146
149
  }
147
150
  default:
@@ -158,10 +161,12 @@ export function registerDownloadProxy(app, deps) {
158
161
  const controller = new AbortController();
159
162
  const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
160
163
  try {
161
- const gitlabResponse = await deps.fetch(gitlabUrl, {
164
+ const gitlabResponse = await fetchWithValidatedRedirects(gitlabUrl, {
162
165
  headers,
163
166
  dispatcher: deps.getDispatcherForUrl(apiUrl),
164
167
  signal: controller.signal,
168
+ fetchImpl: deps.fetch,
169
+ isTrustedRedirectHost: deps.isTrustedRedirectHost,
165
170
  });
166
171
  if (!gitlabResponse.ok) {
167
172
  res.status(gitlabResponse.status).json({
@@ -187,9 +192,11 @@ export function registerDownloadProxy(app, deps) {
187
192
  catch (error) {
188
193
  deps.logger.error({ err: error }, "Download proxy error");
189
194
  if (!res.headersSent) {
190
- const message = error instanceof Error && error.name === "AbortError"
191
- ? "GitLab download timed out"
192
- : "Failed to proxy download from GitLab";
195
+ const message = error instanceof UnsafeRedirectError
196
+ ? `Refused upstream redirect: ${error.message}`
197
+ : error instanceof Error && error.name === "AbortError"
198
+ ? "GitLab download timed out"
199
+ : "Failed to proxy download from GitLab";
193
200
  res.status(502).json({ error: message });
194
201
  }
195
202
  }