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';
import { readFile, readdir, stat } from 'fs/promises'; import { readFile, readdir, stat } from 'fs/promises';
import { join, dirname, normalize, resolve } from 'path'; import { join, dirname, normalize, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { spawn } from 'child_process';
import { promisify } from 'util';
import { RepoManager } from './repo-manager.js'; import { RepoManager } from './repo-manager.js';
import { createGitCommitSignature } from './commit-signer.js'; import { createGitCommitSignature } from './commit-signer.js';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import logger from '../logger.js'; import logger from '../logger.js';
import { sanitizeError, isValidBranchName } from '../../utils/security.js';
import { repoCache, RepoCache } from './repo-cache.js';
export interface FileEntry { export interface FileEntry {
name: string; name: string;
@ -55,15 +59,221 @@ export class FileManager {
this.repoManager = new RepoManager(repoRoot); 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 * Get the full path to a repository
*/ */
private getRepoPath(npub: string, repoName: string): string { private getRepoPath(npub: string, repoName: string): string {
const repoPath = join(this.repoRoot, npub, `${repoName}.git`); const repoPath = join(this.repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal // Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath); // Normalize paths to handle Windows/Unix differences
const resolvedRoot = resolve(this.repoRoot); const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { 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'); throw new Error('Path traversal detected: repository path outside allowed root');
} }
return repoPath; 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 { repoExists(npub: string, repoName: string): boolean {
// Validate inputs // Validate inputs
@ -164,8 +374,20 @@ export class FileManager {
return false; 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); 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}`); 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) // Validate content size (prevent extremely large files)
const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos) const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos)
if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { if (Buffer.byteLength(content, 'utf-8') > maxFileSize) {
@ -353,39 +580,20 @@ export class FileManager {
throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); throw new Error(repoSizeCheck.error || 'Repository size limit exceeded');
} }
// Clone bare repo to a temporary working directory (non-bare) // Use git worktree instead of cloning (much more efficient)
const workDir = join(this.repoRoot, npub, `${repoName}.work`); const workDir = await this.getWorktree(repoPath, branch, npub, repoName);
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
const workGit: SimpleGit = simpleGit(workDir); 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) // Write the file (use validated path)
const validatedPath = pathValidation.normalized || filePath; const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(workDir, validatedPath); const fullFilePath = join(workDir, validatedPath);
const fileDir = dirname(fullFilePath); const fileDir = dirname(fullFilePath);
// Additional security: ensure the resolved path is still within workDir // Additional security: ensure the resolved path is still within workDir
const resolvedPath = resolve(fullFilePath); // Use trailing slash to ensure directory boundary (prevents sibling directory attacks)
const resolvedWorkDir = resolve(workDir); const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir)) { const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory'); throw new Error('Path validation failed: resolved path outside work directory');
} }
@ -414,7 +622,7 @@ export class FileManager {
finalCommitMessage = signedMessage; finalCommitMessage = signedMessage;
} catch (err) { } catch (err) {
// Security: Sanitize error messages (never log private keys) // 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'); logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
// Continue without signature if signing fails // Continue without signature if signing fails
} }
@ -425,11 +633,12 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
// Push to bare repo // Push to bare repo (worktree is already connected)
await workGit.push(['origin', branch]); await workGit.push(['origin', branch]);
// Clean up work directory // Clean up worktree (but keep it for potential reuse)
await rm(workDir, { recursive: true, force: true }); // Note: We could keep worktrees for better performance, but clean up for now
await this.removeWorktree(repoPath, workDir);
} catch (error) { } catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); logger.error({ error, repoPath, filePath, npub }, 'Error writing file');
throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); 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<string[]> { async getBranches(npub: string, repoName: string): Promise<string[]> {
const repoPath = this.getRepoPath(npub, repoName); const repoPath = this.getRepoPath(npub, repoName);
@ -446,16 +655,31 @@ export class FileManager {
throw new Error('Repository not found'); 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); const git: SimpleGit = simpleGit(repoPath);
try { try {
const branches = await git.branch(['-r']); const branches = await git.branch(['-r']);
return branches.all const branchList = branches.all
.map(b => b.replace(/^origin\//, '')) .map(b => b.replace(/^origin\//, ''))
.filter(b => !b.includes('HEAD')); .filter(b => !b.includes('HEAD'));
// Cache the result (cache for 2 minutes)
repoCache.set(cacheKey, branchList, 2 * 60 * 1000);
return branchList;
} catch (error) { } catch (error) {
logger.error({ error, repoPath }, 'Error getting branches'); 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}`); 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 // Validate commit message
if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) {
throw new Error('Commit message is required'); throw new Error('Commit message is required');
@ -535,32 +764,19 @@ export class FileManager {
} }
try { try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`); // Use git worktree instead of cloning (much more efficient)
const { rm } = await import('fs/promises'); const workDir = await this.getWorktree(repoPath, branch, npub, repoName);
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir); const workGit: SimpleGit = simpleGit(workDir);
try {
await workGit.checkout([branch]);
} catch {
await workGit.checkout(['-b', branch]);
}
// Remove the file (use validated path) // Remove the file (use validated path)
const validatedPath = pathValidation.normalized || filePath; const validatedPath = pathValidation.normalized || filePath;
const fullFilePath = join(workDir, validatedPath); const fullFilePath = join(workDir, validatedPath);
// Additional security: ensure the resolved path is still within workDir // Additional security: ensure the resolved path is still within workDir
const resolvedPath = resolve(fullFilePath); // Use trailing slash to ensure directory boundary (prevents sibling directory attacks)
const resolvedWorkDir = resolve(workDir); const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir)) { const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) {
throw new Error('Path validation failed: resolved path outside work directory'); throw new Error('Path validation failed: resolved path outside work directory');
} }
@ -585,7 +801,7 @@ export class FileManager {
finalCommitMessage = signedMessage; finalCommitMessage = signedMessage;
} catch (err) { } catch (err) {
// Security: Sanitize error messages (never log private keys) // 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'); logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit');
// Continue without signature if signing fails // Continue without signature if signing fails
} }
@ -596,11 +812,11 @@ export class FileManager {
'--author': `${authorName} <${authorEmail}>` '--author': `${authorName} <${authorEmail}>`
}); });
// Push to bare repo // Push to bare repo (worktree is already connected)
await workGit.push(['origin', branch]); await workGit.push(['origin', branch]);
// Clean up // Clean up worktree
await rm(workDir, { recursive: true, force: true }); await this.removeWorktree(repoPath, workDir);
} catch (error) { } catch (error) {
logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); logger.error({ error, repoPath, filePath, npub }, 'Error deleting file');
throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`);
@ -616,6 +832,14 @@ export class FileManager {
branchName: string, branchName: string,
fromBranch: string = 'main' fromBranch: string = 'main'
): Promise<void> { ): 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); const repoPath = this.getRepoPath(npub, repoName);
if (!this.repoExists(npub, repoName)) { if (!this.repoExists(npub, repoName)) {
@ -623,29 +847,18 @@ export class FileManager {
} }
try { try {
const workDir = join(this.repoRoot, npub, `${repoName}.work`); // Use git worktree instead of cloning (much more efficient)
const { rm } = await import('fs/promises'); const workDir = await this.getWorktree(repoPath, fromBranch, npub, repoName);
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true });
}
const git: SimpleGit = simpleGit();
await git.clone(repoPath, workDir);
const workGit: SimpleGit = simpleGit(workDir); const workGit: SimpleGit = simpleGit(workDir);
// Checkout source branch
await workGit.checkout([fromBranch]);
// Create and checkout new branch // Create and checkout new branch
await workGit.checkout(['-b', branchName]); await workGit.checkout(['-b', branchName]);
// Push new branch // Push new branch
await workGit.push(['origin', branchName]); await workGit.push(['origin', branchName]);
// Clean up // Clean up worktree
await rm(workDir, { recursive: true, force: true }); await this.removeWorktree(repoPath, workDir);
} catch (error) { } catch (error) {
logger.error({ error, repoPath, branchName, npub }, 'Error creating branch'); logger.error({ error, repoPath, branchName, npub }, 'Error creating branch');
throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); 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 @@
/**
* 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 @@
* Handles repo provisioning, syncing, and NIP-34 integration * 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 { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import { spawn } from 'child_process';
import { promisify } from 'util';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.js'; import { GIT_DOMAIN } from '../../config.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js';
import simpleGit, { type SimpleGit } from 'simple-git'; import simpleGit, { type SimpleGit } from 'simple-git';
import logger from '../logger.js'; import logger from '../logger.js';
import { shouldUseTor, getTorProxy } from '../../utils/tor.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 { export interface RepoPath {
npub: string; npub: string;
@ -87,7 +130,9 @@ export class RepoManager {
// Create bare repository if it doesn't exist // Create bare repository if it doesn't exist
const isNewRepo = !repoExists; const isNewRepo = !repoExists;
if (isNewRepo) { 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 // Create verification file and self-transfer event in the repository
await this.createVerificationFile(repoPath.fullPath, event, selfTransferEvent); 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 * 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> { private getGitEnvForUrl(url: string): Record<string, string> {
const env: Record<string, string> = {}; // Whitelist only necessary environment variables for security
const env: Record<string, string> = {
// Copy process.env, filtering out undefined values PATH: process.env.PATH || '/usr/bin:/bin',
for (const [key, value] of Object.entries(process.env)) { HOME: process.env.HOME || '/tmp',
if (value !== undefined) { USER: process.env.USER || 'git',
env[key] = value; 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)) { 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<void> { private async syncFromSingleRemote(repoPath: string, url: string, index: number): Promise<void> {
for (const url of remoteUrls) { const remoteName = `remote-${index}`;
try { const git = simpleGit(repoPath);
// Add remote if not exists const gitEnv = this.getGitEnvForUrl(url);
const remoteName = `remote-${remoteUrls.indexOf(url)}`;
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`);
// Get environment with Tor proxy if needed try {
const gitEnv = this.getGitEnvForUrl(url); // 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 // Configure git proxy for this remote if it's a .onion address
if (shouldUseTor(url)) { if (shouldUseTor(url)) {
const proxy = getTorProxy(); const proxy = getTorProxy();
if (proxy) { if (proxy) {
// Set git config for this specific URL pattern try {
try { // Use simple-git to set config (safer than exec)
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 { } catch {
// Config might fail, continue anyway // Config might fail, continue anyway
}
} }
} }
}
// Fetch from remote with appropriate environment // Fetch from remote with appropriate environment
await execAsync(`cd "${repoPath}" && git fetch ${remoteName} --all`, { env: gitEnv }); // Use spawn with proper argument arrays for security
await execGitWithEnv(repoPath, ['fetch', remoteName, '--all'], gitEnv);
// Update all branches // Update remote head
await execAsync(`cd "${repoPath}" && git remote set-head ${remoteName} -a`, { env: gitEnv }); try {
} catch (error) { await execGitWithEnv(repoPath, ['remote', 'set-head', remoteName, '-a'], gitEnv);
logger.error({ error, url, repoPath }, 'Failed to sync from remote'); } catch {
// Continue with other remotes // 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> { async syncFromRemotes(repoPath: string, remoteUrls: string[]): Promise<void> {
for (const url of remoteUrls) { if (remoteUrls.length === 0) return;
try {
const remoteName = `remote-${remoteUrls.indexOf(url)}`; // Sync all remotes in parallel for better performance
await execAsync(`cd "${repoPath}" && git remote add ${remoteName} "${url}" || true`); 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 let lastError: Error | null = null;
const gitEnv = this.getGitEnvForUrl(url);
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 // Configure git proxy for this remote if it's a .onion address
if (shouldUseTor(url)) { if (shouldUseTor(url)) {
const proxy = getTorProxy(); const proxy = getTorProxy();
if (proxy) { if (proxy) {
// Set git config for this specific URL pattern
try { 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 { } catch {
// Config might fail, continue anyway // Config might fail, continue anyway
} }
} }
} }
// Push to remote with appropriate environment // Check if force push is safe
await execAsync(`cd "${repoPath}" && git push ${remoteName} --all --force`, { env: gitEnv }); const allowForce = process.env.ALLOW_FORCE_PUSH === 'true' || await this.canSafelyForcePush(repoPath, remoteName);
await execAsync(`cd "${repoPath}" && git push ${remoteName} --tags --force`, { env: gitEnv }); 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) { } catch (error) {
logger.error({ error, url, repoPath }, 'Failed to sync to remote'); lastError = error instanceof Error ? error : new Error(String(error));
// Continue with other remotes 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 {
message = message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]'); message = message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]');
message = message.replace(/[0-9a-f]{64}/g, '[REDACTED]'); // 64-char hex keys 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 // Truncate long pubkeys in error messages
message = message.replace(/(npub[a-z0-9]{50,})/gi, (match) => truncateNpub(match)); message = message.replace(/(npub[a-z0-9]{50,})/gi, (match) => truncateNpub(match));
message = message.replace(/([0-9a-f]{50,})/g, (match) => truncatePubkey(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); 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 * 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';
import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js'; import { BranchProtectionService } from '$lib/services/nostr/branch-protection-service.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { auditLogger } from '$lib/services/security/audit-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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot); const repoManager = new RepoManager(repoRoot);
@ -133,9 +134,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
// Get repository path with security validation // Get repository path with security validation
const repoPath = join(repoRoot, npub, `${repoName}.git`); const repoPath = join(repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal // Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath); // Normalize paths to handle Windows/Unix differences
const resolvedRoot = resolve(repoRoot); const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { 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'); return error(403, 'Invalid repository path');
} }
if (!repoManager.repoExists(repoPath)) { if (!repoManager.repoExists(repoPath)) {
@ -207,10 +210,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`; const pathInfo = gitPath ? `/${gitPath}` : `/info/refs`;
// Set up environment variables for git-http-backend // Set up environment variables for git-http-backend
// Security: Use the specific repository path, not the entire repoRoot // Security: Whitelist only necessary environment variables
// This limits git-http-backend's view to only this repository // This prevents leaking secrets from process.env
const envVars = { const envVars: Record<string, string> = {
...process.env, 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_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_HTTP_EXPORT_ALL: '1', GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method, REQUEST_METHOD: request.method,
@ -221,6 +228,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '', 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 // 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 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'; const operation = service === 'git-upload-pack' || gitPath === 'git-upload-pack' ? 'fetch' : 'clone';
@ -239,6 +251,18 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
gitProcess.kill('SIGTERM'); 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( auditLogger.logRepoAccess(
originalOwnerPubkey, originalOwnerPubkey,
clientIp, clientIp,
@ -287,7 +311,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
} }
if (code !== 0 && chunks.length === 0) { 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; return;
} }
@ -323,7 +348,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
'failure', 'failure',
`Process error: ${err.message}` `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 // Get repository path with security validation
const repoPath = join(repoRoot, npub, `${repoName}.git`); const repoPath = join(repoRoot, npub, `${repoName}.git`);
// Security: Ensure the resolved path is within repoRoot to prevent path traversal // Security: Ensure the resolved path is within repoRoot to prevent path traversal
const resolvedPath = resolve(repoPath); // Normalize paths to handle Windows/Unix differences
const resolvedRoot = resolve(repoRoot); const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/') && resolvedPath !== resolvedRoot) { 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'); return error(403, 'Invalid repository path');
} }
if (!repoManager.repoExists(repoPath)) { if (!repoManager.repoExists(repoPath)) {
@ -407,6 +435,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
const branchMatch = bodyText.match(/refs\/heads\/([^\s\n]+)/); const branchMatch = bodyText.match(/refs\/heads\/([^\s\n]+)/);
targetBranch = branchMatch ? branchMatch[1] : 'main'; // Default to main if can't determine 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( const protectionCheck = await branchProtectionService.canPushToBranch(
authResult.pubkey || '', authResult.pubkey || '',
currentOwnerPubkey, currentOwnerPubkey,
@ -421,7 +454,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
} catch (error) { } catch (error) {
// If we can't check protection, log but don't block (fail open for now) // If we can't check protection, log but don't block (fail open for now)
// Security: Sanitize error messages // 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'); 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}` : `/`; const pathInfo = gitPath ? `/${gitPath}` : `/`;
// Set up environment variables for git-http-backend // Set up environment variables for git-http-backend
// Security: Use the specific repository path, not the entire repoRoot // Security: Whitelist only necessary environment variables
// This limits git-http-backend's view to only this repository // This prevents leaking secrets from process.env
const envVars = { const envVars: Record<string, string> = {
...process.env, 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_PROJECT_ROOT: resolve(repoPath), // Use specific repo path, not repoRoot
GIT_HTTP_EXPORT_ALL: '1', GIT_HTTP_EXPORT_ALL: '1',
REQUEST_METHOD: request.method, REQUEST_METHOD: request.method,
@ -452,6 +489,11 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
HTTP_USER_AGENT: request.headers.get('User-Agent') || '', 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 // 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 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'; 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 }) => {
timeoutId = setTimeout(() => { timeoutId = setTimeout(() => {
gitProcess.kill('SIGTERM'); 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( auditLogger.logRepoAccess(
currentOwnerPubkey, currentOwnerPubkey,
clientIp, clientIp,
@ -540,14 +594,15 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
} }
} catch (err) { } catch (err) {
// Security: Sanitize error messages // 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'); logger.error({ error: sanitizedErr, npub, repoName }, 'Failed to sync to remotes');
// Don't fail the request if sync fails // Don't fail the request if sync fails
} }
} }
if (code !== 0 && chunks.length === 0) { 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; return;
} }
@ -579,7 +634,8 @@ export const POST: RequestHandler = async ({ params, url, request }) => {
'failure', 'failure',
`Process error: ${err.message}` `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