abhigyanpatwari/GitNexus · error · Error

Clone target must be a subdirectory of ${CLONE_ROOT}

Error message

Clone target must be a subdirectory of ${CLONE_ROOT}

What it means

cloneOrPull requires targetDir to resolve strictly inside CLONE_ROOT (getGlobalDir()/repos — ~/.gitnexus/repos by default, /data/gitnexus/repos when GITNEXUS_HOME is set in Docker). It computes path.resolve(targetDir) and path.relative(CLONE_ROOT, ...); an empty result, a '..'-prefixed result, or an absolute result means the target equals or escapes the root, and the call throws. This lexical containment barrier is the CodeQL js/path-injection sanitizer and cannot be bypassed by design — there is no supported way to clone outside CLONE_ROOT.

Source

Thrown at gitnexus/src/server/git-clone.ts:465

  targetDir: string,
  onProgress?: (progress: CloneProgress) => void,
  options?: { token?: string },
): Promise<string> {
  // Containment barrier — inline with the canonical path.relative idiom so
  // CodeQL recognizes the sanitizer at every following filesystem and
  // subprocess sink. The same `safeTarget` is used for every downstream
  // path operation — no reassignment that the analyzer could lose track of.
  //
  // Limitation: this is a lexical containment check, not a realpath check.
  // If an attacker can place a symlink under CLONE_ROOT pointing outside it,
  // the lexical check passes but the clone lands at the symlink target. That
  // requires pre-existing local write access to CLONE_ROOT, so the threat
  // model considers it out of scope; CodeQL js/path-injection accepts the
  // lexical form. Tracked as a follow-up if defense-in-depth is needed.
  const safeTarget = path.resolve(targetDir);
  const rel = path.relative(CLONE_ROOT, safeTarget);
  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {
    throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`);
  }

  // Always validate the requested URL — the prior shape only ran this in
  // the code path where the repo was cloned. Now it runs unconditionally,
  // preventing SSRF / blocked-host bypasses even when targetDir already exists.
  validateGitUrl(url);

  const exists = await fs.access(path.join(safeTarget, '.git')).then(
    () => true,
    () => false,
  );

  if (exists) {
    // Confirm the existing clone is actually the same repository the caller
    // requested. Without this check, a pull would silently succeed against
    // whatever remote the dir was originally cloned from.
    await assertRemoteMatchesRequestedUrl(safeTarget, url);
    onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });

View on GitHub (pinned to aac7515d2a)

Solutions

  1. Always derive the target with getCloneDir(extractRepoName(url)) — it builds a validated path inside CLONE_ROOT
  2. To relocate clones, set GITNEXUS_HOME=<dir> (Docker sets it to /data/gitnexus) and restart — CLONE_ROOT follows it
  3. For unit tests of pull/clone logic, pass paths under a CLONE_ROOT you control by setting GITNEXUS_HOME to a tmpdir before importing, or test the exported helpers (buildCloneArgs, assertRemoteMatchesRequestedUrl) instead
  4. Never pass user-supplied or req-body paths as targetDir; validate through REPO_NAME_PATTERN instead

Example fix

// before
await cloneOrPull(url, '/tmp/work/repo'); // throws: outside CLONE_ROOT
// after
import { extractRepoName, getCloneDir } from './git-clone.js';
await cloneOrPull(url, getCloneDir(extractRepoName(url))); // ~/<GITNEXUS_HOME>/repos/<name>
Defensive patterns

Strategy: validation

Validate before calling

import path from 'path';
import { getGlobalDir } from './storage/repo-manager.js';
const CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));
function isInsideCloneRoot(targetDir: string): boolean {
  const rel = path.relative(CLONE_ROOT, path.resolve(targetDir));
  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
}
if (!isInsideCloneRoot(targetDir)) throw new Error('bad target');

Type guard

import { REPO_NAME_PATTERN } from './git-clone.js';
function isSafeRepoName(name: unknown): name is string {
  return typeof name === 'string' && REPO_NAME_PATTERN.test(name) && name !== '.' && name !== '..';
}

Try / catch

try { await cloneOrPull(url, dir); }
catch (err) {
  if (err instanceof Error && err.message.startsWith('Clone target must be a subdirectory of')) {
    throw new Error(`programmer error: use getCloneDir(extractRepoName(url)) instead of ${dir}`);
  }
  throw err;
}

Prevention

When it happens

Trigger: cloneOrPull(url, '/tmp/myrepo') — an absolute path outside CLONE_ROOT; passing CLONE_ROOT itself (rel === ''); a relative target containing '..' that resolves above the root; calling with a dir derived from user input instead of getCloneDir. Note path.resolve() runs against the server process CWD, so relative targets resolve wherever the server was started.

Common situations: Code written against an older/imagined API that accepted arbitrary clone destinations; tests passing tmpdir fixtures straight into cloneOrPull (use assertRemoteMatchesRequestedUrl or export-tested helpers for that); Docker users expecting clones under /data while GITNEXUS_HOME is unset; symlinking inside the root is documented as out of threat model (lexical check only).

Related errors


AI-assisted analysis of abhigyanpatwari/GitNexus@aac7515d2a (2026-08-20). Data as JSON: /api/errors/5a12fc360e2981b8. Report an issue: GitHub.