Skip to content

GITLAB_PERMISSION_MODE=modify delete-ban bypassed: comma-separated GraphQL operations make mutations invisible to graphqlQueryContainsDeleteOperation (distinct mechanism from GHSA-f492)

Moderate
zereight published GHSA-7cr4-55f8-p36f Sep 23, 2026

Package

npm @zereight/mcp-gitlab (npm)

Affected versions

<= 2.1.63

Patched versions

2.1.65

Description

GITLAB_PERMISSION_MODE=modify delete-ban bypassed by comma-form GraphQL operations (detection-evasion, distinct from GHSA-f492)

  • Target: @zereight/mcp-gitlab (npm), released tag v2.1.63 (commit d265a91, 2026-09-17, latest)
  • Type: safety-control bypass in the execute_graphql tool (permission-mode enforcement)
  • Severity: Medium — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N = 6.5
  • CWE: CWE-284 (Improper Access Control)

Summary

GITLAB_PERMISSION_MODE=modify is documented as: allow create/update, block all delete operations (README.md: "--permission-mode ... modify (no delete tools)"; docs/configuration/environment-variables.md: "modify hides all delete_* tools from tools/list, rejects them when called").

For execute_graphql this is enforced by graphqlQueryContainsDeleteOperation() (utils/graphql-query.ts:155). That detector recognises a mutation only when the keyword is preceded by start-of-string, } or ;:

if (!normalized || !/(?:^|[};]\s*)mutation\b/.test(normalized)) { return false; }
...
const mutationRegex = /(?:^|[};]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;

The sibling write-check for read-only mode (graphqlQueryContainsWriteOperation, same file) explicitly handles comma-separated operations (/\s*(?:,\s*)?(query|mutation|subscription)\b/y) — the delete-check does not.

GraphQL treats commas as ignorable tokens, and GitLab's GraphQL engine (graphql-ruby) drops them at the lexer level:

EFFICIENT_IGNORE_REGEXP = /(?>[, \r\n\t]+|\#[^\n]*$)*/   # lib/graphql/language.rb:93 (master)

So a document such as

, mutation M { deleteIssue(input: { projectPath: "group/proj", iid: "1" }) { id } }

is token-identical (to GitLab) to the same document without the comma; GitLab parses and executes it. The detector returns false, modify mode forwards the query, the destructive mutation executes. Because the mutation keyword is invisible to the detector, any mutation passes — including delete/destroy/remove/purge verbs the denylist is designed to catch.

Relationship to known advisories (including our own)

  • GHSA-f492-qxqp-m79r (this repo, triage, submitted by us 2026-09-16): destructive mutations with non-delete verb names (environmentStop, clusterAgentTokenRevoke, pipelineCancel) passing DELETE_FIELD_PATTERN. This report is a distinct mechanism: the comma form defeats the keyword/syntax detection itself, so delete-named verbs also pass — extending the verb pattern (the fix direction for f492) leaves this gap fully open.
  • GHSA-5648-rgj9-v224 (published, fixed 2.1.30): its F1 "read-only comma bypass" referred to the read-only write-check, which was since fixed by commit 39a50a4 ("fix: detect leading comma mutations..."). The modify-mode delete-check never received the same comma handling — the gap documented here.

Affected versions

Version Verification
2.1.63 (released tag v2.1.63, commit d265a91) cold-process run of the shipped utils/graphql-query.ts (timings.txt)
all releases >= 2.1.30 carrying graphqlQueryContainsDeleteOperation with the comma-less mutation prefix same code shape (file unchanged since the modify-mode guard landed)

Proof (cold, deterministic, no network)

poc.js imports the shipped file at v2.1.63 (Node >= 22.6 native TS type-stripping) and asserts:

CONTROL_delete_blocked:   mutation { deleteIssue(...) {...} }                       -> deleteCheck= true   (correctly blocked)
CONTROL_write_check:      A's document through the READONLY write-check             -> writeCheck= true    (sibling check sees the mutation)
A  query HealthCheck { x }, mutation M { deleteIssue(...) { id } }                  -> deleteCheck= FALSE  (BYPASS)
B  , mutation M { deleteIssue(...) { id } }  (single op, leading comma)             -> deleteCheck= FALSE  (BYPASS)
C  query HealthCheck { x }, mutation M { environmentStop(...) { id } }              -> deleteCheck= FALSE  (BYPASS)

The tool handler forwards the query whenever the detector returns false (index.ts:10974-10978), posting { query: args.query, variables } unchanged (index.ts ~11046). No operationName is ever sent, and form B is a single mutation, so no multi-operation ambiguity arises server-side.

Fix

Align the delete-check's operation-prefix handling with the write-check by accepting an optional leading comma:

-  if (!normalized || !/(?:^|[};]\s*)mutation\b/.test(normalized)) {
+  if (!normalized || !/(?:^|[;},]\s*)mutation\b/.test(normalized)) {
...
-  const mutationRegex = /(?:^|[};]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;
+  const mutationRegex = /(?:^|[;},]\s*)mutation\b[^({]*(?:\([^)]*\))?\s*\{/g;

(patch attached as fix.patch). Robust long-term option: parse with a GraphQL AST parser and operate on definitions (regexes on GraphQL are fragile — this is the third regex-edge in this file's history).

Preconditions

  • Instance running with GITLAB_PERMISSION_MODE=modify; execute_graphql reachable (default toolsets exclude it, but it is the tool the mode-level guard exists for).
  • Attacker shapes execute_graphql arguments — the project's own threat model: "tool-call arguments/content can be shaped by untrusted input (prompt injection) or a malicious client" (GHSA-5648).
  • Operator's token has delete rights (the mode exists to stop the agent from using them).

Attack scenario

  1. Agent session runs with GITLAB_PERMISSION_MODE=modify; docs promise delete ops are rejected.
  2. Prompt-injected content (or a malicious client) invokes execute_graphql with , mutation { deleteIssue(...) }.
  3. graphqlQueryContainsDeleteOperation() -> false (mutation keyword invisible) -> query forwarded.
  4. graphql-ruby ignores the comma and executes the single mutation under the operator's token: the documented safety boundary is crossed.

Confidence

High for the guard-level bypass (cold, deterministic, shipped artifact). High for GitLab-side execution of the comma form (graphql-ruby lexer ignores commas entirely — source cited; no live instance probed).

Disclosure Route / Bounty

GitHub private advisory on zereight/gitlab-mcp (channel already operational for this repo: GHSA-f492). Bounty: Possible (GHSL third-party OSS).

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

CVE ID

No known CVE

Weaknesses

No CWEs

Credits