{"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":664,"sourceCode":"    }\n  } else {\n    if (targetExists && (await fs.readdir(safeTarget)).length > 0) {\n      throw new Error(`Clone target already exists but is not a git repository: ${safeTarget}`);\n    }\n    onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` });\n    try {\n      const runGitImpl = options?.runGitForTest ?? runGit;\n      const cloneArgs = options?.branch\n        ? buildBranchCloneArgs(url, safeTarget, options.branch)\n        : buildCloneArgs(url, safeTarget);\n      await runGitImpl(cloneArgs, undefined, {\n        token: options?.token,\n        url,\n        timeoutMs: options?.timeoutMs,\n      });\n      await assertPostRealpathContainment(cloneRoot, safeTarget);\n    } catch (err: unknown) {\n      if (options?.quarantineRoot) {\n        const partialExists = await fs.access(safeTarget).then(\n          () => true,\n          () => false,\n        );\n        if (partialExists) {\n          try {\n            await quarantineAutoSyncPartial(safeTarget, options.quarantineRoot);\n          } catch (quarantineError) {\n            throw new AggregateError(\n              [err, quarantineError],\n              `Clone failed and partial checkout could not be quarantined: ${safeTarget}`,\n            );\n          }\n        }\n      }\n      throw err;\n    }\n  }","sourceCodeStart":646,"sourceCodeEnd":682,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/ac9a4e9abd8fd3058c070b72c23402a4f887929a/gitnexus/src/server/git-clone.ts#L646-L682","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":"ac9a4e9abd8fd3058c070b72c23402a4f887929a","analyzedAt":"2026-08-20T23:29:22.980Z","contentChangedAt":"2026-08-20T23:29:22.980Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}