abhigyanpatwari/GitNexus · error · Error
Clone target must be a subdirectory of
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.
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
- Invalid repository name
- Invalid upload path
- Path traversal denied
- Refusing to delete : resolved path is outside storage…
- Upload path escapes the sandbox
AI-assisted analysis of abhigyanpatwari/GitNexus@ac9a4e9abd (2026-08-20).
Data as JSON: /api/errors/5a12fc360e2981b8.
Report an issue: GitHub.
Appendix: source
Thrown at gitnexus/src/server/git-clone.ts:664
}
} else {
if (targetExists && (await fs.readdir(safeTarget)).length > 0) {
throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`);
}
onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });
try {
const runGitImpl = options?.runGitForTest ?? runGit;
const cloneArgs = options?.branch
? buildBranchCloneArgs(url, safeTarget, options.branch)
: buildCloneArgs(url, safeTarget);
await runGitImpl(cloneArgs, undefined, {
token: options?.token,
url,
timeoutMs: options?.timeoutMs,
});
await assertPostRealpathContainment(cloneRoot, safeTarget);
} catch (err: unknown) {
if (options?.quarantineRoot) {
const partialExists = await fs.access(safeTarget).then(
() => true,
() => false,
);
if (partialExists) {
try {
await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);
} catch (quarantineError) {
throw new AggregateError(
[err, quarantineError],
`Clone failed and partial checkout could not be quarantined: ${safeTarget}`,
);
}
}
}
throw err;
}
}View on GitHub (pinned to ac9a4e9abd)