GCVE Workshop - 22 September 2026 (14:00-18:00), Luxembourg Before The Vulnopticon Conference - Registration

GHSA-5648-RGJ9-V224

Vulnerability from github – Published: 2026-09-15 20:48 – Updated: 2026-09-15 20:48
VLAI
Summary
@zereight/mcp-gitlab has multiple safety-control bypasses: execute_graphql read-only + allow-list bypass, unauthenticated transports, session-exhaustion DoS
Details

Summary

@zereight/mcp-gitlab exposes GitLab to an LLM agent while relying on read-only mode, a project allow-list, and transport auth as its safety controls. Five defects defeat those controls. Under the MCP threat model, tool-call arguments/content can be shaped by untrusted input (prompt injection) or a malicious client.

Reviewed commit: 60adcc0de5b0e96c4c2029f7a25d2775946421d8 (package version 2.1.28). Source review only; PoCs are local/offline.

  • F1 (HIGH) execute_graphql defeats BOTH read-only mode and GITLAB_ALLOWED_PROJECT_IDS.
  • F2 (HIGH, deployment-conditional) Streamable HTTP /mcp unauthenticated under cookie-jar / device-flow credentials.
  • F3 (MEDIUM) SSE unauthenticated by default, no Origin/Host validation (DNS rebinding).
  • F4 (HIGH) unauthenticated session/transport-exhaustion DoS (token check is syntactic only).
  • F5 (LOW) CI job trace returned verbatim (prompt-injection surface).

Details

F1 — index.ts:9194-9245 (case "execute_graphql"), guard at :9196, detector in utils/graphql-query.ts. (a) Read-only bypass: graphqlQueryContainsWriteOperation() strips comments/strings then tests /(?:^|[};]\s*)(mutation|subscription)\b/. GraphQL treats commas as insignificant, and stripGraphQLCommentsAndStrings does not remove them, so a document beginning with ,mutation{...} executes as a write but is classified read-only. (b) Allow-list bypass: the handler never calls getEffectiveProjectId() or rejectIfProjectScopedDeployment() (unlike other tools), so a raw GraphQL body reaches /api/graphql with the server token against any project the token can access, regardless of GITLAB_ALLOWED_PROJECT_IDS. execute_graphql is listed in readOnlyTools (tools/registry.ts:1288).

F2 — index.ts:1048-1052 forces REMOTE_AUTHORIZATION/GITLAB_MCP_OAUTH only when started with a PAT or job token; hasCookie (:1029) and useOAuth (:1026) are absent from the gate. Started with --cookie-path or --use-oauth, validation passes and mcpBearerAuth degrades to next() (:12903). Every /mcp caller is unauthenticated while buildAuthHeaders() attaches the server's live session upstream.

F3 — index.ts:12276 requireSseAuth is a pass-through when SSE_AUTH_TOKEN is unset (default), and the SSE transport (:12290) is created with no enableDnsRebindingProtection/allowedHosts/allowedOrigins. On the default loopback bind, a malicious web page can DNS-rebind to drive the local server with the operator's GitLab credentials.

F4 — index.ts:12387 validateToken only checks length>=20 and charset (no upstream verification); parseAuthHeaders returns AuthData for any such string; new sessions are admitted purely on capacity (:12938, MAX_SESSIONS default 1000), and the per-session rate limiter only runs when a sessionId already exists. So garbage-token initialize floods fill all session slots for SESSION_TIMEOUT_SECONDS (default 3600s) -> 503 for legitimate users.

F5 — index.ts:10898-10911 (get_pipeline_job_output) returns the CI job trace to the model verbatim. Job logs are attacker-influenceable (e.g. a fork MR pipeline), so embedded instructions become model context and, combined with F1, can escalate to writes.

PoC (local, deterministic)

F1 detector (replicates the shipped logic, no network): isWrite("mutation{deleteProject(input:{id:1}){errors}}") -> DETECTED isWrite(",mutation{deleteProject(input:{id:1}){errors}}") -> BYPASS (executes as a write under read-only mode)

F2 (local GitLab + cookie file): STREAMABLE_HTTP=true GITLAB_AUTH_COOKIE_PATH=./cookies.txt GITLAB_API_URL=http://localhost:8080/api/v4 node build/index.js # starts without an auth error; from another shell with NO credentials: curl -s http://127.0.0.1:3002/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"poc","version":"0"}}}'

F4 (REMOTE_AUTHORIZATION mode): for i in $(seq 1 1000); do curl -s -o /dev/null http://127.0.0.1:3002/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -H 'Private-Token: aaaaaaaaaaaaaaaaaaaaaaaa' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"x","version":"0"}}}'; done # subsequent legitimate initialize -> 503 "Maximum 1000 concurrent sessions allowed"

Impact

A semi-trusted client or a prompt-injected agent can perform arbitrary GitLab writes while the operator believes the server is read-only, against projects outside the allow-list, up to the token's privileges (F1). Anyone able to reach the port (directly or via DNS rebinding on loopback) can use the server's GitLab credentials with no authentication (F2/F3). An unauthenticated attacker can deny service with ~1000 trivial requests (F4). Attacker-influenced CI logs can steer the agent (F5).

Remediation

Parse execute_graphql with a real GraphQL parser and reject non-query operations; enforce project scope or disable the tool under an allow-list (F1). Extend the startup gate to (hasToken || hasJobToken || hasCookie || useOAuth) and ship a mandatory Streamable-HTTP auth token (F2). Enable SDK DNS-rebinding protection with allowedHosts/allowedOrigins and require SSE_AUTH_TOKEN by default (F3). Validate tokens upstream before allocating a session, rate-limit new-session creation per IP, lower idle timeout (F4). Frame externally-sourced content as untrusted and cap size (F5).

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "npm",
        "name": "@zereight/mcp-gitlab"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "2.1.30"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-09-15T20:48:22Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "### Summary\n@zereight/mcp-gitlab exposes GitLab to an LLM agent while relying on read-only mode, a project allow-list, and transport auth as its safety controls. Five defects defeat those controls. Under the MCP threat model, tool-call arguments/content can be shaped by untrusted input (prompt injection) or a malicious client.\n\nReviewed commit: 60adcc0de5b0e96c4c2029f7a25d2775946421d8 (package version 2.1.28). Source review only; PoCs are local/offline.\n\n- F1 (HIGH) execute_graphql defeats BOTH read-only mode and GITLAB_ALLOWED_PROJECT_IDS.\n- F2 (HIGH, deployment-conditional) Streamable HTTP /mcp unauthenticated under cookie-jar / device-flow credentials.\n- F3 (MEDIUM) SSE unauthenticated by default, no Origin/Host validation (DNS rebinding).\n- F4 (HIGH) unauthenticated session/transport-exhaustion DoS (token check is syntactic only).\n- F5 (LOW) CI job trace returned verbatim (prompt-injection surface).\n\n### Details\n\nF1 \u2014 index.ts:9194-9245 (case \"execute_graphql\"), guard at :9196, detector in utils/graphql-query.ts.\n(a) Read-only bypass: graphqlQueryContainsWriteOperation() strips comments/strings then tests /(?:^|[};]\\s*)(mutation|subscription)\\b/. GraphQL treats commas as insignificant, and stripGraphQLCommentsAndStrings does not remove them, so a document beginning with `,mutation{...}` executes as a write but is classified read-only. (b) Allow-list bypass: the handler never calls getEffectiveProjectId() or rejectIfProjectScopedDeployment() (unlike other tools), so a raw GraphQL body reaches /api/graphql with the server token against any project the token can access, regardless of GITLAB_ALLOWED_PROJECT_IDS. execute_graphql is listed in readOnlyTools (tools/registry.ts:1288).\n\nF2 \u2014 index.ts:1048-1052 forces REMOTE_AUTHORIZATION/GITLAB_MCP_OAUTH only when started with a PAT or job token; hasCookie (:1029) and useOAuth (:1026) are absent from the gate. Started with --cookie-path or --use-oauth, validation passes and mcpBearerAuth degrades to next() (:12903). Every /mcp caller is unauthenticated while buildAuthHeaders() attaches the server\u0027s live session upstream.\n\nF3 \u2014 index.ts:12276 requireSseAuth is a pass-through when SSE_AUTH_TOKEN is unset (default), and the SSE transport (:12290) is created with no enableDnsRebindingProtection/allowedHosts/allowedOrigins. On the default loopback bind, a malicious web page can DNS-rebind to drive the local server with the operator\u0027s GitLab credentials.\n\nF4 \u2014 index.ts:12387 validateToken only checks length\u003e=20 and charset (no upstream verification); parseAuthHeaders returns AuthData for any such string; new sessions are admitted purely on capacity (:12938, MAX_SESSIONS default 1000), and the per-session rate limiter only runs when a sessionId already exists. So garbage-token initialize floods fill all session slots for SESSION_TIMEOUT_SECONDS (default 3600s) -\u003e 503 for legitimate users.\n\nF5 \u2014 index.ts:10898-10911 (get_pipeline_job_output) returns the CI job trace to the model verbatim. Job logs are attacker-influenceable (e.g. a fork MR pipeline), so embedded instructions become model context and, combined with F1, can escalate to writes.\n\n### PoC (local, deterministic)\n\nF1 detector (replicates the shipped logic, no network):\n    isWrite(\"mutation{deleteProject(input:{id:1}){errors}}\")   -\u003e DETECTED\n    isWrite(\",mutation{deleteProject(input:{id:1}){errors}}\")  -\u003e BYPASS (executes as a write under read-only mode)\n\nF2 (local GitLab + cookie file):\n    STREAMABLE_HTTP=true GITLAB_AUTH_COOKIE_PATH=./cookies.txt GITLAB_API_URL=http://localhost:8080/api/v4 node build/index.js\n    # starts without an auth error; from another shell with NO credentials:\n    curl -s http://127.0.0.1:3002/mcp -H \u0027Content-Type: application/json\u0027 -H \u0027Accept: application/json, text/event-stream\u0027 \\\n      -d \u0027{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"poc\",\"version\":\"0\"}}}\u0027\n\nF4 (REMOTE_AUTHORIZATION mode):\n    for i in $(seq 1 1000); do curl -s -o /dev/null http://127.0.0.1:3002/mcp \\\n      -H \u0027Content-Type: application/json\u0027 -H \u0027Accept: application/json, text/event-stream\u0027 \\\n      -H \u0027Private-Token: aaaaaaaaaaaaaaaaaaaaaaaa\u0027 \\\n      -d \u0027{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"x\",\"version\":\"0\"}}}\u0027; done\n    # subsequent legitimate initialize -\u003e 503 \"Maximum 1000 concurrent sessions allowed\"\n\n### Impact\nA semi-trusted client or a prompt-injected agent can perform arbitrary GitLab writes while the operator believes the server is read-only, against projects outside the allow-list, up to the token\u0027s privileges (F1). Anyone able to reach the port (directly or via DNS rebinding on loopback) can use the server\u0027s GitLab credentials with no authentication (F2/F3). An unauthenticated attacker can deny service with ~1000 trivial requests (F4). Attacker-influenced CI logs can steer the agent (F5).\n\n### Remediation\nParse execute_graphql with a real GraphQL parser and reject non-query operations; enforce project scope or disable the tool under an allow-list (F1). Extend the startup gate to (hasToken || hasJobToken || hasCookie || useOAuth) and ship a mandatory Streamable-HTTP auth token (F2). Enable SDK DNS-rebinding protection with allowedHosts/allowedOrigins and require SSE_AUTH_TOKEN by default (F3). Validate tokens upstream before allocating a session, rate-limit new-session creation per IP, lower idle timeout (F4). Frame externally-sourced content as untrusted and cap size (F5).",
  "id": "GHSA-5648-rgj9-v224",
  "modified": "2026-09-15T20:48:22Z",
  "published": "2026-09-15T20:48:22Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/security/advisories/GHSA-5648-rgj9-v224"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/issues/596"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/issues/748"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/pull/571"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/pull/624"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/commit/69e784da33e96e64867b511c6f14d3f21f91ba8b"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/zereight/gitlab-mcp"
    },
    {
      "type": "WEB",
      "url": "https://github.com/zereight/gitlab-mcp/releases/tag/v2.1.30"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "@zereight/mcp-gitlab has multiple safety-control bypasses: execute_graphql read-only + allow-list bypass, unauthenticated transports, session-exhaustion DoS"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…