Skip to content

Back to the catalog

One Request to Own Every Repo: How We Hijacked GitLab Through Its MCP Server - Pluto Security
pluto.securitywriteup

One Request to Own Every Repo: How We Hijacked GitLab Through Its MCP Server - Pluto Security

Pluto Security details two critical vulnerabilities in gitlab-mcp (@zereight/mcp-gitlab, 200K+ downloads), the most popular community GitLab MCP server. GHSA-cv3r-c5h8-f4g5 (CVSS 9.8) chains an unauthenticated SSE transport with the upload_markdown tool's unvalidated file_path to read /proc/self/environ and steal the server's GitLab Personal Access Token; GHSA-2h44-8472-frjj (CVSS 9.6) abuses the X-GitLab-API-URL header when ENABLE_DYNAMIC_API_URL=true to make the server attach its PAT to attacker-controlled hosts via SSRF. Both are fixed in v2.1.27 (SSE bearer-auth, non-root Docker, host allowlisting), and the writeup includes full attack commands, root-cause code, and a disclosure timeline.

TL;DR Two critical unauth-file-read and SSRF bugs in the popular gitlab-mcp server let attackers steal the server's GitLab PAT for full account takeover; fixed in v2.1.27.
Document

What it is

Two unauthenticated critical vulnerabilities in @zereight/mcp-gitlab (gitlab-mcp) < 2.1.27, the most popular community GitLab MCP server. Each independently yields the server's GitLab PAT and thus full account takeover. GHSA-cv3r-c5h8-f4g5 (CVSS 9.8, CWE-22) and GHSA-2h44-8472-frjj (CVSS 9.6, CWE-918). No CVEs assigned yet. Fixed in v2.1.27.

How it works

Finding #1 chains two defaults. The SSE transport wires /sse and /messages with no auth middleware, and its per-request auth feature (REMOTE_AUTHORIZATION=true) is explicitly incompatible with SSE mode — there is no supported way to lock it. The upload_markdown tool (in the default-enabled users toolset) takes a bare z.string() file_path, reads it, and uploads it to a GitLab project. Reading /proc/self/environ exfiltrates GITLAB_PERSONAL_ACCESS_TOKEN. The clever part: the stolen data leaves through GitLab itself as a project attachment retrieved over HTTPS, so egress looks like normal traffic to your own GitLab. The shipped Dockerfile has no USER directive (runs as root) and docker-compose maps 3002:3002 (binds 0.0.0.0).

Finding #2: with ENABLE_DYNAMIC_API_URL=true, the X-GitLab-API-URL header sets the outbound API base URL and is validated only by new URL() — a syntax check, not an allowlist. The server then attaches Private-Token: <victim_token> to a request aimed at any attacker host. A confused deputy: one request and the credential walks out.

Preconditions / Affected

  • #1: any instance reachable with SSE=true and a PAT set — the default Docker posture — plus a writable project ID (enumerable via list_projects).
  • #2: any HTTP-transport deployment with ENABLE_DYNAMIC_API_URL=true. Note this reaches the sink in the documented REMOTE_AUTHORIZATION=true mode; the earlier startup guard never touched the header-parsing path.

What to do

Upgrade to v2.1.27+ first: npm i @zereight/mcp-gitlab@latest. It adds requireSseAuth bearer middleware, fails closed (refuses to boot when SSE=true on a non-loopback host without SSE_AUTH_TOKEN, unless SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true), adds USER node, binds compose to 127.0.0.1:3002, and allowlists X-GitLab-API-URL against GITLAB_ALLOWED_HOSTS.

Then:

  • Rotate the PAT. If ever network-reachable in SSE mode or run with ENABLE_DYNAMIC_API_URL=true, treat it as compromised; reissue with narrowest scope.
  • Audit: GitLab access logs for unexpected token use, recent project uploads for an environ attachment, outbound requests to hosts that aren't your GitLab.
  • Bind to 127.0.0.1 or front with an authenticating proxy; never expose 3002 routably. Set SSE_AUTH_TOKEN for remote SSE.
  • Leave ENABLE_DYNAMIC_API_URL off unless needed; if needed, set GITLAB_ALLOWED_HOSTS to exact trusted hostnames.

Caveats

The v2.1.27 fix for #1 does not sanitize upload_markdown's file_path — it only closes the unauthenticated door and shrinks blast radius, so the arbitrary-read primitive remains if auth is ever bypassed. Disclosure gap worth noting: Finding #1's fix shipped ~3.5 months after report, during which the vulnerable config stayed the default with no public advisory to prompt defenders.

Organisations named here

  • CWE-918
  • GHSA-2h44-8472-frjj
  • GHSA-cv3r-c5h8-f4g5

Technologies named here

  • @zereight/mcp-gitlab
  • gitlab-mcp
  • upload_markdown tool
  • list_projects tool
  • list_issues tool
  • Model Context Protocol
  • GitLab
  • Docker
  • npm
  • Node.js
  • CVSS

Techniques named here

  • Exfiltration via legitimate service channel
  • Confused deputy attack
  • Path traversal
  • SSRF