Root cause
Path & link handling
Add job artifacts tools for downloading and inspecting pipeline artifacts
How AI contributed: New attack surfacePublished Jul 13, 2026 in
zereight/gitlab-mcp
mcp-gitlab contains a path traversal vulnerability in the job_id parameter of build/index.js that allows attackers to redirect GitLab API requests to arbitrary endpoints. Attackers can supply crafted job_id values like ../../../user to escape the intended path prefix and access arbitrary GitLab API resources using the operator's personal access token.
AI-written code concatenated an unsanitized job_id into GitLab API paths, enabling path traversal that lets attackers access arbitrary endpoints with the user's token.
Root cause
Add job artifacts tools for downloading and inspecting pipeline artifacts
Fix
fix: encode job and pipeline IDs in GitLab API paths (#592)
Case facts
@@ -203,6 +203,11 @@ import { ListWikiPagesSchema, MarkdownUploadSchema, DownloadAttachmentSchema,+ DownloadJobArtifactsSchema,+ GetJobArtifactFileSchema,+ type GitLabArtifactEntry,+ GitLabArtifactEntrySchema,+ ListJobArtifactsSchema, MergeMergeRequestSchema, ApproveMergeRequestSchema, UnapproveMergeRequestSchema,@@ -1268,6 +1273,23 @@ const allTools = [ description: "Cancel a running pipeline job", inputSchema: toJSONSchema(CancelPipelineJobSchema), },+ {+ name: "list_job_artifacts",+ description: "List artifact files in a job's artifacts archive. Returns file names, paths, types, and sizes.",+ inputSchema: toJSONSchema(ListJobArtifactsSchema),+ },+ {+ name: "download_job_artifacts",+ description:+ "Download the entire artifact archive (zip) for a job to a local path. Returns the saved file path.",+ inputSchema: toJSONSchema(DownloadJobArtifactsSchema),+ },+ {+ name: "get_job_artifact_file",+ description:+ "Get the content of a single file from a job's artifacts by its path within the archive",+ inputSchema: toJSONSchema(GetJobArtifactFileSchema),+ }, { name: "list_merge_requests", description:@@ -1434,6 +1456,9 @@ const readOnlyTools = new Set([ "list_pipeline_trigger_jobs", "get_pipeline_job", "get_pipeline_job_output",+ "list_job_artifacts",+ "download_job_artifacts",+ "get_job_artifact_file", "list_labels", "get_label", "list_group_projects",@@ -1497,6 +1522,9 @@ const pipelineToolNames = new Set([ "play_pipeline_job", "retry_pipeline_job", "cancel_pipeline_job",+ "list_job_artifacts",+ "download_job_artifacts",+ "get_job_artifact_file", ]); // --- Toolset definitions ---@@ -4688,6 +4716,119 @@ async function getPipelineJobOutput( return fullTrace; } +/**+ * List artifact files in a job's artifacts archive+ *+ * @param {string} projectId - The ID or URL-encoded path of the project+ * @param {string} jobId - The ID of the job+ * @param {Object} options - Options for listing artifacts+ * @returns {Promise<GitLabArtifactEntry[]>} List of artifact entries+ */+async function listJobArtifacts(+ projectId: string,+ jobId: string,+ options: Omit<z.infer<typeof ListJobArtifactsSchema>, "project_id" | "job_id"> = {}+): Promise<GitLabArtifactEntry[]> {+ projectId = decodeURIComponent(projectId);+ const url = new URL(+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`+ );++ Object.entries(options).forEach(([key, value]) => {+ if (value !== undefined) {+ if (typeof value === "boolean") {+ url.searchParams.append(key, value ? "true" : "false");+ } else {+ url.searchParams.append(key, value.toString());+ }+ }+ });++ const response = await fetch(url.toString(), {+ ...getFetchConfig(),@@ -2623,6 +2623,50 @@ export const CreateReleaseEvidenceSchema = z.object({ tag_name: z.string().describe("The Git tag the release is associated with"), }); +// Job Artifacts schemas+export const ListJobArtifactsSchema = z.object({+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),+ job_id: z.coerce.string().describe("The ID of the job"),+ path: z+ .string()+ .optional()+ .describe("Directory path within the artifacts archive (defaults to root)"),+ recursive: z+ .boolean()+ .optional()+ .describe("Whether to list artifacts recursively"),+});++export const GitLabArtifactEntrySchema = z.object({+ name: z.string(),+ path: z.string(),+ type: z.enum(["file", "directory"]),+ size: z.number().optional(),+ mode: z.string().optional(),+});++export const DownloadJobArtifactsSchema = z.object({+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),+ job_id: z.coerce.string().describe("The ID of the job"),+ local_path: z+ .string()+ .optional()+ .describe("Local directory to save the artifact archive (defaults to current directory)"),+});++export const GetJobArtifactFileSchema = z.object({+ project_id: z.coerce.string().describe("Project ID or URL-encoded path"),+ job_id: z.coerce.string().describe("The ID of the job"),+ artifact_path: z+ .string()+ .describe("Path to the file within the artifacts archive"),+});++export type GitLabArtifactEntry = z.infer<typeof GitLabArtifactEntrySchema>;+export type ListJobArtifactsOptions = z.infer<typeof ListJobArtifactsSchema>;+export type DownloadJobArtifactsOptions = z.infer<typeof DownloadJobArtifactsSchema>;+export type GetJobArtifactFileOptions = z.infer<typeof GetJobArtifactFileSchema>;+ export const DownloadReleaseAssetSchema = z.object({ project_id: z.coerce.string().describe("Project ID or URL-encoded path"), tag_name: z.string().describe("The Git tag the release is associated with"),@@ -0,0 +1,204 @@+import { describe, test, before, after } from 'node:test';+import assert from 'node:assert';+import { spawn } from 'child_process';+import { MockGitLabServer, findMockServerPort } from './utils/mock-gitlab-server.js';+import fs from 'node:fs';+import path from 'node:path';+import os from 'node:os';++const MOCK_TOKEN = 'glpat-mock-token-12345';+const TEST_PROJECT_ID = '123';+const TEST_JOB_ID = '456';++// Helper to run an MCP tool via the built server+async function callTool(+ toolName: string,+ args: Record<string, any>,+ env: NodeJS.ProcessEnv+): Promise<any> {+ return new Promise<any>((resolve, reject) => {+ const proc = spawn('node', ['build/index.js'], {+ stdio: ['pipe', 'pipe', 'pipe'],+ env: {+ ...process.env,+ ...env,+ GITLAB_READ_ONLY_MODE: 'true',+ USE_PIPELINE: 'true',+ },+ });++ let output = '';+ let errorOutput = '';+ proc.stdout?.on('data', (d: Buffer) => (output += d));+ proc.stderr?.on('data', (d: Buffer) => (errorOutput += d));++ proc.on('close', (code) => {+ if (code !== 0) return reject(new Error(`Process exited with code ${code}: ${errorOutput}`));++ const line = output.split('\n').find((l) => l.startsWith('{'));+ if (!line) return reject(new Error('No JSON output found'));++ try {+ const response = JSON.parse(line);+ if (response.error) {+ reject(response.error);+ } else {+ const content = response.result?.content?.[0]?.text;+ if (content) {+ try {+ resolve(JSON.parse(content));+ } catch {+ // Not JSON (plain text response)+ resolve(content);+ }+ } else {+ resolve(response.result);+ }+ }+ } catch (e) {+ reject(e);+ }+ });++ proc.stdin?.end(+ JSON.stringify({+ jsonrpc: '2.0',+ id: 1,+ method: 'tools/call',+ params: { name: toolName, arguments: args },+ }) + '\n'+ );+ });+}++describe('job artifacts tools', () => {+ let mockGitLab: MockGitLabServer;+ let mockGitLabUrl: string;+ let tmpDir: string;++ before(async () => {+ const mockPort = await findMockServerPort(9200);+ mockGitLab = new MockGitLabServer({+ port: mockPort,+ validTokens: [MOCK_TOKEN],+ });++ // Add mock handler for artifact tree listing+ mockGitLab.addMockHandler('get', `/projects/${TEST_PROJECT_ID}/jobs/${TEST_JOB_ID}/artifacts/tree`, (req, res) => {+ res.json([+ {@@ -7036,7 +7036,7 @@ async function getPipeline( ): Promise<GitLabPipeline> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}` ); const response = await fetch(url.toString(), {@@ -7187,7 +7187,7 @@ async function listPipelineJobs( ): Promise<GitLabPipelineJob[]> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/jobs`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/jobs` ); // Add all query parameters@@ -7229,7 +7229,7 @@ async function listPipelineTriggerJobs( ): Promise<GitLabPipelineTriggerJob[]> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/bridges`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/bridges` ); // Add all query parameters@@ -7262,7 +7262,7 @@ async function getPipelineJob( ): Promise<GitLabPipelineJob> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}` ); const response = await fetch(url.toString(), {@@ -7297,7 +7297,7 @@ async function getPipelineJobOutput( ): Promise<string> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/trace`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/trace` ); const response = await fetch(url.toString(), {@@ -7405,7 +7405,7 @@ async function listJobArtifacts( ): Promise<GitLabArtifactEntry[]> { projectId = decodeURIComponent(projectId); const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${jobId}/artifacts/tree`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/tree` ); Object.entries(options).forEach(([key, value]) => {@@ -7450,7 +7450,7 @@ async function downloadJobArtifacts( const effectiveProjectId = getEffectiveProjectId(projectId); const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts` ); const response = await fetch(url.toString(), {@@ -7498,7 +7498,7 @@ async function getJobArtifactFile( .join("/"); const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${jobId}/artifacts/${encodedArtifactPath}`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(effectiveProjectId)}/jobs/${encodeGitLabPathSegment(jobId)}/artifacts/${encodedArtifactPath}` ); const response = await fetch(url.toString(), {@@ -7566,7 +7566,7 @@ async function retryPipeline( ): Promise<GitLabPipeline> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/retry`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/retry` ); const response = await fetch(url.toString(), {@@ -7592,7 +7592,7 @@ async function cancelPipeline( ): Promise<GitLabPipeline> { projectId = decodeURIComponent(projectId); // Decode project ID const url = new URL(- `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${pipelineId}/cancel`+ `${getEffectiveApiUrl()}/projects/${encodeURIComponent(getEffectiveProjectId(projectId))}/pipelines/${encodeGitLabPathSegment(pipelineId)}/cancel` ); const response = await fetch(url.toString(), {@@ -13,6 +13,8 @@ const rawPathSegmentPatterns = [ "/draft_notes/${draftNoteId}", "/versions/${versionId}", "/groups/${groupId}",+ "/jobs/${jobId}",+ "/pipelines/${pipelineId}", ]; test("GitLab URL path IDs are encoded before interpolation", () => {@@ -33,6 +35,21 @@ test("encoding keeps path traversal payloads inside one URL segment", () => { ); }); +test("job_id path traversal stays under /jobs/", () => {+ // GHSA-7c3w-fxgh-frc7 / #587: unencoded job_id with ../ escaped to /api/v4/user+ const payload = "../../../user";+ const url = new URL(+ `https://gitlab.com/api/v4/projects/1/jobs/${encodeURIComponent(payload)}/trace`+ );++ assert.equal(+ url.pathname,+ "/api/v4/projects/1/jobs/..%2F..%2F..%2Fuser/trace"+ );+ assert.match(url.pathname, /\/jobs\/[^/]+\/trace$/);+ assert.equal(url.pathname.includes("/api/v4/user"), false);+});+ test("malformed percent-encoded segments can still be encoded", () => { assert.equal(encodeURIComponent("bad%zz/path"), "bad%25zz%2Fpath"); });AI-assisted change 74cad38e5e807428512b05ba559f30db5ab28e8212ca72d1b4a3a00d63d2938f · Fix c23b247e7293024071117b008e319a3175a0654f9ed1bc0f42a0b983c24bb29e