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
- Always derive the target with getCloneDir(extractRepoName(url)) — it builds a validated path inside CLONE_ROOT
- To relocate clones, set GITNEXUS_HOME=<dir> (Docker sets it to /data/gitnexus) and restart — CLONE_ROOT follows it
- 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
- 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
- Never accept targetDir from user input — derive it via getCloneDir(extractRepoName(url))
- Relocate clones by setting GITNEXUS_HOME, not by passing custom directories
- For tests, set GITNEXUS_HOME to a tmpdir before importing git-clone so CLONE_ROOT lands in your fixture
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
- Refusing to delete ${dbPath}: resolved path ${realPath} is o
- Invalid repository name
- Invalid upload path
- Upload path must not contain traversal segments
- Upload path escapes the sandbox
AI-assisted analysis of abhigyanpatwari/GitNexus@aac7515d2a (2026-08-20).
Data as JSON: /api/errors/5a12fc360e2981b8.
Report an issue: GitHub.