Browse Source

bug-fixing

main
Silberengel 4 weeks ago
parent
commit
2e20851757
  1. 363
      src/lib/services/git/file-manager.ts
  2. 102
      src/lib/services/git/repo-cache.ts
  3. 258
      src/lib/services/git/repo-manager.ts
  4. 22
      src/lib/utils/security.ts
  5. 96
      src/routes/api/git/[...path]/+server.ts

363
src/lib/services/git/file-manager.ts

@ -7,10 +7,14 @@ import simpleGit, { type SimpleGit } from 'simple-git'; @@ -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 { @@ -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<string> {
// 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<void>((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<string> => {
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<void>((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<void>((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<void> {
try {
// Use spawn for worktree remove (safer than exec)
await new Promise<void>((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 { @@ -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 { @@ -164,8 +374,20 @@ export class FileManager {
return false;
}
// Check cache first
const cacheKey = RepoCache.repoExistsKey(npub, repoName);
const cached = repoCache.get<boolean>(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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -437,7 +646,7 @@ export class FileManager {
}
/**
* Get list of branches
* Get list of branches (with caching)
*/
async getBranches(npub: string, repoName: string): Promise<string[]> {
const repoPath = this.getRepoPath(npub, repoName);
@ -446,16 +655,31 @@ export class FileManager { @@ -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<string[]>(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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -616,6 +832,14 @@ export class FileManager {
branchName: string,
fromBranch: string = 'main'
): Promise<void> {
// 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 { @@ -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)}`);

102
src/lib/services/git/repo-cache.ts

@ -0,0 +1,102 @@ @@ -0,0 +1,102 @@
/**
* Simple in-memory cache for repository metadata
* Reduces redundant filesystem and git operations
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number; // Time to live in milliseconds
}
export class RepoCache {
private cache: Map<string, CacheEntry<any>> = new Map();
private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default
/**
* Get a value from cache
*/
get<T>(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<T>(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);
}

258
src/lib/services/git/repo-manager.ts

@ -3,19 +3,62 @@ @@ -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<string, string> = {}
): 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 { @@ -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 { @@ -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<string, string> {
const env: Record<string, string> = {};
// Copy process.env, filtering out undefined values
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) {
env[key] = value;
}
// Whitelist only necessary environment variables for security
const env: Record<string, string> = {
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',
};
// 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 { @@ -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<void> {
for (const url of remoteUrls) {
try {
// Add remote if not exists
const remoteName = `remote-${remoteUrls.indexOf(url)}`;
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`);
private async syncFromSingleRemote(repoPath: string, url: string, index: number): Promise<void> {
const remoteName = `remote-${index}`;
const git = simpleGit(repoPath);
const gitEnv = this.getGitEnvForUrl(url);
// Get environment with Tor proxy if needed
const gitEnv = this.getGitEnvForUrl(url);
try {
// Add remote if not exists (ignore error if already 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 });
} catch {
// Config might fail, continue anyway
}
// 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 });
// Fetch from remote with appropriate environment
// Use spawn with proper argument arrays for security
await execGitWithEnv(repoPath, ['fetch', remoteName, '--all'], 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
// 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<void> {
for (const url of remoteUrls) {
try {
const remoteName = `remote-${remoteUrls.indexOf(url)}`;
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`);
async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise<void> {
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<boolean> {
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<void> {
const remoteName = `remote-${index}`;
const git = simpleGit(repoPath);
const gitEnv = this.getGitEnvForUrl(url);
// Get environment with Tor proxy if needed
const gitEnv = this.getGitEnvForUrl(url);
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// 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<void> {
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)');
}
});
}
/**

22
src/lib/utils/security.ts

@ -39,6 +39,10 @@ export function sanitizeError(error: unknown): string { @@ -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 { @@ -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
*/

96
src/routes/api/git/[...path]/+server.ts

@ -21,6 +21,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; @@ -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 }) => { @@ -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 }) => { @@ -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<string, string> = {
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,
@ -221,6 +228,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -221,6 +228,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
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';
const operation = service === 'git-upload-pack' || gitPath === 'git-upload-pack' ? 'fetch' : 'clone';
@ -239,6 +251,18 @@ export const GET: RequestHandler = async ({ params, url, request }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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 }) => { @@ -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<string, string> = {
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,
@ -452,6 +489,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -452,6 +489,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
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';
const operation = gitPath === 'git-receive-pack' || path.includes('git-receive-pack') ? 'push' : 'fetch';
@ -470,6 +512,18 @@ export const POST: RequestHandler = async ({ params, url, request }) => { @@ -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 }) => { @@ -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 }) => { @@ -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}`));
});
});
};

Loading…
Cancel
Save