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
- Agent session runs with
GITLAB_PERMISSION_MODE=modify; docs promise delete ops are rejected.
- Prompt-injected content (or a malicious client) invokes
execute_graphql with , mutation { deleteIssue(...) }.
graphqlQueryContainsDeleteOperation() -> false (mutation keyword invisible) -> query forwarded.
- 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).
GITLAB_PERMISSION_MODE=modify delete-ban bypassed by comma-form GraphQL operations (detection-evasion, distinct from GHSA-f492)
v2.1.63(commitd265a91, 2026-09-17, latest)execute_graphqltool (permission-mode enforcement)Summary
GITLAB_PERMISSION_MODE=modifyis 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_graphqlthis is enforced bygraphqlQueryContainsDeleteOperation()(utils/graphql-query.ts:155). That detector recognises a mutation only when the keyword is preceded by start-of-string,}or;: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:
So a document such as
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 themutationkeyword 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)
environmentStop,clusterAgentTokenRevoke,pipelineCancel) passingDELETE_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.39a50a4("fix: detect leading comma mutations..."). The modify-mode delete-check never received the same comma handling — the gap documented here.Affected versions
v2.1.63, commitd265a91)utils/graphql-query.ts(timings.txt)graphqlQueryContainsDeleteOperationwith the comma-less mutation prefixProof (cold, deterministic, no network)
poc.jsimports the shipped file at v2.1.63 (Node >= 22.6 native TS type-stripping) and asserts:The tool handler forwards the query whenever the detector returns false (
index.ts:10974-10978), posting{ query: args.query, variables }unchanged (index.ts~11046). NooperationNameis 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:
(patch attached as
fix.patch). Robust long-term option: parse with a GraphQL AST parser and operate ondefinitions(regexes on GraphQL are fragile — this is the third regex-edge in this file's history).Preconditions
GITLAB_PERMISSION_MODE=modify;execute_graphqlreachable (default toolsets exclude it, but it is the tool the mode-level guard exists for).execute_graphqlarguments — the project's own threat model: "tool-call arguments/content can be shaped by untrusted input (prompt injection) or a malicious client" (GHSA-5648).Attack scenario
GITLAB_PERMISSION_MODE=modify; docs promise delete ops are rejected.execute_graphqlwith, mutation { deleteIssue(...) }.graphqlQueryContainsDeleteOperation()->false(mutation keyword invisible) -> query forwarded.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).