{"record":{"id":"5a12fc360e2981b8","repo":"abhigyanpatwari/GitNexus","slug":"clone-target-must-be-a-subdirectory-of-clone-roo","errorCode":null,"errorMessage":"Clone target must be a subdirectory of ${CLONE_ROOT}","messagePattern":"Clone target must be a subdirectory of (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"gitnexus/src/server/git-clone.ts","lineNumber":465,"sourceCode":"  targetDir: string,\n  onProgress?: (progress: CloneProgress) => void,\n  options?: { token?: string },\n): Promise<string> {\n  // Containment barrier — inline with the canonical path.relative idiom so\n  // CodeQL recognizes the sanitizer at every following filesystem and\n  // subprocess sink. The same `safeTarget` is used for every downstream\n  // path operation — no reassignment that the analyzer could lose track of.\n  //\n  // Limitation: this is a lexical containment check, not a realpath check.\n  // If an attacker can place a symlink under CLONE_ROOT pointing outside it,\n  // the lexical check passes but the clone lands at the symlink target. That\n  // requires pre-existing local write access to CLONE_ROOT, so the threat\n  // model considers it out of scope; CodeQL js/path-injection accepts the\n  // lexical form. Tracked as a follow-up if defense-in-depth is needed.\n  const safeTarget = path.resolve(targetDir);\n  const rel = path.relative(CLONE_ROOT, safeTarget);\n  if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) {\n    throw new Error(`Clone target must be a subdirectory of ${CLONE_ROOT}`);\n  }\n\n  // Always validate the requested URL — the prior shape only ran this in\n  // the code path where the repo was cloned. Now it runs unconditionally,\n  // preventing SSRF / blocked-host bypasses even when targetDir already exists.\n  validateGitUrl(url);\n\n  const exists = await fs.access(path.join(safeTarget, '.git')).then(\n    () => true,\n    () => false,\n  );\n\n  if (exists) {\n    // Confirm the existing clone is actually the same repository the caller\n    // requested. Without this check, a pull would silently succeed against\n    // whatever remote the dir was originally cloned from.\n    await assertRemoteMatchesRequestedUrl(safeTarget, url);\n    onProgress?.({ phase: 'pulling', message: 'Pulling latest changes...' });","sourceCodeStart":447,"sourceCodeEnd":483,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/aac7515d2a8c50a1f8f923c6fb77218b333560d6/gitnexus/src/server/git-clone.ts#L447-L483","documentation":"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.","triggerScenarios":"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.","commonSituations":"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).","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"],"exampleFix":"// before\nawait cloneOrPull(url, '/tmp/work/repo'); // throws: outside CLONE_ROOT\n// after\nimport { extractRepoName, getCloneDir } from './git-clone.js';\nawait cloneOrPull(url, getCloneDir(extractRepoName(url))); // ~/<GITNEXUS_HOME>/repos/<name>","handlingStrategy":"validation","validationCode":"import path from 'path';\nimport { getGlobalDir } from './storage/repo-manager.js';\nconst CLONE_ROOT = path.resolve(path.join(getGlobalDir(), 'repos'));\nfunction isInsideCloneRoot(targetDir: string): boolean {\n  const rel = path.relative(CLONE_ROOT, path.resolve(targetDir));\n  return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);\n}\nif (!isInsideCloneRoot(targetDir)) throw new Error('bad target');","typeGuard":"import { REPO_NAME_PATTERN } from './git-clone.js';\nfunction isSafeRepoName(name: unknown): name is string {\n  return typeof name === 'string' && REPO_NAME_PATTERN.test(name) && name !== '.' && name !== '..';\n}","tryCatchPattern":"try { await cloneOrPull(url, dir); }\ncatch (err) {\n  if (err instanceof Error && err.message.startsWith('Clone target must be a subdirectory of')) {\n    throw new Error(`programmer error: use getCloneDir(extractRepoName(url)) instead of ${dir}`);\n  }\n  throw err;\n}","preventionTips":["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"],"tags":["git-clone","path-containment","security-guard","path-traversal","filesystem"],"backgroundTag":"path-containment-violation","analyzedSha":"aac7515d2a8c50a1f8f923c6fb77218b333560d6","analyzedAt":"2026-08-20T23:29:22.980Z","schemaVersion":2},"datasetVersion":"2026-08-22T20:17:22.307Z"}