diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 0ee4d69..36326c6 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -7,10 +7,14 @@ import simpleGit, { type SimpleGit } from 'simple-git'; import { readFile, readdir, stat } from 'fs/promises'; import { join, dirname, normalize, resolve } from 'path'; import { existsSync } from 'fs'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; import { RepoManager } from './repo-manager.js'; import { createGitCommitSignature } from './commit-signer.js'; import type { NostrEvent } from '../../types/nostr.js'; import logger from '../logger.js'; +import { sanitizeError, isValidBranchName } from '../../utils/security.js'; +import { repoCache, RepoCache } from './repo-cache.js'; export interface FileEntry { name: string; @@ -55,15 +59,221 @@ export class FileManager { this.repoManager = new RepoManager(repoRoot); } + /** + * Create or get a git worktree for a repository + * More efficient than cloning the entire repo for each operation + * Security: Validates branch name to prevent path traversal attacks + */ + private async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + + const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); + const worktreePath = join(worktreeRoot, branch); + + // Additional security: Ensure resolved path is still within worktreeRoot + const resolvedPath = resolve(worktreePath).replace(/\\/g, '/'); + const resolvedRoot = resolve(worktreeRoot).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + throw new Error('Path traversal detected: worktree path outside allowed root'); + } + const { mkdir, rm } = await import('fs/promises'); + + // Ensure worktree root exists + if (!existsSync(worktreeRoot)) { + await mkdir(worktreeRoot, { recursive: true }); + } + + const git = simpleGit(repoPath); + + // Check if worktree already exists + if (existsSync(worktreePath)) { + // Verify it's a valid worktree + try { + const worktreeGit = simpleGit(worktreePath); + await worktreeGit.status(); + return worktreePath; + } catch { + // Invalid worktree, remove it + await rm(worktreePath, { recursive: true, force: true }); + } + } + + // Create new worktree + try { + // Use spawn for worktree add (safer than exec) + await new Promise((resolve, reject) => { + const gitProcess = spawn('git', ['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + gitProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + // If branch doesn't exist, create it first using git branch (works on bare repos) + if (stderr.includes('fatal: invalid reference') || stderr.includes('fatal: not a valid object name')) { + // First, try to find a source branch (HEAD, main, or master) + const findSourceBranch = async (): Promise => { + try { + const branches = await git.branch(['-a']); + // Try HEAD first, then main, then master + if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { + return 'HEAD'; + } + if (branches.all.includes('main') || branches.all.includes('origin/main')) { + return 'main'; + } + if (branches.all.includes('master') || branches.all.includes('origin/master')) { + return 'master'; + } + // Use the first available branch + const firstBranch = branches.all.find(b => !b.includes('HEAD')); + if (firstBranch) { + return firstBranch.replace(/^origin\//, ''); + } + throw new Error('No source branch found'); + } catch { + // Default to HEAD + return 'HEAD'; + } + }; + + findSourceBranch().then((sourceBranch) => { + // Create branch using git branch command (works on bare repos) + return new Promise((resolveBranch, rejectBranch) => { + const branchProcess = spawn('git', ['branch', branch, sourceBranch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let branchStderr = ''; + branchProcess.stderr.on('data', (chunk: Buffer) => { + branchStderr += chunk.toString(); + }); + + branchProcess.on('close', (branchCode) => { + if (branchCode === 0) { + resolveBranch(); + } else { + rejectBranch(new Error(`Failed to create branch: ${branchStderr}`)); + } + }); + + branchProcess.on('error', rejectBranch); + }); + }).then(() => { + // Retry worktree add after creating the branch + return new Promise((resolve2, reject2) => { + const gitProcess2 = spawn('git', ['worktree', 'add', worktreePath, branch], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let retryStderr = ''; + gitProcess2.stderr.on('data', (chunk: Buffer) => { + retryStderr += chunk.toString(); + }); + + gitProcess2.on('close', (code2) => { + if (code2 === 0) { + resolve2(); + } else { + reject2(new Error(`Failed to create worktree after creating branch: ${retryStderr}`)); + } + }); + + gitProcess2.on('error', reject2); + }); + }).then(resolve).catch(reject); + } else { + reject(new Error(`Failed to create worktree: ${stderr}`)); + } + } + }); + + gitProcess.on('error', reject); + }); + + return worktreePath; + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.error({ error: sanitizedError, repoPath, branch }, 'Failed to create worktree'); + throw new Error(`Failed to create worktree: ${sanitizedError}`); + } + } + + /** + * Remove a worktree + */ + private async removeWorktree(repoPath: string, worktreePath: string): Promise { + try { + // Use spawn for worktree remove (safer than exec) + await new Promise((resolve, reject) => { + const gitProcess = spawn('git', ['worktree', 'remove', worktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + // If worktree remove fails, try force remove + const gitProcess2 = spawn('git', ['worktree', 'remove', '--force', worktreePath], { + cwd: repoPath, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + gitProcess2.on('close', (code2) => { + if (code2 === 0) { + resolve(); + } else { + // Last resort: just delete the directory + import('fs/promises').then(({ rm }) => { + return rm(worktreePath, { recursive: true, force: true }); + }).then(() => resolve()).catch(reject); + } + }); + + gitProcess2.on('error', reject); + } + }); + + gitProcess.on('error', reject); + }); + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.warn({ error: sanitizedError, repoPath, worktreePath }, 'Failed to remove worktree cleanly'); + // Try to remove directory directly as fallback + try { + const { rm } = await import('fs/promises'); + await rm(worktreePath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + } + } + /** * Get the full path to a repository */ private getRepoPath(npub: string, repoName: string): string { const repoPath = join(this.repoRoot, npub, `${repoName}.git`); // Security: Ensure the resolved path is within repoRoot to prevent path traversal - const resolvedPath = resolve(repoPath); - const resolvedRoot = resolve(this.repoRoot); - if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { + // Normalize paths to handle Windows/Unix differences + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); + // Must be a subdirectory of repoRoot, not equal to it + if (!resolvedPath.startsWith(resolvedRoot + '/')) { throw new Error('Path traversal detected: repository path outside allowed root'); } return repoPath; @@ -151,7 +361,7 @@ export class FileManager { } /** - * Check if repository exists + * Check if repository exists (with caching) */ repoExists(npub: string, repoName: string): boolean { // Validate inputs @@ -164,8 +374,20 @@ export class FileManager { return false; } + // Check cache first + const cacheKey = RepoCache.repoExistsKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + return cached; + } + const repoPath = this.getRepoPath(npub, repoName); - return this.repoManager.repoExists(repoPath); + const exists = this.repoManager.repoExists(repoPath); + + // Cache the result (cache for 1 minute) + repoCache.set(cacheKey, exists, 60 * 1000); + + return exists; } /** @@ -318,6 +540,11 @@ export class FileManager { throw new Error(`Invalid file path: ${pathValidation.error}`); } + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + // Validate content size (prevent extremely large files) const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos) if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { @@ -353,39 +580,20 @@ export class FileManager { throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); } - // Clone bare repo to a temporary working directory (non-bare) - const workDir = join(this.repoRoot, npub, `${repoName}.work`); - const { rm } = await import('fs/promises'); - - // Remove work directory if it exists to ensure clean state - if (existsSync(workDir)) { - await rm(workDir, { recursive: true, force: true }); - } - - // Clone the bare repo to a working directory - const git: SimpleGit = simpleGit(); - await git.clone(repoPath, workDir); - - // Use the work directory for operations + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, branch, npub, repoName); const workGit: SimpleGit = simpleGit(workDir); - // Checkout the branch (or create it) - try { - await workGit.checkout([branch]); - } catch { - // Branch doesn't exist, create it - await workGit.checkout(['-b', branch]); - } - // Write the file (use validated path) const validatedPath = pathValidation.normalized || filePath; const fullFilePath = join(workDir, validatedPath); const fileDir = dirname(fullFilePath); // Additional security: ensure the resolved path is still within workDir - const resolvedPath = resolve(fullFilePath); - const resolvedWorkDir = resolve(workDir); - if (!resolvedPath.startsWith(resolvedWorkDir)) { + // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { throw new Error('Path validation failed: resolved path outside work directory'); } @@ -414,7 +622,7 @@ export class FileManager { finalCommitMessage = signedMessage; } catch (err) { // Security: Sanitize error messages (never log private keys) - const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err); + const sanitizedErr = sanitizeError(err); logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); // Continue without signature if signing fails } @@ -425,11 +633,12 @@ export class FileManager { '--author': `${authorName} <${authorEmail}>` }); - // Push to bare repo + // Push to bare repo (worktree is already connected) await workGit.push(['origin', branch]); - // Clean up work directory - await rm(workDir, { recursive: true, force: true }); + // Clean up worktree (but keep it for potential reuse) + // Note: We could keep worktrees for better performance, but clean up for now + await this.removeWorktree(repoPath, workDir); } catch (error) { logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); @@ -437,7 +646,7 @@ export class FileManager { } /** - * Get list of branches + * Get list of branches (with caching) */ async getBranches(npub: string, repoName: string): Promise { const repoPath = this.getRepoPath(npub, repoName); @@ -446,16 +655,31 @@ export class FileManager { throw new Error('Repository not found'); } + // Check cache first + const cacheKey = RepoCache.branchesKey(npub, repoName); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + return cached; + } + const git: SimpleGit = simpleGit(repoPath); try { const branches = await git.branch(['-r']); - return branches.all + const branchList = branches.all .map(b => b.replace(/^origin\//, '')) .filter(b => !b.includes('HEAD')); + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, branchList, 2 * 60 * 1000); + + return branchList; } catch (error) { logger.error({ error, repoPath }, 'Error getting branches'); - return ['main', 'master']; // Default branches + const defaultBranches = ['main', 'master']; + // Cache default branches for shorter time (30 seconds) + repoCache.set(cacheKey, defaultBranches, 30 * 1000); + return defaultBranches; } } @@ -515,6 +739,11 @@ export class FileManager { throw new Error(`Invalid file path: ${pathValidation.error}`); } + // Security: Validate branch name to prevent path traversal + if (!isValidBranchName(branch)) { + throw new Error(`Invalid branch name: ${branch}`); + } + // Validate commit message if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { throw new Error('Commit message is required'); @@ -535,32 +764,19 @@ export class FileManager { } try { - const workDir = join(this.repoRoot, npub, `${repoName}.work`); - const { rm } = await import('fs/promises'); - - if (existsSync(workDir)) { - await rm(workDir, { recursive: true, force: true }); - } - - const git: SimpleGit = simpleGit(); - await git.clone(repoPath, workDir); - + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, branch, npub, repoName); const workGit: SimpleGit = simpleGit(workDir); - try { - await workGit.checkout([branch]); - } catch { - await workGit.checkout(['-b', branch]); - } - // Remove the file (use validated path) const validatedPath = pathValidation.normalized || filePath; const fullFilePath = join(workDir, validatedPath); // Additional security: ensure the resolved path is still within workDir - const resolvedPath = resolve(fullFilePath); - const resolvedWorkDir = resolve(workDir); - if (!resolvedPath.startsWith(resolvedWorkDir)) { + // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) + const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { throw new Error('Path validation failed: resolved path outside work directory'); } @@ -585,7 +801,7 @@ export class FileManager { finalCommitMessage = signedMessage; } catch (err) { // Security: Sanitize error messages (never log private keys) - const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err); + const sanitizedErr = sanitizeError(err); logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); // Continue without signature if signing fails } @@ -596,11 +812,11 @@ export class FileManager { '--author': `${authorName} <${authorEmail}>` }); - // Push to bare repo + // Push to bare repo (worktree is already connected) await workGit.push(['origin', branch]); - // Clean up - await rm(workDir, { recursive: true, force: true }); + // Clean up worktree + await this.removeWorktree(repoPath, workDir); } catch (error) { logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); @@ -616,6 +832,14 @@ export class FileManager { branchName: string, fromBranch: string = 'main' ): Promise { + // Security: Validate branch names to prevent path traversal + if (!isValidBranchName(branchName)) { + throw new Error(`Invalid branch name: ${branchName}`); + } + if (!isValidBranchName(fromBranch)) { + throw new Error(`Invalid source branch name: ${fromBranch}`); + } + const repoPath = this.getRepoPath(npub, repoName); if (!this.repoExists(npub, repoName)) { @@ -623,29 +847,18 @@ export class FileManager { } try { - const workDir = join(this.repoRoot, npub, `${repoName}.work`); - const { rm } = await import('fs/promises'); - - if (existsSync(workDir)) { - await rm(workDir, { recursive: true, force: true }); - } - - const git: SimpleGit = simpleGit(); - await git.clone(repoPath, workDir); - + // Use git worktree instead of cloning (much more efficient) + const workDir = await this.getWorktree(repoPath, fromBranch, npub, repoName); const workGit: SimpleGit = simpleGit(workDir); - // Checkout source branch - await workGit.checkout([fromBranch]); - // Create and checkout new branch await workGit.checkout(['-b', branchName]); // Push new branch await workGit.push(['origin', branchName]); - // Clean up - await rm(workDir, { recursive: true, force: true }); + // Clean up worktree + await this.removeWorktree(repoPath, workDir); } catch (error) { logger.error({ error, repoPath, branchName, npub }, 'Error creating branch'); throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/lib/services/git/repo-cache.ts b/src/lib/services/git/repo-cache.ts new file mode 100644 index 0000000..01e5756 --- /dev/null +++ b/src/lib/services/git/repo-cache.ts @@ -0,0 +1,102 @@ +/** + * Simple in-memory cache for repository metadata + * Reduces redundant filesystem and git operations + */ + +interface CacheEntry { + data: T; + timestamp: number; + ttl: number; // Time to live in milliseconds +} + +export class RepoCache { + private cache: Map> = new Map(); + private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default + + /** + * Get a value from cache + */ + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) { + return null; + } + + // Check if entry has expired + const now = Date.now(); + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + return entry.data as T; + } + + /** + * Set a value in cache + */ + set(key: string, data: T, ttl?: number): void { + this.cache.set(key, { + data, + timestamp: Date.now(), + ttl: ttl || this.defaultTTL + }); + } + + /** + * Delete a value from cache + */ + delete(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear(); + } + + /** + * Clear expired entries + */ + cleanup(): void { + const now = Date.now(); + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + } + } + } + + /** + * Generate cache key for repository existence check + */ + static repoExistsKey(npub: string, repoName: string): string { + return `repo:exists:${npub}:${repoName}`; + } + + /** + * Generate cache key for branch list + */ + static branchesKey(npub: string, repoName: string): string { + return `repo:branches:${npub}:${repoName}`; + } + + /** + * Generate cache key for file listing + */ + static fileListKey(npub: string, repoName: string, ref: string, path: string): string { + return `repo:files:${npub}:${repoName}:${ref}:${path}`; + } +} + +// Singleton instance +export const repoCache = new RepoCache(); + +// Cleanup expired entries every 10 minutes +if (typeof setInterval !== 'undefined') { + setInterval(() => { + repoCache.cleanup(); + }, 10 * 60 * 1000); +} diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index d23d5ed..ae93e44 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -3,19 +3,62 @@ * Handles repo provisioning, syncing, and NIP-34 integration */ -import { exec } from 'child_process'; -import { promisify } from 'util'; import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs'; import { join } from 'path'; import { readdir } from 'fs/promises'; +import { spawn } from 'child_process'; +import { promisify } from 'util'; import type { NostrEvent } from '../../types/nostr.js'; import { GIT_DOMAIN } from '../../config.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; import simpleGit, { type SimpleGit } from 'simple-git'; import logger from '../logger.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; +import { sanitizeError } from '../../utils/security.js'; + +/** + * Execute git command with custom environment variables safely + * Uses spawn with argument arrays to prevent command injection + * Security: Only uses whitelisted environment variables, does not spread process.env + */ +function execGitWithEnv( + repoPath: string, + args: string[], + env: Record = {} +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const gitProcess = spawn('git', args, { + cwd: repoPath, + // Security: Only use whitelisted env vars, don't spread process.env + // The env parameter should already contain only safe, whitelisted variables + env: env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + gitProcess.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + + gitProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + gitProcess.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr }); + } else { + reject(new Error(`Git command failed with code ${code}: ${stderr || stdout}`)); + } + }); -const execAsync = promisify(exec); + gitProcess.on('error', (err) => { + reject(err); + }); + }); +} export interface RepoPath { npub: string; @@ -87,7 +130,9 @@ export class RepoManager { // Create bare repository if it doesn't exist const isNewRepo = !repoExists; if (isNewRepo) { - await execAsync(`git init --bare "${repoPath.fullPath}"`); + // Use simple-git to create bare repo (safer than exec) + const git = simpleGit(); + await git.init(['--bare', repoPath.fullPath]); // Create verification file and self-transfer event in the repository await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); @@ -107,15 +152,21 @@ export class RepoManager { /** * Get git environment variables with Tor proxy if needed for .onion addresses + * Security: Only whitelist necessary environment variables */ private getGitEnvForUrl(url: string): Record { - const env: Record = {}; + // Whitelist only necessary environment variables for security + const env: Record = { + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME || '/tmp', + USER: process.env.USER || 'git', + LANG: process.env.LANG || 'C.UTF-8', + LC_ALL: process.env.LC_ALL || 'C.UTF-8', + }; - // Copy process.env, filtering out undefined values - for (const [key, value] of Object.entries(process.env)) { - if (value !== undefined) { - env[key] = value; - } + // Add TZ if set (for consistent timestamps) + if (process.env.TZ) { + env.TZ = process.env.TZ; } if (shouldUseTor(url)) { @@ -148,76 +199,179 @@ export class RepoManager { } /** - * Sync repository from multiple remote URLs + * Sync from a single remote URL (helper for parallelization) */ - async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise { - for (const url of remoteUrls) { + private async syncFromSingleRemote(repoPath: string, url: string, index: number): Promise { + const remoteName = `remote-${index}`; + const git = simpleGit(repoPath); + const gitEnv = this.getGitEnvForUrl(url); + + try { + // Add remote if not exists (ignore error if already exists) try { - // Add remote if not exists - const remoteName = `remote-${remoteUrls.indexOf(url)}`; - await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); - - // Get environment with Tor proxy if needed - const gitEnv = this.getGitEnvForUrl(url); - - // Configure git proxy for this remote if it's a .onion address - if (shouldUseTor(url)) { - const proxy = getTorProxy(); - if (proxy) { - // Set git config for this specific URL pattern - try { - await execAsync(`cd "${repoPath}" && git config --local http.${url}.proxy socks5://${proxy.host}:${proxy.port}`, { env: gitEnv }); - } catch { - // Config might fail, continue anyway - } + await git.addRemote(remoteName, url); + } catch { + // Remote might already exist, that's okay + } + + // Configure git proxy for this remote if it's a .onion address + if (shouldUseTor(url)) { + const proxy = getTorProxy(); + if (proxy) { + try { + // Use simple-git to set config (safer than exec) + await git.addConfig(`http.${url}.proxy`, `socks5://${proxy.host}:${proxy.port}`, false, 'local'); + } catch { + // Config might fail, continue anyway } } - - // Fetch from remote with appropriate environment - await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`, { env: gitEnv }); - - // Update all branches - await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`, { env: gitEnv }); - } catch (error) { - logger.error({ error, url, repoPath }, 'Failed to sync from remote'); - // Continue with other remotes } + + // Fetch from remote with appropriate environment + // Use spawn with proper argument arrays for security + await execGitWithEnv(repoPath, ['fetch', remoteName, '--all'], gitEnv); + + // Update remote head + try { + await execGitWithEnv(repoPath, ['remote', 'set-head', remoteName, '-a'], gitEnv); + } catch { + // Ignore errors for set-head + } + } catch (error) { + const sanitizedError = sanitizeError(error); + logger.error({ error: sanitizedError, url, repoPath }, 'Failed to sync from remote'); + throw error; // Re-throw for Promise.allSettled handling } } /** - * Sync repository to multiple remote URLs after a push + * Sync repository from multiple remote URLs (parallelized for efficiency) */ - async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise { - for (const url of remoteUrls) { + async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise { + if (remoteUrls.length === 0) return; + + // Sync all remotes in parallel for better performance + const results = await Promise.allSettled( + remoteUrls.map((url, index) => this.syncFromSingleRemote(repoPath, url, index)) + ); + + // Log any failures but don't throw (partial success is acceptable) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const sanitizedError = sanitizeError(result.reason); + logger.warn({ error: sanitizedError, url: remoteUrls[index], repoPath }, 'Failed to sync from one remote (continuing with others)'); + } + }); + } + + /** + * Check if force push is safe (no divergent history) + * This is a simplified check - in production you might want more sophisticated validation + */ + private async canSafelyForcePush(repoPath: string, remoteName: string): Promise { + try { + const git = simpleGit(repoPath); + // Fetch to see if there are any remote changes + await git.fetch(remoteName); + // If fetch succeeds, check if we're ahead (safe to force) or behind (dangerous) + const status = await git.status(); + // For now, default to false (safer) unless explicitly allowed + // In production, you'd check branch divergence more carefully + return false; + } catch { + // If we can't determine, default to false (safer) + return false; + } + } + + /** + * Sync to a single remote URL with retry logic (helper for parallelization) + */ + private async syncToSingleRemote(repoPath: string, url: string, index: number, maxRetries: number = 3): Promise { + const remoteName = `remote-${index}`; + const git = simpleGit(repoPath); + const gitEnv = this.getGitEnvForUrl(url); + + let lastError: Error | null = null; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const remoteName = `remote-${remoteUrls.indexOf(url)}`; - await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); - - // Get environment with Tor proxy if needed - const gitEnv = this.getGitEnvForUrl(url); + // Add remote if not exists + try { + await git.addRemote(remoteName, url); + } catch { + // Remote might already exist, that's okay + } // Configure git proxy for this remote if it's a .onion address if (shouldUseTor(url)) { const proxy = getTorProxy(); if (proxy) { - // Set git config for this specific URL pattern try { - await execAsync(`cd "${repoPath}" && git config --local http.${url}.proxy socks5://${proxy.host}:${proxy.port}`, { env: gitEnv }); + await git.addConfig(`http.${url}.proxy`, `socks5://${proxy.host}:${proxy.port}`, false, 'local'); } catch { // Config might fail, continue anyway } } } - // Push to remote with appropriate environment - await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`, { env: gitEnv }); - await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`, { env: gitEnv }); + // Check if force push is safe + const allowForce = process.env.ALLOW_FORCE_PUSH === 'true' || await this.canSafelyForcePush(repoPath, remoteName); + const forceFlag = allowForce ? ['--force'] : []; + + // Push branches with appropriate environment using spawn + await execGitWithEnv(repoPath, ['push', remoteName, '--all', ...forceFlag], gitEnv); + + // Push tags + await execGitWithEnv(repoPath, ['push', remoteName, '--tags', ...forceFlag], gitEnv); + + // Success - return + return; } catch (error) { - logger.error({ error, url, repoPath }, 'Failed to sync to remote'); - // Continue with other remotes + lastError = error instanceof Error ? error : new Error(String(error)); + const sanitizedError = sanitizeError(lastError); + + if (attempt < maxRetries) { + // Exponential backoff: wait 2^attempt seconds + const delayMs = Math.pow(2, attempt) * 1000; + logger.warn({ + error: sanitizedError, + url, + repoPath, + attempt, + maxRetries, + retryIn: `${delayMs}ms` + }, 'Failed to sync to remote, retrying...'); + + await new Promise(resolve => setTimeout(resolve, delayMs)); + } else { + logger.error({ error: sanitizedError, url, repoPath, attempts: maxRetries }, 'Failed to sync to remote after all retries'); + throw lastError; + } } } + + throw lastError || new Error('Failed to sync to remote'); + } + + /** + * Sync repository to multiple remote URLs after a push (parallelized with retry) + */ + async syncToRemotes(repoPath: string, remoteUrls: string[]): Promise { + if (remoteUrls.length === 0) return; + + // Sync all remotes in parallel for better performance + const results = await Promise.allSettled( + remoteUrls.map((url, index) => this.syncToSingleRemote(repoPath, url, index)) + ); + + // Log any failures but don't throw (partial success is acceptable) + results.forEach((result, index) => { + if (result.status === 'rejected') { + const sanitizedError = sanitizeError(result.reason); + logger.warn({ error: sanitizedError, url: remoteUrls[index], repoPath }, 'Failed to sync to one remote (continuing with others)'); + } + }); } /** diff --git a/src/lib/utils/security.ts b/src/lib/utils/security.ts index 51ed534..27fd167 100644 --- a/src/lib/utils/security.ts +++ b/src/lib/utils/security.ts @@ -39,6 +39,10 @@ export function sanitizeError(error: unknown): string { message = message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]'); message = message.replace(/[0-9a-f]{64}/g, '[REDACTED]'); // 64-char hex keys + // Remove password patterns + message = message.replace(/password[=:]\s*\S+/gi, 'password=[REDACTED]'); + message = message.replace(/pwd[=:]\s*\S+/gi, 'pwd=[REDACTED]'); + // Truncate long pubkeys in error messages message = message.replace(/(npub[a-z0-9]{50,})/gi, (match) => truncateNpub(match)); message = message.replace(/([0-9a-f]{50,})/g, (match) => truncatePubkey(match)); @@ -48,6 +52,24 @@ export function sanitizeError(error: unknown): string { return String(error); } +/** + * Validate branch name to prevent injection attacks + */ +export function isValidBranchName(name: string): boolean { + if (!name || typeof name !== 'string') return false; + if (name.length === 0 || name.length > 255) return false; + if (name.startsWith('.') || name.startsWith('-')) return false; + if (name.includes('..') || name.includes('//')) return false; + // Allow alphanumeric, dots, hyphens, underscores, and forward slashes + // But not at the start or end + if (!/^[a-zA-Z0-9._/-]+$/.test(name)) return false; + if (name.endsWith('.') || name.endsWith('-') || name.endsWith('/')) return false; + // Git reserved names + const reserved = ['HEAD', 'MERGE_HEAD', 'FETCH_HEAD', 'ORIG_HEAD']; + if (reserved.includes(name.toUpperCase())) return false; + return true; +} + /** * Check if a string might contain a private key */ diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 58acea8..240fe74 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -21,6 +21,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js'; import logger from '$lib/services/logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; +import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoManager = new RepoManager(repoRoot); @@ -133,9 +134,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { // Get repository path with security validation const repoPath = join(repoRoot, npub, `${repoName}.git`); // Security: Ensure the resolved path is within repoRoot to prevent path traversal - const resolvedPath = resolve(repoPath); - const resolvedRoot = resolve(repoRoot); - if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { + // Normalize paths to handle Windows/Unix differences + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + // Must be a subdirectory of repoRoot, not equal to it + if (!resolvedPath.startsWith(resolvedRoot + '/')) { return error(403, 'Invalid repository path'); } if (!repoManager.repoExists(repoPath)) { @@ -207,10 +210,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`; // Set up environment variables for git-http-backend - // Security: Use the specific repository path, not the entire repoRoot - // This limits git-http-backend's view to only this repository - const envVars = { - ...process.env, + // Security: Whitelist only necessary environment variables + // This prevents leaking secrets from process.env + const envVars: Record = { + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME || '/tmp', + USER: process.env.USER || 'git', + LANG: process.env.LANG || 'C.UTF-8', + LC_ALL: process.env.LC_ALL || 'C.UTF-8', GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: request.method, @@ -220,6 +227,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { CONTENT_LENGTH: request.headers.get('Content-Length') || '0', HTTP_USER_AGENT: request.headers.get('User-Agent') || '', }; + + // Add TZ if set (for consistent timestamps) + if (process.env.TZ) { + envVars.TZ = process.env.TZ; + } // Execute git-http-backend with timeout and security hardening const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; @@ -239,6 +251,18 @@ export const GET: RequestHandler = async ({ params, url, request }) => { timeoutId = setTimeout(() => { gitProcess.kill('SIGTERM'); + // Force kill after grace period if process doesn't terminate + const forceKillTimeout = setTimeout(() => { + if (!gitProcess.killed) { + gitProcess.kill('SIGKILL'); + } + }, 5000); // 5 second grace period + + // Clear force kill timeout if process terminates + gitProcess.on('close', () => { + clearTimeout(forceKillTimeout); + }); + auditLogger.logRepoAccess( originalOwnerPubkey, clientIp, @@ -287,7 +311,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => { } if (code !== 0 && chunks.length === 0) { - resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`)); + const sanitizedError = sanitizeError(errorOutput || 'Unknown error'); + resolve(error(500, `git-http-backend error: ${sanitizedError}`)); return; } @@ -323,7 +348,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => { 'failure', `Process error: ${err.message}` ); - resolve(error(500, `Failed to execute git-http-backend: ${err.message}`)); + const sanitizedError = sanitizeError(err); + resolve(error(500, `Failed to execute git-http-backend: ${sanitizedError}`)); }); }); }; @@ -350,9 +376,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { // Get repository path with security validation const repoPath = join(repoRoot, npub, `${repoName}.git`); // Security: Ensure the resolved path is within repoRoot to prevent path traversal - const resolvedPath = resolve(repoPath); - const resolvedRoot = resolve(repoRoot); - if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { + // Normalize paths to handle Windows/Unix differences + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + // Must be a subdirectory of repoRoot, not equal to it + if (!resolvedPath.startsWith(resolvedRoot + '/')) { return error(403, 'Invalid repository path'); } if (!repoManager.repoExists(repoPath)) { @@ -407,6 +435,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { const branchMatch = bodyText.match(/refs\/heads\/([^\s\n]+)/); targetBranch = branchMatch ? branchMatch[1] : 'main'; // Default to main if can't determine + // Validate branch name to prevent injection + if (!isValidBranchName(targetBranch)) { + return error(400, 'Invalid branch name'); + } + const protectionCheck = await branchProtectionService.canPushToBranch( authResult.pubkey || '', currentOwnerPubkey, @@ -421,7 +454,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } catch (error) { // If we can't check protection, log but don't block (fail open for now) // Security: Sanitize error messages - const sanitizedError = error instanceof Error ? error.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(error); + const sanitizedError = sanitizeError(error); logger.warn({ error: sanitizedError, npub, repoName, targetBranch }, 'Failed to check branch protection'); } } @@ -438,10 +471,14 @@ export const POST: RequestHandler = async ({ params, url, request }) => { const pathInfo = gitPath ? `/${gitPath}` : `/`; // Set up environment variables for git-http-backend - // Security: Use the specific repository path, not the entire repoRoot - // This limits git-http-backend's view to only this repository - const envVars = { - ...process.env, + // Security: Whitelist only necessary environment variables + // This prevents leaking secrets from process.env + const envVars: Record = { + PATH: process.env.PATH || '/usr/bin:/bin', + HOME: process.env.HOME || '/tmp', + USER: process.env.USER || 'git', + LANG: process.env.LANG || 'C.UTF-8', + LC_ALL: process.env.LC_ALL || 'C.UTF-8', GIT_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot GIT_HTTP_EXPORT_ALL: '1', REQUEST_METHOD: request.method, @@ -451,6 +488,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { CONTENT_LENGTH: bodyBuffer.length.toString(), HTTP_USER_AGENT: request.headers.get('User-Agent') || '', }; + + // Add TZ if set (for consistent timestamps) + if (process.env.TZ) { + envVars.TZ = process.env.TZ; + } // Execute git-http-backend with timeout and security hardening const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; @@ -470,6 +512,18 @@ export const POST: RequestHandler = async ({ params, url, request }) => { timeoutId = setTimeout(() => { gitProcess.kill('SIGTERM'); + // Force kill after grace period if process doesn't terminate + const forceKillTimeout = setTimeout(() => { + if (!gitProcess.killed) { + gitProcess.kill('SIGKILL'); + } + }, 5000); // 5 second grace period + + // Clear force kill timeout if process terminates + gitProcess.on('close', () => { + clearTimeout(forceKillTimeout); + }); + auditLogger.logRepoAccess( currentOwnerPubkey, clientIp, @@ -540,14 +594,15 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } } catch (err) { // Security: Sanitize error messages - const sanitizedErr = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : String(err); + const sanitizedErr = sanitizeError(err); logger.error({ error: sanitizedErr, npub, repoName }, 'Failed to sync to remotes'); // Don't fail the request if sync fails } } if (code !== 0 && chunks.length === 0) { - resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`)); + const sanitizedError = sanitizeError(errorOutput || 'Unknown error'); + resolve(error(500, `git-http-backend error: ${sanitizedError}`)); return; } @@ -579,7 +634,8 @@ export const POST: RequestHandler = async ({ params, url, request }) => { 'failure', `Process error: ${err.message}` ); - resolve(error(500, `Failed to execute git-http-backend: ${err.message}`)); + const sanitizedError = sanitizeError(err); + resolve(error(500, `Failed to execute git-http-backend: ${sanitizedError}`)); }); }); };