
Cloud Vulnerability DB
A community-led vulnerabilities database
A tool argument that is placed into the path of a request to GitLab was not checked for path separators or dot segments. A crafted value could make the server build a request path that, once a front proxy or GitLab resolves the dot segments, names a different GitLab API endpoint than the action intends. The request still carries the user's own token and never exceeds the authority that token already has, but it can reach an endpoint the action was not meant to touch, and it bypasses the server's own per-action controls (the tool exclusion list, the tier filter, the chosen tool surface, and the destructive-action confirmation). The attacker in the threat model is a model filling tool arguments under prompt injection: the model, not the operator, chooses the crafted value.
The defect is present in the whole release history. The fix lands in the maintained 3.x line only; the 1.x and 2.x lines are unmaintained and receive no backport (see the security policy's supported versions).
| Ecosystem | Package | Vulnerable | Patched |
|---|---|---|---|
| Go | github.com/jmrplens/gitlab-mcp-server | <= 1.6.1 | none |
| Go | github.com/jmrplens/gitlab-mcp-server/v2 | <= 2.7.5 | none |
| Go | github.com/jmrplens/gitlab-mcp-server/v3 | <= 3.3.0 | 3.4.0 |
| npm | @jmrp.io/gitlab-mcp-server | <= 3.3.0 | 3.4.0 |
| PyPI | jmrplens-gitlab-mcp-server | <= 3.3.0 | 3.4.0 |
| NuGet | gitlab-mcp-server | <= 3.3.0 | 3.4.0 |
| The range was established by running the reproduction against the tree of every | |||
| release tag for the job artifact and system hook URL-variable actions: the job | |||
| artifact actions are affected from the first release (1.0.0), and the system | |||
| hook URL-variable actions from the release that added them (2.0.2). The other | |||
| actions listed below were not swept per tag, and their first affected release | |||
| comes from the source history: for the first class, the release whose handler | |||
| first called the same client-go route; for the second class, the first release | |||
| (1.0.0), whose model registry route and transport already left a value that is | |||
. or .. as a whole a dot segment. All are still present on the development | |||
| branch and refused only by the fix. |
The server itself unconditionally builds a request whose path does not correspond to the action the model invoked. Whether that reaches a different GitLab endpoint depends on what sits in front of GitLab:
Gitlab::Middleware::PathTraversalCheck
decodes the request path and, from GitLab 18.2, answers a path-traversal
attempt with 400 by default. The middleware acts only while its
check_path_traversal_middleware feature flag is on, and before 18.2 that
flag is off by default, so the middleware neither logs nor refuses the
attempt. Switched on, it refuses on 18.0 and 18.1; on 17.4 to 17.11 it
refuses only with its check_path_traversal_middleware_reject_requests flag
on as well, and otherwise only logs, as it does from 16.5, which added it, to
17.3. Where it does not refuse, neither Grape nor Rails strips dot segments,
so the request reaches the route it names literally, or none, and is answered
404. In both cases the attacker gains nothing.PUT or DELETE to
another endpoint the token may write.
The request never exceeds the token's own scope and grants, so GitLab continues
to enforce authorization; the impact scales with the authority of the
configured token and is largest for an administrator token behind a normalizing
front layer. The distinct harm is that the server bypasses the operator's and
the model's per-action controls and acts on an endpoint the approved action
never named.Two classes of route are affected. Each action below says how it was confirmed: through the real handlers and the real client transport chain against a mock GitLab that records the request (mock), and for some also live, through the real 3.3.0 binary against a licensed GitLab Enterprise Edition 19.5 instance, both directly and behind a proxy that resolves dot segments (live). The fix was run against the mock and, live, behind the proxy. Beyond its named action, the second class was confirmed on a sample. Routes that place the argument into the path without escaping it. Each of these actions passes a path argument to a client-go route that interpolates it into the request path as it is, so the value can carry separators and dot segments into the path:
job.download_single_artifact,
job.download_single_artifact_by_ref, job.download_artifacts,
admin.system_hook_set_url_variable and
admin.system_hook_delete_url_variable.group.hook_set_url_variable,
group.hook_set_custom_header, pipeline.schedule_delete_variable,
template.license_get and template.project_template_get.group.hook_delete_url_variable,
group.hook_delete_custom_header, group.hook_test,
pipeline.schedule_edit_variable and template.project_template_list. On
3.3.0 each sent the dot segment to the wire; the fix refuses each at the
handler, naming the parameter, with no request sent.
Live, behind the proxy, 3.3.0 sent each of those last five actions with an
argument that is .. as a whole, and GitLab received it at another path
under the user's token: the URL-variable and custom-header deletions deleted
the whole group webhook, the schedule variable edit reached the update route
of the schedule itself, which reads none of the fields it sent, the template
list read the project, and the hook test reached the hook's own path, where no
route answered; sent directly, GitLab refused each with 400, and the fix
sent none of them.
Routes that escape the argument, where a value that is exactly . or ..
still changes the route. Escaping keeps a separator inside the value, but a
value that is . or .. as a whole is still a dot segment once it is in the
path:model_registry.download (the model_version_id, filename and path
arguments): its route escapes each argument but leaves a dot as it is, as
the route requires. From 3.3.0 the handler normalizes path with
path.Clean before sending it, which resolves a . or .. between
slashes. path.Clean splits on / alone, so a dot segment in path
marked off by a backslash is kept as a name and sent with the backslash
escaped as %5C; a layer that decodes it and reads a backslash as a
separator still sees the dot segment, so path stays affected on 3.3.0 in
that form. Reproduced (mock): model_version_id and filename reached the
wire as a dot segment on 3.3.0; a path that is . or .. as a whole
reached it on 3.2.0, before path.Clean, and on 3.3.0 only the backslash
form still carries one (a percent-encoded one goes out escaped twice); the
fix refuses each at the handler. Not run live, since the proxy used does not
read a backslash as a separator.%2E, and this server's own transport turned it back
into . before sending (see Root cause), so a value that is . or .. as
a whole reached the wire as a dot segment. Reproduced (mock) on a sample of
five actions across three domains, reads and writes (branch.get,
tag.get, release.get, branch.delete, tag.delete), and live for
branch.get and tag.get: on 3.3.0 each sent the dot segment to the wire,
and the fix's transport check refused each before any request. The sample
stands for the class, which is every escaped path argument; the transport
check covers all of them.
Live, behind the same proxy, branch.get and tag.get with a name that is. as a whole were served as the project's whole branch or tag list, which
the 3.3.0 handler then failed to read as a single entry; sent directly, GitLab
answered 400 and 404, and the fix sent neither.
A value that is . or .. as a whole is one segment, so it can only remove
its own place in the route and, for .., the segment before it; it adds no
segment of its own. The backslash form of the model registry path can carry
several dot segments and a name after them.
Reads, available to a normal user with read access:
job.download_single_artifact and job.download_single_artifact_by_ref
(the artifact_path argument) and job.download_artifacts (the ref_name
argument).template.license_get, template.project_template_get and
template.project_template_list (the template name and type arguments).model_registry.download (the arguments named above).admin.system_hook_set_url_variable and
admin.system_hook_delete_url_variable (the key argument): require an
administrator token.group.hook_set_url_variable, group.hook_delete_url_variable,
group.hook_set_custom_header, group.hook_delete_custom_header and
group.hook_test (the key and trigger arguments): require the Owner role
on the group, and are served only on a Premium or Ultimate instance.pipeline.schedule_edit_variable and pipeline.schedule_delete_variable
(the key argument): require the schedule's owner or a Maintainer.%2E to ., everywhere else.A few client-go routes build the request path by interpolating a path argument
without escaping it (client-go's NoEscape path variable), so a value carrying
a separator or a dot segment survives into the outgoing path. The server did
not neutralize such a value before the request was built, and the server's own
transport also rewrites %2E to . (a workaround for some proxies), so an
encoded dot segment reaches the wire as a literal one. The same rewrite undoes
client-go's escaping of a dot in every escaped argument, so an argument that is
. or .. as a whole reaches the wire as a dot segment as well, and the model
registry route escapes its arguments in a way that never encodes the dot. The
client-go behavior is reported to GitLab privately.
The patch adds one central check, a check in each affected handler, and escaping of the affected arguments:
.
or .. segment, in the path as it stands or once its percent-encoding is
undone. That includes an escaped argument that is . or .. as a whole, so
the check covers the second class on every action. The check applies to
every action, including any added later, and refuses rather than rewriting
the request. The refusal is reported to the model as a refused path argument
that never left the server, not as a network or TLS failure.. or ..
segment with an error that names the parameter. A dot inside a segment, as
in v1.2, is still accepted. In the model registry path, a . or ..
between slashes is still resolved within the package, and a dot segment the
normalization keeps as a name, marked off by a backslash or written
percent-encoded, is refused the same way./, for an artifact path and the
archive ref. A separator in a single name therefore stays inside that name.
A value with a space, a non-ASCII byte or a quote no longer strips the
encoding from the project or group path it is sent with.Until 3.4.0, each of these stops the part it names. Only the first covers both
classes on every action; the third covers every action named above. Each
variable is given as its release line reads it: GITLAB_MCP_* on 3.x, and the
names given below on 1.x and 2.x.
Gitlab::Middleware::PathTraversalCheck refuse such a
path with 400, with no layer in front of GitLab that resolves dot segments
before forwarding. It does so by default from GitLab 18.2, as long as its
check_path_traversal_middleware feature flag stays on; on 18.0 and 18.1,
turn that flag on; on 17.4 to 17.11, turn on both it and
check_path_traversal_middleware_reject_requests. From 16.5, which added
the check, to 17.3 it can only log. A layer that resolves dot segments first
hides them from that check, so this holds only without one.GITLAB_MCP_READ_ONLY=true on 3.x, or
GITLAB_READ_ONLY=true on 1.x and 2.x, removes every write action, among
them the nine above (admin.system_hook_set_url_variable,
admin.system_hook_delete_url_variable, the five group.hook_* actions and
the two pipeline.schedule_*_variable actions), and with them the writes of
the second class. It does not cover the seven read actions (the three
job.* actions, the three template.* actions and
model_registry.download) or the reads of the second class.GITLAB_MCP_EXCLUDE_TOOLS takes
canonical action IDs, comma-separated, and this value removes all sixteen
actions named above on any surface:GITLAB_MCP_EXCLUDE_TOOLS=job.download_single_artifact,job.download_single_artifact_by_ref,job.download_artifacts,template.license_get,template.project_template_get,template.project_template_list,model_registry.download,admin.system_hook_set_url_variable,admin.system_hook_delete_url_variable,group.hook_set_url_variable,group.hook_delete_url_variable,group.hook_set_custom_header,group.hook_delete_custom_header,group.hook_test,pipeline.schedule_edit_variable,pipeline.schedule_delete_variableAppend them to any value already set, since the variable is read as one list.
On 2.x, and on 1.x from 1.0.6, the variable is EXCLUDE_TOOLS, and it takes
the names of the tools the server registers rather than action IDs: on the
individual surface it removes these actions by that release's own tool
names, and on the meta surface, or the dynamic surface where the release has
one, only together with the domain tool that carries them, such as
gitlab_job or gitlab_group. 1.0.0 to 1.0.5 read no such variable.admin.system_hook_* actions whatever their path. This covers that family
only: the job artifact, template, model registry, group hook and pipeline
schedule actions, and the second class, stay reachable at the roles listed
above.CVSS v3.1 base vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:L/I:L/A:N, base score 4.7 (Medium).
Reasoning:
400 from GitLab 18.2, where the
path-traversal check is on by default, 404 before it).AC:L), the vector isCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N, base score 6.1 (Medium).
Both land in Medium.
Found in the project's own October 2026 security review.
Source: NVD
Free Vulnerability Assessment
Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.
Get a personalized demo
"Best User Experience I have ever seen, provides full visibility to cloud workloads."
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
"We know that if Wiz identifies something as critical, it actually is."