{"record":{"id":"b3b9c314262514cd","repo":"abhigyanpatwari/GitNexus","slug":"refusing-symlink-in-clone-target-path-current","errorCode":null,"errorMessage":"Refusing symlink in clone target path: ${current}","messagePattern":"Refusing symlink in clone target path: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"gitnexus/src/server/git-clone.ts","lineNumber":526,"sourceCode":"  target: string,\n  verifyOwnership = false,\n): Promise<void> {\n  const resolvedRoot = path.resolve(root);\n  const resolvedTarget = path.resolve(target);\n  const relativeTarget = path.relative(resolvedRoot, resolvedTarget);\n  if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) return;\n  let current = resolvedRoot;\n  for (const segment of relativeTarget.split(path.sep).filter(Boolean)) {\n    current = path.join(current, segment);\n    let stat;\n    try {\n      stat = await fs.lstat(current);\n    } catch (err: unknown) {\n      if ((err as NodeJS.ErrnoException).code === 'ENOENT') break;\n      throw err;\n    }\n    if (stat.isSymbolicLink()) {\n      throw new Error(`Refusing symlink in clone target path: ${current}`);\n    }\n    if (verifyOwnership) await assertDirectoryOwnerAndPermissions(current);\n  }\n}\n\n/**\n * Hosts the per-request GitHub PAT may be sent to. Exported so the\n * /api/analyze boundary check and this injection-site check share one\n * allowlist (they must agree, or a token accepted by the API could be\n * silently dropped — or worse — at injection).\n */\nexport const GITHUB_TOKEN_HOSTS: ReadonlySet<string> = new Set(['github.com', 'www.github.com']);\n\n/**\n * Resolve at most ONE git credential for a clone/pull, by server-side policy\n * keyed on the clone host against a fixed allowlist (never a free-form user\n * toggle):\n *   1. a per-request GitHub PAT — only for hosts in GITHUB_TOKEN_HOSTS;","sourceCodeStart":508,"sourceCodeEnd":544,"githubUrl":"https://github.com/abhigyanpatwari/GitNexus/blob/0d1aed942f0e8b5d3bac27519fff441aceea722d/gitnexus/src/server/git-clone.ts#L508-L544","documentation":"assertNoSymlinkPath walks every path component from the clone root down to the target, lstat-ing each; if any component is a symbolic link the operation is refused. Unlike the realpath containment checks (which allow symlinks that still land inside the root), this is a stricter zero-symlink policy on the clone target path, optionally verifying ownership/permissions of each component.","triggerScenarios":"Any directory in the path between the clone root and targetDir is a symlink (e.g. clones -> /mnt/bigdisk/clones); an attacker pre-plants a symlink component so a future clone writes through it; a user reorganized directories with links after an earlier clone.","commonSituations":"Users symlink clone roots to bigger disks; per-project symlinked scratch dirs; shared machines where symlinks are an attack vector for repo-swap attacks; container images that link /var/git to another volume.","solutions":["Replace the symlink component with a real directory (or bind-mount the volume instead of symlinking).","Point targetDir at a path with no symlinked components under the clone root.","Move the clone root itself (allowedCloneRoot) to the real filesystem location instead of linking to it.","Check each component with fs.lstat before calling to find which one is the link."],"exampleFix":"// before\nln -s /mnt/bigdisk/clones /srv/clones  // symlink triggers the error\n// after\nmount --bind /mnt/bigdisk/clones /srv/clones  // real dir, no symlink in path","handlingStrategy":"validation","validationCode":"import { fs } from 'node:fs/promises';\nasync function hasSymlinkComponent(root: string, target: string): Promise<boolean> {\n  let cur = path.resolve(target);\n  const stop = path.resolve(root);\n  while (cur !== stop && cur.startsWith(stop)) {\n    const st = await fs.lstat(cur);\n    if (st.isSymbolicLink()) return true;\n    cur = path.dirname(cur);\n  }\n  return false;\n}","typeGuard":null,"tryCatchPattern":"try {\n  await cloneOrPull(opts);\n} catch (err) {\n  if ((err as Error).message.startsWith('Refusing symlink in clone target path')) {\n    const m = (err as Error).message.match(/path: (.+)$/);\n    throw new Error(`Replace symlink ${m?.[1]} with a real directory or bind-mount.`, { cause: err });\n  }\n  throw err;\n}","preventionTips":["Use bind mounts instead of symlinks for relocating clone storage.","Audit the clone root tree for symlinks (find root -type l) on a schedule.","Keep per-repo clones directly under the real root with no intermediate links.","On shared machines, treat any symlink in the clone path as a potential repo-swap attack."],"tags":["git","security","symlink"],"backgroundTag":"path-traversal-blocked","analyzedSha":"0d1aed942f0e8b5d3bac27519fff441aceea722d","analyzedAt":"2026-09-08T00:40:44.970Z","contentChangedAt":"2026-09-08T00:40:44.970Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}