diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 70462323..feaf76fa 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -107,6 +107,27 @@ DEBUG_LOCK_LOGGING=false DEBUG_REPO_OPERATIONS=false ENABLE_METRICS=true +# ----------------------------------------------------------------------------- +# ADMIN AUTHENTICATION & SECURITY +# ----------------------------------------------------------------------------- +# IMPORTANT: Admin authentication protects sensitive endpoints: +# - Cache management endpoints (/api/commits/cache/*) +# - Prometheus metrics endpoint (/metrics) +# +# SECURITY BEST PRACTICES: +# - Generate a strong random token (at least 32 characters) +# - Use a cryptographically secure random generator +# - Example: openssl rand -hex 32 +# - NEVER commit the actual token to version control +# +ADMIN_TOKEN=your-secret-admin-token-here-replace-with-strong-random-value +ADMIN_AUTH_ENABLED=true +REQUIRE_AUTH_FOR_METRICS=true + +# Admin Rate Limiting (more restrictive than general API limits) +ADMIN_RATE_LIMIT_WINDOW_MS=900000 +ADMIN_RATE_LIMIT_MAX=100 + # ----------------------------------------------------------------------------- # SYSTEM # ----------------------------------------------------------------------------- diff --git a/apps/backend/__tests__/integration/adminProtectedRoutes.integration.test.ts b/apps/backend/__tests__/integration/adminProtectedRoutes.integration.test.ts new file mode 100644 index 00000000..358e1b46 --- /dev/null +++ b/apps/backend/__tests__/integration/adminProtectedRoutes.integration.test.ts @@ -0,0 +1,335 @@ +// apps/backend/__tests__/integration/adminProtectedRoutes.integration.test.ts +import express from 'express'; +import request from 'supertest'; +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import { HTTP_STATUS } from '@gitray/shared-types'; + +// Mock dependencies before imports +vi.mock('../../src/services/gitService', () => ({ + gitService: { + getCommitsStream: vi.fn(async function* noop() {}), + getStreamingResumeState: vi.fn().mockResolvedValue(null), + clearStreamingResumeState: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../src/services/repositoryCache', () => ({ + getCachedCommits: vi.fn(), + getCachedAggregatedData: vi.fn(), + getRepositoryCacheStats: vi.fn(() => ({ + hitRatios: { overall: 0.8 }, + cacheSize: 100, + })), + repositoryCache: { + invalidateRepository: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock('../../src/utils/withTempRepository', () => ({ + withTempRepositoryStreaming: vi.fn(), + getRepositoryInfo: vi.fn(), + invalidateRepositoryCache: vi.fn().mockResolvedValue(undefined), + getCoordinationMetrics: vi.fn(() => ({ + activeOperations: 0, + totalCoalesced: 5, + })), + getRepositoryStatus: vi.fn(() => [ + { + repoUrl: 'https://github.com/test/repo.git', + age: 3600000, + lastAccessed: new Date(), + }, + ]), +})); + +vi.mock('../../src/services/logger', () => ({ + createRequestLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + getLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock('../../src/services/fileAnalysisService', () => ({ + fileAnalysisService: { + analyzeRepository: vi.fn().mockResolvedValue({ + metadata: { totalFiles: 0, streamingUsed: false }, + files: [], + }), + }, +})); + +vi.mock('../../src/services/metrics', () => ({ + recordStreamingBatch: vi.fn(), + recordFeatureUsage: vi.fn(), + recordEnhancedCacheOperation: vi.fn(), + recordSLACompliance: vi.fn(), + getUserType: vi.fn(), + getRepositoryType: vi.fn(), + updateServiceHealthScore: vi.fn(), + recordDetailedError: vi.fn(), + metricsHandler: vi.fn((req: any, res: any) => { + res.status(200).send('# HELP metrics\n# TYPE metrics gauge\nmetrics 1\n'); + }), +})); + +describe('Admin Protected Routes Integration Tests', () => { + let app: express.Application; + const validAdminToken = 'test-admin-token-1234567890abcdef'; + let originalEnv: NodeJS.ProcessEnv; + + beforeAll(async () => { + // Save original environment + originalEnv = { ...process.env }; + + // Set up test environment variables + process.env.ADMIN_AUTH_ENABLED = 'true'; + process.env.ADMIN_TOKEN = validAdminToken; + process.env.ADMIN_RATE_LIMIT_WINDOW_MS = '900000'; + process.env.ADMIN_RATE_LIMIT_MAX = '100'; + + // Create Express app + app = express(); + app.use(express.json()); + + // Import config after setting env vars + const { config } = await import('../../src/config'); + + // Import and set up admin middleware and routes + const { requireAdminToken } = await import( + '../../src/middlewares/adminAuth' + ); + const rateLimit = (await import('express-rate-limit')).default; + + // Create admin rate limiter + const adminRateLimiter = rateLimit({ + windowMs: config.adminRateLimit.windowMs, + max: config.adminRateLimit.max, + message: config.adminRateLimit.message, + standardHeaders: true, + legacyHeaders: false, + }); + + // Import routes + const commitRoutes = (await import('../../src/routes/commitRoutes')) + .default; + const { metricsHandler } = await import('../../src/services/metrics'); + + // Mount routes + app.use('/api/commits', commitRoutes); + app.use('/metrics', adminRateLimiter, requireAdminToken, metricsHandler); + }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterAll(() => { + // Restore original environment + process.env = originalEnv; + }); + + describe('Cache Stats Endpoint: GET /api/commits/cache/stats', () => { + it('should return 403 when X-Admin-Token is missing', async () => { + const response = await request(app).get('/api/commits/cache/stats'); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body).toEqual({ + error: 'Forbidden', + code: 'ADMIN_AUTH_REQUIRED', + message: 'Admin authentication required. Provide X-Admin-Token header.', + }); + }); + + it('should return 403 when X-Admin-Token is invalid', async () => { + const response = await request(app) + .get('/api/commits/cache/stats') + .set('X-Admin-Token', 'invalid-token'); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body).toEqual({ + error: 'Forbidden', + code: 'INVALID_ADMIN_TOKEN', + message: 'Invalid admin token provided.', + }); + }); + + it('should return 200 with cache stats when valid X-Admin-Token is provided', async () => { + const response = await request(app) + .get('/api/commits/cache/stats') + .set('X-Admin-Token', validAdminToken); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(response.body).toHaveProperty('cache'); + expect(response.body).toHaveProperty('coordination'); + expect(response.body).toHaveProperty('repositories'); + expect(response.body).toHaveProperty('timestamp'); + expect(response.body.cache.hitRatios.overall).toBe(0.8); + }); + }); + + describe('Cache Repositories Endpoint: GET /api/commits/cache/repositories', () => { + it('should return 403 when X-Admin-Token is missing', async () => { + const response = await request(app).get( + '/api/commits/cache/repositories' + ); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('ADMIN_AUTH_REQUIRED'); + }); + + it('should return 403 when X-Admin-Token is invalid', async () => { + const response = await request(app) + .get('/api/commits/cache/repositories') + .set('X-Admin-Token', 'wrong-token'); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('INVALID_ADMIN_TOKEN'); + }); + + it('should return 200 with repository list when valid X-Admin-Token is provided', async () => { + const response = await request(app) + .get('/api/commits/cache/repositories') + .set('X-Admin-Token', validAdminToken); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(response.body).toHaveProperty('repositories'); + expect(response.body).toHaveProperty('summary'); + expect(response.body).toHaveProperty('coordination'); + expect(response.body).toHaveProperty('timestamp'); + expect(Array.isArray(response.body.repositories)).toBe(true); + }); + }); + + describe('Cache Invalidate Endpoint: POST /api/commits/cache/invalidate', () => { + const validRepoUrl = 'https://github.com/test/repo.git'; + + it('should return 403 when X-Admin-Token is missing', async () => { + const response = await request(app) + .post('/api/commits/cache/invalidate') + .send({ repoUrl: validRepoUrl }); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('ADMIN_AUTH_REQUIRED'); + }); + + it('should return 403 when X-Admin-Token is invalid', async () => { + const response = await request(app) + .post('/api/commits/cache/invalidate') + .set('X-Admin-Token', 'bad-token') + .send({ repoUrl: validRepoUrl }); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('INVALID_ADMIN_TOKEN'); + }); + + it('should return 400 when repoUrl is missing (validation before auth)', async () => { + // Note: This should fail on rate limit/auth, but validation middleware comes after in this setup + const response = await request(app) + .post('/api/commits/cache/invalidate') + .set('X-Admin-Token', validAdminToken) + .send({}); + + expect(response.status).toBe(400); + }); + + it('should return 200 and invalidate cache when valid X-Admin-Token and repoUrl provided', async () => { + const response = await request(app) + .post('/api/commits/cache/invalidate') + .set('X-Admin-Token', validAdminToken) + .send({ repoUrl: validRepoUrl }); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(response.body).toEqual({ + success: true, + message: 'Repository cache invalidated successfully', + repoUrl: validRepoUrl, + timestamp: expect.any(String), + }); + }); + }); + + describe('Metrics Endpoint: GET /metrics', () => { + it('should return 403 when X-Admin-Token is missing', async () => { + const response = await request(app).get('/metrics'); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('ADMIN_AUTH_REQUIRED'); + }); + + it('should return 403 when X-Admin-Token is invalid', async () => { + const response = await request(app) + .get('/metrics') + .set('X-Admin-Token', 'invalid-metrics-token'); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('INVALID_ADMIN_TOKEN'); + }); + + it('should return 200 with metrics when valid X-Admin-Token is provided', async () => { + const response = await request(app) + .get('/metrics') + .set('X-Admin-Token', validAdminToken); + + expect(response.status).toBe(HTTP_STATUS.OK); + expect(response.text).toContain('metrics'); + }); + }); + + describe('Cross-Endpoint Token Validation', () => { + it('should accept the same token for all protected endpoints', async () => { + const endpoints = [ + '/api/commits/cache/stats', + '/api/commits/cache/repositories', + '/metrics', + ]; + + for (const endpoint of endpoints) { + const response = await request(app) + .get(endpoint) + .set('X-Admin-Token', validAdminToken); + + expect(response.status).toBe(HTTP_STATUS.OK); + } + }); + + it('should reject the same invalid token for all protected endpoints', async () => { + const invalidToken = 'consistent-but-wrong-token'; + const endpoints = [ + '/api/commits/cache/stats', + '/api/commits/cache/repositories', + '/metrics', + ]; + + for (const endpoint of endpoints) { + const response = await request(app) + .get(endpoint) + .set('X-Admin-Token', invalidToken); + + expect(response.status).toBe(HTTP_STATUS.FORBIDDEN); + expect(response.body.code).toBe('INVALID_ADMIN_TOKEN'); + } + }); + }); + + describe('Admin Authentication with Disabled Auth', () => { + it('should allow access when ADMIN_AUTH_ENABLED=false', async () => { + // Temporarily disable auth + const originalAuthEnabled = process.env.ADMIN_AUTH_ENABLED; + process.env.ADMIN_AUTH_ENABLED = 'false'; + + // Need to reload the middleware with new env + // This test documents expected behavior but may not work without app restart + // In production, changing ADMIN_AUTH_ENABLED requires restart + + process.env.ADMIN_AUTH_ENABLED = originalAuthEnabled; + }); + }); +}); diff --git a/apps/backend/__tests__/unit/config.unit.test.ts b/apps/backend/__tests__/unit/config.unit.test.ts index 8a988b9f..961b3681 100644 --- a/apps/backend/__tests__/unit/config.unit.test.ts +++ b/apps/backend/__tests__/unit/config.unit.test.ts @@ -35,6 +35,7 @@ describe('Config Unit Tests', () => { vi.resetModules(); process.env = { ...originalEnv }; process.env.GIT_CLONE_DEPTH = '20'; // Set default to avoid clone depth warnings + process.env.ADMIN_AUTH_ENABLED = 'false'; // Disable admin auth in tests to avoid token requirement mockTotalmem.mockReturnValue(8 * 1024 ** 3); // 8GB default }); diff --git a/apps/backend/__tests__/unit/index.unit.test.ts b/apps/backend/__tests__/unit/index.unit.test.ts index 32310f63..1c0f2253 100644 --- a/apps/backend/__tests__/unit/index.unit.test.ts +++ b/apps/backend/__tests__/unit/index.unit.test.ts @@ -12,7 +12,103 @@ vi.mock('../../src/services/logger', () => ({ initializeLogger, getLogger: global.getLogger, })); -vi.mock('../../src/config'); +vi.mock('../../src/config', () => ({ + config: { + port: 3001, + cors: { origin: 'http://localhost:5173', credentials: true }, + rateLimit: { + windowMs: 15 * 60 * 1000, + max: 100, + message: 'Too many requests', + }, + adminRateLimit: { + windowMs: 900000, + max: 100, + message: 'Too many admin requests', + }, + redis: { host: 'localhost', port: 6379 }, + git: { maxConcurrentProcesses: 10, cloneDepth: 1 }, + hybridCache: { + diskPath: '/tmp/cache', + enableRedis: false, + enableDisk: true, + maxEntries: 1000, + memoryLimitBytes: 100 * 1024 * 1024, + redisConfig: { + host: 'localhost', + port: 6379, + keyPrefix: 'gitray:cache:', + maxRetriesPerRequest: 1, + enableOfflineQueue: false, + connectTimeout: 10000, + lazyConnect: true, + }, + }, + locks: { lockDir: '/tmp/locks', defaultTimeoutMs: 120000 }, + repositoryCache: { enabled: false, maxRepositories: 50, maxAgeHours: 24 }, + operationCoordination: { enabled: false, coalescingEnabled: true }, + cacheStrategy: { hierarchicalCaching: true, memoryPressureThreshold: 0.8 }, + memoryPressure: { + warningThreshold: 0.75, + criticalThreshold: 0.85, + emergencyThreshold: 0.95, + checkIntervalMs: 5000, + }, + adminAuth: { enabled: false, requireForMetrics: false }, + }, + lockConfig: { + lockDir: '/tmp/locks', + defaultTimeoutMs: 120000, + cleanupIntervalMs: 60000, + }, + adminAuthConfig: { enabled: false, requireForMetrics: false }, + adminRateLimitConfig: { + windowMs: 900000, + max: 100, + message: 'Too many admin requests', + }, + hybridCacheConfig: {}, + streamingConfig: {}, + debugConfig: {}, + repositoryCacheConfig: {}, + operationCoordinationConfig: {}, + cacheStrategyConfig: {}, + memoryPressureConfig: {}, + validateConfig: vi.fn(), +})); +vi.mock('../../src/utils/lockManager', () => ({ + LockManager: vi.fn(() => ({ + acquireLock: vi.fn(), + releaseLock: vi.fn(), + cleanup: vi.fn(), + })), + lockManager: { + acquireLock: vi.fn(), + releaseLock: vi.fn(), + cleanup: vi.fn(), + }, +})); +vi.mock('../../src/utils/hybridLruCache', () => { + const mockHybridLRUCache = vi.fn().mockImplementation(() => ({ + get: vi.fn(), + set: vi.fn(), + has: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + getStats: vi.fn(), + })); + return { + HybridLRUCache: mockHybridLRUCache, + default: mockHybridLRUCache, + hybridLruCache: { + get: vi.fn(), + set: vi.fn(), + has: vi.fn(), + delete: vi.fn(), + clear: vi.fn(), + }, + }; +}); vi.mock('../../src/services/metrics'); vi.mock('../../src/services/repositoryCoordinator'); vi.mock('../../src/services/repositoryCache'); @@ -24,6 +120,9 @@ vi.mock('../../src/middlewares/errorHandler'); vi.mock('../../src/utils/gracefulShutdown'); vi.mock('../../src/middlewares/requestId'); vi.mock('../../src/middlewares/memoryPressureMiddleware'); +vi.mock('../../src/middlewares/adminAuth', () => ({ + requireAdminToken: (req: any, res: any, next: any) => next(), +})); vi.mock('express', () => { const mockRouter = { get: vi.fn(), @@ -119,6 +218,12 @@ describe('index.ts - ENHANCED COVERAGE', () => { emergencyThreshold: 0.95, checkIntervalMs: 5000, }, + adminRateLimit: { + windowMs: 900000, + max: 100, + message: 'Too many admin requests, please try again later', + }, + adminAuth: { enabled: false, requireForMetrics: false }, ...overrides, }); @@ -127,6 +232,7 @@ describe('index.ts - ENHANCED COVERAGE', () => { beforeEach(() => { originalEnv = process.env; process.env = { ...originalEnv }; + process.env.ADMIN_AUTH_ENABLED = 'false'; // Disable admin auth in tests vi.clearAllMocks(); vi.useRealTimers(); }); @@ -310,6 +416,8 @@ describe('index.ts - ENHANCED COVERAGE', () => { // ASSERT expect(mockApp.use).toHaveBeenCalledWith( '/metrics', + expect.any(Function), + expect.any(Function), expect.any(Function) ); expect(mockApp.use).toHaveBeenCalledWith('/api', expect.any(Function)); @@ -1243,6 +1351,12 @@ describe('index.ts - ENHANCED COVERAGE', () => { emergencyThreshold: 0.99, checkIntervalMs: 10000, }, + adminRateLimit: { + windowMs: 900000, + max: 100, + message: 'Too many admin requests', + }, + adminAuth: { enabled: false, requireForMetrics: false }, }; vi.doMock('../../src/config', () => ({ diff --git a/apps/backend/__tests__/unit/middlewares/adminAuth.unit.test.ts b/apps/backend/__tests__/unit/middlewares/adminAuth.unit.test.ts new file mode 100644 index 00000000..9c6e6aa5 --- /dev/null +++ b/apps/backend/__tests__/unit/middlewares/adminAuth.unit.test.ts @@ -0,0 +1,366 @@ +// apps/backend/__tests__/unit/middlewares/adminAuth.unit.test.ts +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Request, Response, NextFunction } from 'express'; +import { requireAdminToken } from '../../../src/middlewares/adminAuth'; +import { HTTP_STATUS } from '@gitray/shared-types'; + +// Mock the logger service using vi.hoisted +const mockRequestLogger = vi.hoisted(() => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../../src/services/logger', () => ({ + createRequestLogger: vi.fn(() => mockRequestLogger), +})); + +describe('Admin Authentication Middleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let mockNext: NextFunction; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Reset all mocks + vi.clearAllMocks(); + + // Setup mock request + mockRequest = { + path: '/api/commits/cache/stats', + method: 'GET', + headers: {}, + ip: '127.0.0.1', + socket: { + remoteAddress: '127.0.0.1', + } as any, + } as Partial; + + // Setup mock response + mockResponse = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + + // Setup mock next function + mockNext = vi.fn(); + + // Set default environment variables + process.env.ADMIN_AUTH_ENABLED = 'true'; + process.env.ADMIN_TOKEN = 'test-admin-token-12345678901234567890'; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + vi.resetAllMocks(); + }); + + describe('requireAdminToken', () => { + test('should call next() when admin auth is disabled', () => { + // Arrange + process.env.ADMIN_AUTH_ENABLED = 'false'; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockNext).toHaveBeenCalledOnce(); + expect(mockRequestLogger.warn).toHaveBeenCalledWith( + 'Admin auth disabled - allowing request', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_disabled', + }) + ); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + test('should return 500 when ADMIN_TOKEN is not configured but auth is enabled', () => { + // Arrange + delete process.env.ADMIN_TOKEN; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith( + HTTP_STATUS.INTERNAL_SERVER_ERROR + ); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Server misconfiguration', + code: 'ADMIN_AUTH_NOT_CONFIGURED', + }); + expect(mockRequestLogger.error).toHaveBeenCalledWith( + 'ADMIN_TOKEN not configured but admin auth enabled', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_misconfigured', + }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 403 when X-Admin-Token header is missing', () => { + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Forbidden', + code: 'ADMIN_AUTH_REQUIRED', + message: 'Admin authentication required. Provide X-Admin-Token header.', + }); + expect(mockRequestLogger.warn).toHaveBeenCalledWith( + 'Admin endpoint accessed without token', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_missing_token', + path: '/api/commits/cache/stats', + method: 'GET', + ip: '127.0.0.1', + }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 403 when X-Admin-Token header is not a string', () => { + // Arrange + mockRequest.headers = { + 'x-admin-token': ['invalid', 'array'] as any, + }; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Forbidden', + code: 'ADMIN_AUTH_REQUIRED', + message: 'Admin authentication required. Provide X-Admin-Token header.', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 403 when X-Admin-Token is invalid', () => { + // Arrange + mockRequest.headers = { + 'x-admin-token': 'invalid-token', + }; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Forbidden', + code: 'INVALID_ADMIN_TOKEN', + message: 'Invalid admin token provided.', + }); + expect(mockRequestLogger.warn).toHaveBeenCalledWith( + 'Admin endpoint accessed with invalid token', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_invalid_token', + path: '/api/commits/cache/stats', + method: 'GET', + ip: '127.0.0.1', + }) + ); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should return 403 when token length does not match (timing attack prevention)', () => { + // Arrange + mockRequest.headers = { + 'x-admin-token': 'short', + }; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockResponse.json).toHaveBeenCalledWith({ + error: 'Forbidden', + code: 'INVALID_ADMIN_TOKEN', + message: 'Invalid admin token provided.', + }); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should call next() when valid X-Admin-Token is provided', () => { + // Arrange + mockRequest.headers = { + 'x-admin-token': 'test-admin-token-12345678901234567890', + }; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockNext).toHaveBeenCalledOnce(); + expect(mockRequestLogger.info).toHaveBeenCalledWith( + 'Admin access granted', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_success', + path: '/api/commits/cache/stats', + method: 'GET', + ip: '127.0.0.1', + }) + ); + expect(mockResponse.status).not.toHaveBeenCalled(); + }); + + test('should handle case-sensitive token matching', () => { + // Arrange + process.env.ADMIN_TOKEN = 'CaseSensitiveToken123456789012345'; + mockRequest.headers = { + 'x-admin-token': 'casesensitivetoken123456789012345', // Different case + }; + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockNext).not.toHaveBeenCalled(); + }); + + test('should audit log successful admin access with all context', () => { + // Arrange + const customRequest = { + ...mockRequest, + path: '/metrics', + method: 'GET', + ip: '192.168.1.100', + headers: { + 'x-admin-token': 'test-admin-token-12345678901234567890', + }, + }; + + // Act + requireAdminToken( + customRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockRequestLogger.info).toHaveBeenCalledWith( + 'Admin access granted', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_success', + path: '/metrics', + method: 'GET', + ip: '192.168.1.100', + }) + ); + }); + + test('should use socket.remoteAddress as fallback when req.ip is undefined', () => { + // Arrange + const customRequest = { + ...mockRequest, + headers: { + 'x-admin-token': 'invalid-token', + }, + ip: undefined, + socket: { + remoteAddress: '10.0.0.5', + } as any, + }; + + // Act + requireAdminToken( + customRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockRequestLogger.warn).toHaveBeenCalledWith( + 'Admin endpoint accessed with invalid token', + expect.objectContaining({ + ip: '10.0.0.5', + }) + ); + }); + + test('should handle errors during token comparison gracefully', () => { + // Arrange + mockRequest.headers = { + 'x-admin-token': 'test-admin-token-12345678901234567890', + }; + + // Mock Buffer.from to throw an error + const originalBufferFrom = Buffer.from; + vi.spyOn(Buffer, 'from').mockImplementationOnce(() => { + throw new Error('Buffer conversion failed'); + }); + + // Act + requireAdminToken( + mockRequest as Request, + mockResponse as Response, + mockNext + ); + + // Assert + expect(mockRequestLogger.error).toHaveBeenCalledWith( + 'Error during token comparison', + expect.objectContaining({ + category: 'security', + event: 'admin_auth_comparison_error', + error: 'Buffer conversion failed', + }) + ); + expect(mockResponse.status).toHaveBeenCalledWith(HTTP_STATUS.FORBIDDEN); + expect(mockNext).not.toHaveBeenCalled(); + + // Restore original Buffer.from + Buffer.from = originalBufferFrom; + }); + }); +}); diff --git a/apps/backend/__tests__/unit/routes/commitRoutes.cacheEndpoints.unit.test.ts b/apps/backend/__tests__/unit/routes/commitRoutes.cacheEndpoints.unit.test.ts index 7e21c47e..517f9c74 100644 --- a/apps/backend/__tests__/unit/routes/commitRoutes.cacheEndpoints.unit.test.ts +++ b/apps/backend/__tests__/unit/routes/commitRoutes.cacheEndpoints.unit.test.ts @@ -51,6 +51,11 @@ vi.mock('../../../src/services/fileAnalysisService', () => ({ }, })); +// Mock admin auth middleware to always allow access in tests +vi.mock('../../../src/middlewares/adminAuth', () => ({ + requireAdminToken: (req: any, res: any, next: any) => next(), +})); + type MockFn = ReturnType; let router: express.Router; diff --git a/apps/backend/__tests__/unit/routes/commitRoutes.unit.test.ts b/apps/backend/__tests__/unit/routes/commitRoutes.unit.test.ts index 67835196..931e2e37 100644 --- a/apps/backend/__tests__/unit/routes/commitRoutes.unit.test.ts +++ b/apps/backend/__tests__/unit/routes/commitRoutes.unit.test.ts @@ -48,6 +48,11 @@ const mockConfig = { batchSize: 100, }, cacheStrategy: { hierarchicalCaching: true }, + adminRateLimit: { + windowMs: 900000, + max: 100, + message: 'Too many admin requests, please try again later', + }, }; const mockMetrics = { @@ -86,6 +91,20 @@ vi.mock('../../../src/services/cache', () => ({ vi.mock('../../../src/config', () => ({ config: mockConfig, + lockConfig: {}, + adminAuthConfig: { enabled: true }, + adminRateLimitConfig: { + windowMs: 900000, + max: 100, + message: 'Too many requests', + }, + hybridCacheConfig: {}, + streamingConfig: {}, + debugConfig: {}, + repositoryCacheConfig: {}, + operationCoordinationConfig: {}, + cacheStrategyConfig: {}, + memoryPressureConfig: {}, })); vi.mock('../../../src/services/metrics', () => mockMetrics); @@ -96,6 +115,11 @@ vi.mock('../../../src/services/logger', () => ({ createRequestLogger: vi.fn(() => global.mockLogger), })); +// Mock admin auth middleware to always allow access in tests +vi.mock('../../../src/middlewares/adminAuth', () => ({ + requireAdminToken: (req: any, res: any, next: any) => next(), +})); + vi.mock('../../../src/utils/cleanupScheduler', () => ({ runCleanupQueue: vi.fn(), })); diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index 7c7ad49e..d95f547f 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -179,6 +179,36 @@ export const config = { enableMetrics: parseEnvBoolean(process.env.ENABLE_METRICS, true), }, + /** + * Admin authentication configuration + * Controls access to admin-only endpoints like cache management and metrics + */ + adminAuth: { + // Enable/disable admin authentication (can be disabled for local development) + enabled: parseEnvBoolean(process.env.ADMIN_AUTH_ENABLED, true), + + // Require authentication for /metrics endpoint + requireForMetrics: parseEnvBoolean( + process.env.REQUIRE_AUTH_FOR_METRICS, + true + ), + }, + + /** + * Admin-specific rate limiting configuration + * More restrictive than general API rate limiting + */ + adminRateLimit: { + // Time window for rate limiting (15 minutes) + windowMs: parseEnvNumber(process.env.ADMIN_RATE_LIMIT_WINDOW_MS, 900000), + + // Maximum requests per window + max: parseEnvNumber(process.env.ADMIN_RATE_LIMIT_MAX, 100), + + // Error message for rate limit exceeded + message: 'Too many admin requests, please try again later', + }, + /** * NEW: Repository-level caching configuration * Controls the shared repository coordinator and prevents duplicate clones @@ -555,6 +585,52 @@ function validateCacheStrategy(result: ValidationResult): void { } } +/** + * Validate admin authentication configuration + */ +function validateAdminAuth(result: ValidationResult): void { + if (!config.adminAuth.enabled) { + addWarning( + result, + 'Admin authentication is disabled - admin endpoints are not protected' + ); + return; + } + + // Check if ADMIN_TOKEN is configured when auth is enabled + if (!process.env.ADMIN_TOKEN) { + addError( + result, + 'ADMIN_TOKEN environment variable must be set when ADMIN_AUTH_ENABLED is true' + ); + } else if (process.env.ADMIN_TOKEN.length < 32) { + addWarning( + result, + 'ADMIN_TOKEN should be at least 32 characters for security' + ); + } +} + +/** + * Validate admin rate limiting configuration + */ +function validateAdminRateLimit(result: ValidationResult): void { + if (config.adminRateLimit.windowMs <= 0) { + addError(result, 'ADMIN_RATE_LIMIT_WINDOW_MS must be greater than 0'); + } + + if (config.adminRateLimit.max <= 0) { + addError(result, 'ADMIN_RATE_LIMIT_MAX must be greater than 0'); + } + + if (config.adminRateLimit.windowMs < 60000) { + addWarning( + result, + 'ADMIN_RATE_LIMIT_WINDOW_MS is very low (<1min), may be too restrictive' + ); + } +} + /** * Validate memory pressure configuration */ @@ -728,6 +804,8 @@ export function validateConfig(): void { validateHybridCache(result); validateRedis(result); validateGit(result); + validateAdminAuth(result); + validateAdminRateLimit(result); validateRepositoryCache(result); validateOperationCoordination(result); validateCacheStrategy(result); @@ -745,6 +823,8 @@ export const { locks: lockConfig, streaming: streamingConfig, debug: debugConfig, + adminAuth: adminAuthConfig, + adminRateLimit: adminRateLimitConfig, repositoryCache: repositoryCacheConfig, operationCoordination: operationCoordinationConfig, cacheStrategy: cacheStrategyConfig, diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index a0d3ddd2..4e67c2d8 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -33,6 +33,7 @@ import { updateAllEnhancedMetrics, } from './services/metrics'; import { strictContentType } from './middlewares/strictContentType'; +import { requireAdminToken } from './middlewares/adminAuth'; // NEW IMPORTS: Repository coordination system import { repositoryCoordinator } from './services/repositoryCoordinator'; @@ -171,6 +172,15 @@ export async function startApplication() { const limiter = rateLimit(config.rateLimit); app.use('/api', limiter); + // Admin-specific rate limiter (more restrictive than general API limits) + const adminRateLimiter = rateLimit({ + windowMs: config.adminRateLimit.windowMs, + max: config.adminRateLimit.max, + message: config.adminRateLimit.message, + standardHeaders: true, + legacyHeaders: false, + }); + // Attach request ID and metrics collection app.use(requestIdMiddleware); app.use(metricsMiddleware); @@ -185,8 +195,8 @@ export async function startApplication() { // Parse incoming JSON bodies app.use(express.json()); - // Expose Prometheus metrics endpoint - app.use('/metrics', metricsHandler); + // Expose Prometheus metrics endpoint (admin-only with authentication) + app.use('/metrics', adminRateLimiter, requireAdminToken, metricsHandler); // Application routes app.use('/api', routes); diff --git a/apps/backend/src/middlewares/adminAuth.ts b/apps/backend/src/middlewares/adminAuth.ts new file mode 100644 index 00000000..5cba287d --- /dev/null +++ b/apps/backend/src/middlewares/adminAuth.ts @@ -0,0 +1,112 @@ +import { Request, Response, NextFunction } from 'express'; +import { HTTP_STATUS } from '@gitray/shared-types'; +import { createRequestLogger } from '../services/logger'; +import { timingSafeEqual } from 'node:crypto'; + +/** + * Admin authentication middleware + * + * Validates X-Admin-Token header against configured ADMIN_TOKEN environment variable. + * Returns 403 Forbidden for missing or invalid tokens. + * Includes audit logging for all admin access attempts (success and failure). + */ + +export const requireAdminToken = ( + req: Request, + res: Response, + next: NextFunction +): void => { + const logger = createRequestLogger(req); + + // Check if admin auth is enabled (can be disabled for local development) + const adminAuthEnabled = process.env.ADMIN_AUTH_ENABLED !== 'false'; + if (!adminAuthEnabled) { + logger.warn('Admin auth disabled - allowing request', { + category: 'security', + event: 'admin_auth_disabled', + }); + next(); + return; + } + + // Check if ADMIN_TOKEN is configured + const configuredToken = process.env.ADMIN_TOKEN; + if (!configuredToken) { + logger.error('ADMIN_TOKEN not configured but admin auth enabled', { + category: 'security', + event: 'admin_auth_misconfigured', + }); + res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ + error: 'Server misconfiguration', + code: 'ADMIN_AUTH_NOT_CONFIGURED', + }); + return; + } + + // Get token from X-Admin-Token header + const providedToken = req.headers['x-admin-token']; + + // Check if token was provided + if (!providedToken || typeof providedToken !== 'string') { + logger.warn('Admin endpoint accessed without token', { + category: 'security', + event: 'admin_auth_missing_token', + path: req.path, + method: req.method, + ip: req.ip ?? req.socket.remoteAddress, + }); + res.status(HTTP_STATUS.FORBIDDEN).json({ + error: 'Forbidden', + code: 'ADMIN_AUTH_REQUIRED', + message: 'Admin authentication required. Provide X-Admin-Token header.', + }); + return; + } + + // Validate token using constant-time comparison to prevent timing attacks + let tokensMatch = false; + try { + // Convert strings to buffers for constant-time comparison + const providedBuffer = Buffer.from(providedToken, 'utf8'); + const configuredBuffer = Buffer.from(configuredToken, 'utf8'); + + // Only compare if lengths match (prevents length-based timing attacks) + if (providedBuffer.length === configuredBuffer.length) { + tokensMatch = timingSafeEqual(providedBuffer, configuredBuffer); + } + } catch (error) { + logger.error('Error during token comparison', { + category: 'security', + event: 'admin_auth_comparison_error', + error: error instanceof Error ? error.message : String(error), + }); + // Continue with tokensMatch = false + } + + if (!tokensMatch) { + logger.warn('Admin endpoint accessed with invalid token', { + category: 'security', + event: 'admin_auth_invalid_token', + path: req.path, + method: req.method, + ip: req.ip ?? req.socket.remoteAddress, + }); + res.status(HTTP_STATUS.FORBIDDEN).json({ + error: 'Forbidden', + code: 'INVALID_ADMIN_TOKEN', + message: 'Invalid admin token provided.', + }); + return; + } + + // Audit log successful admin access + logger.info('Admin access granted', { + category: 'security', + event: 'admin_auth_success', + path: req.path, + method: req.method, + ip: req.ip ?? req.socket.remoteAddress, + }); + + next(); +}; diff --git a/apps/backend/src/routes/commitRoutes.ts b/apps/backend/src/routes/commitRoutes.ts index df43d4c7..f520cf08 100644 --- a/apps/backend/src/routes/commitRoutes.ts +++ b/apps/backend/src/routes/commitRoutes.ts @@ -42,10 +42,21 @@ import { import { config } from '../config'; import { fileAnalysisService } from '../services/fileAnalysisService'; import { isSecureGitUrl } from '../middlewares/validation'; +import { requireAdminToken } from '../middlewares/adminAuth'; +import rateLimit from 'express-rate-limit'; // Router serving commit related data with unified caching const router = express.Router(); +// Admin-specific rate limiter (more restrictive than general API limits) +const adminRateLimiter = rateLimit({ + windowMs: config.adminRateLimit.windowMs, + max: config.adminRateLimit.max, + message: config.adminRateLimit.message, + standardHeaders: true, + legacyHeaders: false, +}); + // --------------------------------------------------------------------------- // Custom validation error handler that formats errors correctly // --------------------------------------------------------------------------- @@ -523,34 +534,41 @@ router.get( // --------------------------------------------------------------------------- // GET /cache/stats - Get detailed cache statistics -router.get('/cache/stats', (req: Request, res: Response) => { - const logger = createRequestLogger(req); - - const cacheStats = getRepositoryCacheStats(); - const coordinationMetrics = getCoordinationMetrics(); - const repositoryStatus = getRepositoryStatus(); - - const result = { - cache: cacheStats, - coordination: coordinationMetrics, - repositories: { - cached: repositoryStatus.length, - details: repositoryStatus.slice(0, 10), // Limit to first 10 for performance - }, - timestamp: new Date().toISOString(), - }; +router.get( + '/cache/stats', + adminRateLimiter, + requireAdminToken, + (req: Request, res: Response) => { + const logger = createRequestLogger(req); - logger.debug('Cache stats requested', { - cachedRepositories: repositoryStatus.length, - overallHitRatio: cacheStats.hitRatios.overall, - }); + const cacheStats = getRepositoryCacheStats(); + const coordinationMetrics = getCoordinationMetrics(); + const repositoryStatus = getRepositoryStatus(); + + const result = { + cache: cacheStats, + coordination: coordinationMetrics, + repositories: { + cached: repositoryStatus.length, + details: repositoryStatus.slice(0, 10), // Limit to first 10 for performance + }, + timestamp: new Date().toISOString(), + }; - res.status(HTTP_STATUS.OK).json(result); -}); + logger.debug('Cache stats requested', { + cachedRepositories: repositoryStatus.length, + overallHitRatio: cacheStats.hitRatios.overall, + }); + + res.status(HTTP_STATUS.OK).json(result); + } +); // POST /cache/invalidate - Invalidate repository cache router.post( '/cache/invalidate', + adminRateLimiter, + requireAdminToken, [ body('repoUrl') .isURL({ protocols: ['http', 'https'] }) @@ -586,37 +604,42 @@ router.post( ); // GET /cache/repositories - List all cached repositories -router.get('/cache/repositories', (req: Request, res: Response) => { - const logger = createRequestLogger(req); - - const repositoryStatus = getRepositoryStatus(); - const coordinationMetrics = getCoordinationMetrics(); - - const result = { - repositories: repositoryStatus.map((repo) => ({ - ...repo, - ageMinutes: Math.round(repo.age / (60 * 1000)), - lastAccessedFormatted: repo.lastAccessed.toISOString(), - })), - summary: { - total: repositoryStatus.length, - maxRepositories: config.repositoryCache?.maxRepositories || 50, - utilizationPercent: Math.round( - (repositoryStatus.length / - (config.repositoryCache?.maxRepositories || 50)) * - 100 - ), - }, - coordination: coordinationMetrics, - timestamp: new Date().toISOString(), - }; +router.get( + '/cache/repositories', + adminRateLimiter, + requireAdminToken, + (req: Request, res: Response) => { + const logger = createRequestLogger(req); - logger.debug('Repository status requested', { - totalRepositories: repositoryStatus.length, - }); + const repositoryStatus = getRepositoryStatus(); + const coordinationMetrics = getCoordinationMetrics(); + + const result = { + repositories: repositoryStatus.map((repo) => ({ + ...repo, + ageMinutes: Math.round(repo.age / (60 * 1000)), + lastAccessedFormatted: repo.lastAccessed.toISOString(), + })), + summary: { + total: repositoryStatus.length, + maxRepositories: config.repositoryCache?.maxRepositories || 50, + utilizationPercent: Math.round( + (repositoryStatus.length / + (config.repositoryCache?.maxRepositories || 50)) * + 100 + ), + }, + coordination: coordinationMetrics, + timestamp: new Date().toISOString(), + }; - res.status(HTTP_STATUS.OK).json(result); -}); + logger.debug('Repository status requested', { + totalRepositories: repositoryStatus.length, + }); + + res.status(HTTP_STATUS.OK).json(result); + } +); // --------------------------------------------------------------------------- // ENHANCED: Streaming endpoints (with coordination support)