/** * Git Clone Utility * * Shallow-clones repositories into ~/.gitnexus/repos/{name}/. * If already cloned, does git pull instead. */ import { spawn } from 'child_process'; import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { isIP } from 'net'; import { logger } from '../core/logger.js'; import { parseRepoNameFromUrl } from '../storage/git.js'; /** Root directory for all cloned repositories. Targets must resolve inside this. */ const CLONE_ROOT = path.resolve(path.join(os.homedir(), '.gitnexus', 'repos')); // A valid git repository name is filesystem-safe: alphanumerics plus `. _ -`. // Rejecting anything else (including `..`, `/`, `\`, shell metacharacters) // guarantees getCloneDir(repoName) cannot escape CLONE_ROOT regardless of // how the caller derived repoName. export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/; /** * Extract the repository name from a git URL (HTTPS or SSH). * * Throws if the URL does not yield a filesystem-safe last segment. A name * like `..` or `foo/bar` would otherwise let `getCloneDir(name)` escape the * clone root via path traversal. */ export function extractRepoName(url: string): string { const name = parseRepoNameFromUrl(url); if ( !name || name === '.' || name === '..' || name === 'unknown' || !REPO_NAME_PATTERN.test(name) ) { throw new Error('Could not extract a valid repository name from URL'); } return name; } /** Get the clone target directory for a repo name. */ export function getCloneDir(repoName: string): string { // Re-validate at the boundary even though extractRepoName already checked — // callers may pass a repoName from another source (test fixtures, scripts). if (!repoName || repoName === '.' || repoName === '..' || !REPO_NAME_PATTERN.test(repoName)) { throw new Error('Invalid repository name'); } return path.join(CLONE_ROOT, repoName); } // Cloud metadata hostnames that must never be reachable via user-supplied URLs const BLOCKED_HOSTNAMES = new Set([ 'localhost', 'metadata.google.internal', 'metadata.azure.com', 'metadata.internal', ]); /** * Validate a git URL to prevent SSRF attacks. * Only allows https:// and http:// schemes. Blocks private/internal addresses, * IPv6 private ranges, cloud metadata hostnames, and numeric IP encodings. */ export function validateGitUrl(url: string): void { let parsed: URL; try { parsed = new URL(url); } catch { throw new Error('Invalid URL'); } if (!['https:', 'http:'].includes(parsed.protocol)) { throw new Error('Only https:// and http:// git URLs are allowed'); } const host = parsed.hostname.toLowerCase(); // Block known dangerous hostnames (cloud metadata services) if (BLOCKED_HOSTNAMES.has(host)) { throw new Error('Cloning from private/internal addresses is not allowed'); } // Strip IPv6 brackets if present (URL parser behavior varies across Node versions) let normalizedHost = host; if (host.startsWith('[') && host.endsWith(']')) { normalizedHost = host.slice(1, -1); } // Check if this is an IPv6 address // Use manual colon detection as fallback since isIP may return 0 for some // normalized IPv6 forms (e.g. ::ffff:7f00:1) const isIPv6 = isIP(normalizedHost) === 6 || normalizedHost.includes(':'); if (isIPv6) { assertNotPrivateIPv6(normalizedHost); return; } // Check if this is an IPv4 address (including numeric encodings) if (isIP(normalizedHost) === 4) { assertNotPrivateIPv4(normalizedHost); return; } // For non-IP hostnames, check for numeric IP tricks // Decimal encoding: 2130706433 = 127.0.0.1 // Hex encoding: 0x7f000001 = 127.0.0.1 if (/^\d+$/.test(host) || /^0x[0-9a-f]+$/i.test(host)) { throw new Error('Cloning from private/internal addresses is not allowed'); } // Standard IPv4 regex checks for dotted notation if ( /^127\./.test(host) || /^10\./.test(host) || /^172\.(1[6-9]|2\d|3[01])\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host) || /^0\./.test(host) || host === '0.0.0.0' || /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(host) || /^198\.1[89]\./.test(host) ) { throw new Error('Cloning from private/internal addresses is not allowed'); } } function assertNotPrivateIPv6(ip: string): void { // Expand common compressed forms for comparison const lower = ip.toLowerCase(); // IPv6 loopback if (lower === '::1' || lower === '0:0:0:0:0:0:0:1') { throw new Error('Cloning from private/internal addresses is not allowed'); } // Unspecified address if (lower === '::' || lower === '0:0:0:0:0:0:0:0') { throw new Error('Cloning from private/internal addresses is not allowed'); } // IPv6 Unique Local Address (fc00::/7 = fc and fd prefixes) if (lower.startsWith('fc') || lower.startsWith('fd')) { throw new Error('Cloning from private/internal addresses is not allowed'); } // IPv6 link-local (fe80::/10) if ( lower.startsWith('fe80') || lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb') ) { throw new Error('Cloning from private/internal addresses is not allowed'); } // IPv4-mapped IPv6 (::ffff:x.x.x.x or ::ffff:hex:hex) // Node may normalize ::ffff:127.0.0.1 to ::ffff:7f00:1 if (lower.startsWith('::ffff:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } // Also catch the expanded form: 0:0:0:0:0:ffff: if (lower.includes(':ffff:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } // IPv4-compatible IPv6 (RFC 4291 § 2.5.5.1, deprecated form: ::w.x.y.z). // Node's URL parser collapses http://[::127.0.0.1]/ to "::7f00:1" — the IPv4 // is hidden in the last 32 bits without the ::ffff: marker, so the check // above misses it. The form is still routable to the embedded IPv4 on most // network stacks, so any address compressed to ::xxxx[:yyyy] must be blocked. if (/^::[0-9a-f]{1,4}(:[0-9a-f]{1,4})?$/.test(lower)) { throw new Error('Cloning from private/internal addresses is not allowed'); } // NAT64 well-known prefix (RFC 6052 § 2.1: 64:ff9b::/96, plus the local // 64:ff9b:1::/48 from RFC 8215). Maps any IPv4 address — including private // ranges — into IPv6, so a host with NAT64 can reach the embedded IPv4 via // e.g. 64:ff9b::7f00:1 → 127.0.0.1. // The check intentionally covers the full 64:ff9b::/32 block (broader than // the two cited ranges): IANA reserves it for IPv4-IPv6 translation, so // blocking the whole prefix is defensively sound and prevents a narrower // CIDR check from quietly re-opening the bypass for 64:ff9b:1::/48 or any // future translation assignment. if (lower.startsWith('64:ff9b:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } // 6to4 (RFC 3056, 2002::/16). Encodes an IPv4 address in bits 17-48, so // 2002:7f00:0001::1 routes to 127.0.0.1 on 6to4-capable stacks. The // protocol was deprecated by RFC 7526 and the public relay anycast // (192.88.99.1) has been retired, so broad-blocking the prefix has near- // zero false-positive cost while closing the IPv4-embedded bypass. // Teredo (2001::/32) embeds IPv4 obfuscated by XOR; precise blocking is // impractical and is out of scope here. if (lower.startsWith('2002:')) { throw new Error('Cloning from private/internal addresses is not allowed'); } } function assertNotPrivateIPv4(ip: string): void { const parts = ip.split('.').map(Number); const [a, b] = parts; if ( a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || a === 0 || (a === 100 && b >= 64 && b <= 127) || (a === 198 && (b === 18 || b === 19)) ) { throw new Error('Cloning from private/internal addresses is not allowed'); } } export interface CloneProgress { phase: 'cloning' | 'pulling'; message: string; } /** * Build the `git clone` argument list for a given URL and target directory. * * The `--` separator is non-negotiable: it stops git from parsing a URL that * starts with `--` (e.g. `--upload-pack=evil`) as an option flag, which would * otherwise execute an attacker-chosen subprocess (CodeQL * js/second-order-command-line-injection, alerts #166/#167). * * Exported so the separator placement is testable without mocking spawn. */ export function buildCloneArgs(url: string, targetDir: string): string[] { return ['clone', '--depth', '1', '--', url, targetDir]; } /** * Normalize a git URL into a comparable form. * * Two URLs are considered the same repository when their normalized forms * are identical: lowercased hostname, no trailing `.git`, no trailing * slashes on the path, default port stripped. Path comparison stays * case-sensitive because that's how Git hosts treat the path component on * the wire (case-folding GitHub's web UI is a separate convenience). * * Returns the original input if URL parsing fails — the caller can still * compare with the literal string for non-URL forms (e.g. SSH `git@host:`). */ export function normalizeGitUrlForCompare(url: string): string { // Strip trailing slashes and a trailing `.git` for both URL and SSH forms. let trimmed = url; while (trimmed.length > 0 && trimmed[trimmed.length - 1] === '/') { trimmed = trimmed.slice(0, -1); } if (trimmed.endsWith('.git')) trimmed = trimmed.slice(0, -4); try { const parsed = new URL(trimmed); parsed.hostname = parsed.hostname.toLowerCase(); // strip default ports if ( (parsed.protocol === 'https:' && parsed.port === '443') || (parsed.protocol === 'http:' && parsed.port === '80') ) { parsed.port = ''; } // Strip credentials — never material to repo identity, and including // them would let two equivalent URLs (with/without basic auth) compare // unequal. parsed.username = ''; parsed.password = ''; // Recompose without trailing slash on the path. let pathname = parsed.pathname; while (pathname.length > 1 && pathname[pathname.length - 1] === '/') { pathname = pathname.slice(0, -1); } parsed.pathname = pathname; return `${parsed.protocol}//${parsed.hostname}${parsed.port ? ':' + parsed.port : ''}${parsed.pathname}`; } catch { // Non-URL forms (e.g. `git@github.com:owner/repo`) — return the trimmed // form lowercased on the hostname-ish prefix. SSH-form normalization // is best-effort; exact-string compare is sufficient for the threat // model (mismatched origins still differ at the literal level). return trimmed.toLowerCase(); } } /** * Read `remote.origin.url` from an existing clone using `git config --get`. * * Returns `null` if the config key is absent, the spawn fails, or the * directory isn't a git repository. The caller decides what a missing * remote means for its threat model — for cloneOrPull, a missing remote * on an existing clone is treated as a refuse-to-pull condition. */ export function getRemoteOriginUrl(cwd: string): Promise { return new Promise((resolve) => { const proc = spawn('git', ['config', '--get', 'remote.origin.url'], { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, }); let stdout = ''; proc.stdout.on('data', (chunk: Buffer) => { stdout += chunk; }); proc.on('close', (code) => { if (code === 0 && stdout.trim()) { resolve(stdout.trim()); } else { resolve(null); } }); proc.on('error', () => resolve(null)); }); } /** * Verify that an existing clone's `remote.origin.url` matches the requested * URL (after normalization). Throws on mismatch or missing remote. * * Closes the wrong-repo silent-analysis vector that Codex's adversarial * review on PR #1325 surfaced: clone dirs are keyed by URL basename, so a * request for `https://gitlab.example/attacker/repo.git` would otherwise * collide with an existing `~/.gitnexus/repos/repo` cloned from a different * origin and `git pull --ff-only` would silently succeed against the wrong * remote. * * Exported so the comparison logic is testable in isolation against any * tmpdir-based fixture, without needing to populate CLONE_ROOT. */ export async function assertRemoteMatchesRequestedUrl( targetDir: string, requestedUrl: string, ): Promise { const remoteUrl = await getRemoteOriginUrl(targetDir); if (remoteUrl === null) { throw new Error(`Existing clone at ${targetDir} has no remote.origin — refusing to pull`); } if (normalizeGitUrlForCompare(remoteUrl) !== normalizeGitUrlForCompare(requestedUrl)) { throw new Error( `Existing clone at ${targetDir} has remote ${remoteUrl}, not the requested URL ${requestedUrl}`, ); } } /** * Clone or pull a git repository. * If targetDir doesn't exist: git clone --depth 1 * If targetDir exists with .git: git pull --ff-only (after verifying the * existing clone's remote.origin matches the requested URL). * * Security: * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The * path.relative containment barrier below is the inline canonical idiom * CodeQL's js/path-injection sanitizer recognizes. * - validateGitUrl runs unconditionally on the requested URL — both the * clone path and the pull path. An earlier shape only validated on the * clone branch; an existing clone with the same basename let an * attacker's URL skip the SSRF / scheme / private-IP checks (Codex * adversarial review on PR #1325). * - When the target already has `.git`, the existing clone's * remote.origin.url is fetched and compared (normalized) to the * requested URL. Refuses to pull if they differ — this closes the * wrong-repo silent-analysis vector where two URLs sharing a basename * would collide on the same on-disk clone dir. * - The git URL is passed after a `--` separator so a value beginning with * `--` (e.g. `--upload-pack=evil`) cannot be interpreted as a git option * (CodeQL js/second-order-command-line-injection). */ export async function cloneOrPull( url: string, targetDir: string, onProgress?: (progress: CloneProgress) => void, ): Promise { // 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...' }); await runGit(['pull', '--ff-only'], safeTarget); } else { await fs.mkdir(path.dirname(safeTarget), { recursive: true }); onProgress?.({ phase: 'cloning', message: `Cloning ${url}...` }); await runGit(buildCloneArgs(url, safeTarget)); } return safeTarget; } function runGit(args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { const proc = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, env: { ...process.env, // Prevent git from prompting for credentials (hangs the process) GIT_TERMINAL_PROMPT: '0', // Ensure no credential helper tries to open a GUI prompt GIT_ASKPASS: process.platform === 'win32' ? 'echo' : '/bin/true', }, }); let stderr = ''; proc.stderr.on('data', (chunk: Buffer) => { stderr += chunk; }); proc.on('close', (code) => { if (code === 0) resolve(); else { // Log full stderr internally but don't expose it to API callers (SSRF mitigation) if (stderr.trim()) logger.error(`git ${args[0]} stderr: ${stderr.trim()}`); reject(new Error(`git ${args[0]} failed (exit code ${code})`)); } }); proc.on('error', (err) => { reject(new Error(`Failed to spawn git: ${err.message}`)); }); }); }