diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index c1f6fcb4..e51f3559 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: ci-cd-pipeline on: push: @@ -587,18 +587,213 @@ jobs: - name: Install dependencies run: npm ci - - name: Run Lighthouse CI + - name: Install Lighthouse CI + run: npm install -g @lhci/cli@0.12.x + + - name: Create Lighthouse configuration for deployed site + run: | + cat > lighthouserc-deployed.js << 'EOF' + module.exports = { + ci: { + collect: { + url: [ + '${{ steps.deploy-production.outputs.deployment-url }}/', + '${{ steps.deploy-production.outputs.deployment-url }}/about', + '${{ steps.deploy-production.outputs.deployment-url }}/hackathons', + '${{ steps.deploy-production.outputs.deployment-url }}/leaderboard', + '${{ steps.deploy-production.outputs.deployment-url }}/auth/signin' + ], + numberOfRuns: 3, + settings: { + chromeFlags: '--no-sandbox --disable-dev-shm-usage --disable-gpu', + preset: 'desktop' + } + }, + assert: { + assertions: { + 'categories:performance': ['warn', { minScore: 0.7 }], + 'categories:accessibility': ['warn', { minScore: 0.8 }], + 'categories:best-practices': ['warn', { minScore: 0.8 }], + 'categories:seo': ['warn', { minScore: 0.8 }], + 'first-contentful-paint': ['warn', { maxNumericValue: 3000 }], + 'largest-contentful-paint': ['warn', { maxNumericValue: 4000 }], + 'cumulative-layout-shift': ['warn', { maxNumericValue: 0.2 }], + 'total-blocking-time': ['warn', { maxNumericValue: 500 }], + 'speed-index': ['warn', { maxNumericValue: 4000 }] + } + }, + upload: { + target: 'temporary-public-storage' + } + } + }; + EOF + + - name: Wait for deployment to be fully ready + run: | + echo "⏳ Waiting for deployment to be fully ready for Lighthouse testing..." + DEPLOYMENT_URL="${{ needs.deploy-production.outputs.deployment-url }}" + + # Wait up to 5 minutes for the deployment to be ready + for i in {1..30}; do + echo "Attempt $i/30: Testing deployment readiness..." + if curl -f -s --max-time 10 "$DEPLOYMENT_URL" > /dev/null; then + echo "✅ Deployment is ready for Lighthouse testing" + break + else + echo "⏳ Deployment not ready yet, waiting 10 seconds..." + sleep 10 + fi + + if [ $i -eq 30 ]; then + echo "❌ Deployment not ready after 5 minutes, but continuing with Lighthouse test" + fi + done + + - name: Run Lighthouse CI on deployed site run: | - npm install -g @lhci/cli@0.12.x - lhci autorun --assert.assertions.categories:performance=warn --assert.assertions.categories:accessibility=warn --assert.assertions.categories:best-practices=warn --assert.assertions.categories:seo=warn --assert.assertions.first-contentful-paint=warn --assert.assertions.largest-contentful-paint=warn --assert.assertions.cumulative-layout-shift=warn --assert.assertions.total-blocking-time=warn --assert.assertions.speed-index=warn + echo "🚀 Starting Lighthouse CI performance testing..." + echo "Testing URL: ${{ steps.deploy-production.outputs.deployment-url }}" + + # Run Lighthouse CI with the deployed configuration + lhci autorun --config=lighthouserc-deployed.js env: LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload performance results + - name: Verify Lighthouse results directory exists + run: | + echo "🔍 Checking Lighthouse results directory..." + if [ -d ".lighthouseci" ]; then + echo "✅ .lighthouseci directory exists" + ls -la .lighthouseci/ + echo "📊 Lighthouse results found:" + find .lighthouseci -name "*.json" -o -name "*.html" | head -10 + else + echo "❌ .lighthouseci directory not found" + echo "Creating directory for fallback..." + mkdir -p .lighthouseci + echo "{}" > .lighthouseci/manifest.json + fi + + - name: Upload Lighthouse performance results uses: actions/upload-artifact@v4 + if: always() + with: + name: lighthouse-performance-results + path: .lighthouseci/ + retention-days: 30 + if-no-files-found: warn + + # Performance Monitoring for Staging + performance-staging: + name: Performance Monitoring (Staging) + runs-on: ubuntu-latest + needs: deploy-staging + if: github.ref == 'refs/heads/develop' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Install Lighthouse CI + run: npm install -g @lhci/cli@0.12.x + + - name: Create Lighthouse configuration for staging + run: | + cat > lighthouserc-staging.js << 'EOF' + module.exports = { + ci: { + collect: { + url: [ + '${{ steps.deploy-staging.outputs.deployment-url }}/', + '${{ steps.deploy-staging.outputs.deployment-url }}/about', + '${{ steps.deploy-staging.outputs.deployment-url }}/hackathons', + '${{ steps.deploy-staging.outputs.deployment-url }}/leaderboard', + '${{ steps.deploy-staging.outputs.deployment-url }}/auth/signin' + ], + numberOfRuns: 2, + settings: { + chromeFlags: '--no-sandbox --disable-dev-shm-usage --disable-gpu', + preset: 'desktop' + } + }, + assert: { + assertions: { + 'categories:performance': ['warn', { minScore: 0.6 }], + 'categories:accessibility': ['warn', { minScore: 0.7 }], + 'categories:best-practices': ['warn', { minScore: 0.7 }], + 'categories:seo': ['warn', { minScore: 0.7 }] + } + }, + upload: { + target: 'temporary-public-storage' + } + } + }; + EOF + + - name: Wait for staging deployment to be ready + run: | + echo "⏳ Waiting for staging deployment to be ready for Lighthouse testing..." + DEPLOYMENT_URL="${{ needs.deploy-staging.outputs.deployment-url }}" + + # Wait up to 3 minutes for the staging deployment to be ready + for i in {1..18}; do + echo "Attempt $i/18: Testing staging deployment readiness..." + if curl -f -s --max-time 10 "$DEPLOYMENT_URL" > /dev/null; then + echo "✅ Staging deployment is ready for Lighthouse testing" + break + else + echo "⏳ Staging deployment not ready yet, waiting 10 seconds..." + sleep 10 + fi + + if [ $i -eq 18 ]; then + echo "❌ Staging deployment not ready after 3 minutes, but continuing with Lighthouse test" + fi + done + - name: Run Lighthouse CI on staging site + run: | + echo "🚀 Starting Lighthouse CI performance testing on staging..." + echo "Testing URL: ${{ steps.deploy-staging.outputs.deployment-url }}" + + # Run Lighthouse CI with the staging configuration + lhci autorun --config=lighthouserc-staging.js + env: + LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }} + LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify Lighthouse results directory exists + run: | + echo "🔍 Checking Lighthouse results directory..." + if [ -d ".lighthouseci" ]; then + echo "✅ .lighthouseci directory exists" + ls -la .lighthouseci/ + echo "📊 Lighthouse results found:" + find .lighthouseci -name "*.json" -o -name "*.html" | head -10 + else + echo "❌ .lighthouseci directory not found" + echo "Creating directory for fallback..." + mkdir -p .lighthouseci + echo "{}" > .lighthouseci/manifest.json + fi + + - name: Upload Lighthouse staging results + uses: actions/upload-artifact@v4 + if: always() with: - name: lighthouse-results + name: lighthouse-staging-results path: .lighthouseci/ - retention-days: 30 \ No newline at end of file + retention-days: 7 + if-no-files-found: warn \ No newline at end of file diff --git a/app/api/admin/audit-logs/route.ts b/app/api/admin/audit-logs/route.ts index b2247754..492945d0 100644 --- a/app/api/admin/audit-logs/route.ts +++ b/app/api/admin/audit-logs/route.ts @@ -6,7 +6,7 @@ import { createAuditLogger, AuditLogFilter, AuditActionType } from '@/lib/servic * GET /api/admin/audit-logs * Retrieve audit logs with filtering and pagination */ -async function getAuditLogs(request: NextRequest, _user: AuthenticatedUser) { +async function getAuditLogs(request: NextRequest, user: AuthenticatedUser) { try { const { searchParams } = new URL(request.url); @@ -39,6 +39,9 @@ async function getAuditLogs(request: NextRequest, _user: AuthenticatedUser) { const auditLogger = createAuditLogger(); const result = await auditLogger.getLogs(filter); + + // Log the audit log access for security tracking + console.log(`Admin ${user.id} accessed audit logs with filter:`, filter); return NextResponse.json({ success: true, diff --git a/app/api/admin/audit-logs/stats/route.ts b/app/api/admin/audit-logs/stats/route.ts index 750a7068..2ef23069 100644 --- a/app/api/admin/audit-logs/stats/route.ts +++ b/app/api/admin/audit-logs/stats/route.ts @@ -6,7 +6,7 @@ import { createAuditLogger } from '@/lib/services/audit-logger'; * GET /api/admin/audit-logs/stats * Get audit log statistics */ -async function getAuditStats(request: NextRequest, _user: AuthenticatedUser) { +async function getAuditStats(request: NextRequest, user: AuthenticatedUser) { try { const { searchParams } = new URL(request.url); const periodDays = searchParams.get('period_days') @@ -23,6 +23,9 @@ async function getAuditStats(request: NextRequest, _user: AuthenticatedUser) { const auditLogger = createAuditLogger(); const stats = await auditLogger.getAuditStats(periodDays); + + // Log the audit stats access for security tracking + console.log(`Admin ${user.id} accessed audit stats for ${periodDays} days`); return NextResponse.json({ success: true, diff --git a/app/api/admin/check-status/route.ts b/app/api/admin/check-status/route.ts index ee40a88a..5d1c7484 100644 --- a/app/api/admin/check-status/route.ts +++ b/app/api/admin/check-status/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { ErrorSanitizer } from '@/lib/security/error-sanitizer'; export async function GET() { try { @@ -7,10 +8,11 @@ export async function GET() { const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) { - return NextResponse.json({ - error: 'Unauthorized', - message: 'User not authenticated' - }, { status: 401 }); + return ErrorSanitizer.createErrorResponse( + authError || new Error('User not authenticated'), + 401, + 'admin-check-status-auth' + ); } // Check admin status in profiles table @@ -20,7 +22,7 @@ export async function GET() { .eq('id', user.id) .single(); - // If profile doesn't exist, create it + // If profile doesn't exist, create it with default user privileges if (profileError && profileError.code === 'PGRST116') { console.log('Profile not found, creating new profile...'); const { data: newProfile, error: createError } = await supabase @@ -28,35 +30,36 @@ export async function GET() { .insert({ id: user.id, email: user.email, - is_admin: true // Grant admin by default for now + is_admin: false // SECURITY FIX: Default to regular user, not admin }) .select('is_admin, first_name, last_name, username, email') .single(); if (createError) { - console.error('Error creating profile:', createError); - return NextResponse.json({ - error: 'Failed to create profile', - message: createError.message - }, { status: 500 }); + return ErrorSanitizer.createErrorResponse( + createError, + 500, + 'admin-check-status-create-profile' + ); } - return NextResponse.json({ - user: { - id: user.id, - email: newProfile.email || user.email, - full_name: `${newProfile.first_name || ''} ${newProfile.last_name || ''}`.trim() || newProfile.username, - is_admin: newProfile.is_admin - }, - message: 'Profile created with admin privileges' - }); + return NextResponse.json({ + user: { + id: user.id, + email: newProfile.email || user.email, + full_name: `${newProfile.first_name || ''} ${newProfile.last_name || ''}`.trim() || newProfile.username, + is_admin: newProfile.is_admin + }, + message: 'Profile created successfully' + }); } if (profileError) { - return NextResponse.json({ - error: 'Profile not found', - message: profileError.message - }, { status: 404 }); + return ErrorSanitizer.createErrorResponse( + profileError, + 404, + 'admin-check-status-profile' + ); } return NextResponse.json({ @@ -70,56 +73,19 @@ export async function GET() { }); } catch (error) { - console.error('Error checking admin status:', error); - return NextResponse.json({ - error: 'Internal server error', - message: 'Failed to check admin status' - }, { status: 500 }); + return ErrorSanitizer.createErrorResponse( + error, + 500, + 'admin-check-status-catch' + ); } } export async function POST() { - try { - const supabase = await createClient(); - const { data: { user }, error: authError } = await supabase.auth.getUser(); - - if (authError || !user) { - return NextResponse.json({ - error: 'Unauthorized', - message: 'User not authenticated' - }, { status: 401 }); - } - - // Grant admin privileges - const { data: profile, error: updateError } = await supabase - .from('profiles') - .update({ is_admin: true }) - .eq('id', user.id) - .select('is_admin, first_name, last_name, username, email') - .single(); - - if (updateError) { - return NextResponse.json({ - error: 'Failed to update admin status', - message: updateError.message - }, { status: 500 }); - } - - return NextResponse.json({ - user: { - id: user.id, - email: profile.email || user.email, - full_name: `${profile.first_name || ''} ${profile.last_name || ''}`.trim() || profile.username, - is_admin: profile.is_admin - }, - message: 'Admin privileges granted successfully' - }); - - } catch (error) { - console.error('Error granting admin privileges:', error); - return NextResponse.json({ - error: 'Internal server error', - message: 'Failed to grant admin privileges' - }, { status: 500 }); - } + // SECURITY FIX: Remove this endpoint entirely as it allows privilege escalation + // Admin privileges should only be granted through proper admin workflows + return NextResponse.json({ + error: 'Forbidden', + message: 'This endpoint has been disabled for security reasons' + }, { status: 403 }); } \ No newline at end of file diff --git a/app/api/admin/monitoring/alerts/route.ts b/app/api/admin/monitoring/alerts/route.ts index d4363a4f..48809689 100644 --- a/app/api/admin/monitoring/alerts/route.ts +++ b/app/api/admin/monitoring/alerts/route.ts @@ -6,7 +6,7 @@ import { monitoringAlerting } from '@/lib/monitoring/alerting'; * GET /api/admin/monitoring/alerts * Get monitoring alerts */ -async function getAlerts(request: NextRequest, _user: AuthenticatedUser) { +async function getAlerts(request: NextRequest, user: AuthenticatedUser) { try { const { searchParams } = new URL(request.url); const status = searchParams.get('status') as 'active' | 'resolved' | 'acknowledged' | null; @@ -31,6 +31,9 @@ async function getAlerts(request: NextRequest, _user: AuthenticatedUser) { // Sort by created_at descending alerts.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + // Log the monitoring alerts access for security tracking + console.log(`Admin ${user.id} accessed monitoring alerts with filters:`, { status, type, severity }); + return NextResponse.json({ success: true, data: { @@ -53,7 +56,7 @@ async function getAlerts(request: NextRequest, _user: AuthenticatedUser) { * POST /api/admin/monitoring/alerts * Acknowledge or resolve an alert */ -async function updateAlert(request: NextRequest, _user: AuthenticatedUser) { +async function updateAlert(request: NextRequest, user: AuthenticatedUser) { try { const body = await request.json(); const { alert_id, action } = body; @@ -86,6 +89,9 @@ async function updateAlert(request: NextRequest, _user: AuthenticatedUser) { ); } + // Log the alert update for security tracking + console.log(`Admin ${user.id} ${action}d alert ${alert_id}`); + return NextResponse.json({ success: true, message: `Alert ${action}d successfully` diff --git a/lib/monitoring/alerting.ts b/lib/monitoring/alerting.ts index aaaf1862..49e0756f 100644 --- a/lib/monitoring/alerting.ts +++ b/lib/monitoring/alerting.ts @@ -411,8 +411,8 @@ export class MonitoringAlerting { /** * Send alert via email (using existing email system) */ - private async sendEmailAlert(alert: Alert, _config: Record): Promise { - const emailRecipients = process.env.ALERT_EMAIL_RECIPIENTS || 'connect@codeunia.com'; + private async sendEmailAlert(alert: Alert, config: Record): Promise { + const emailRecipients = (config.emailRecipients as string) || process.env.ALERT_EMAIL_RECIPIENTS || 'connect@codeunia.com'; const emailContent = { subject: `[${alert.severity.toUpperCase()}] ${alert.title}`, diff --git a/lib/performance/optimization.ts b/lib/performance/optimization.ts index 934b1eb6..fd5982f3 100644 --- a/lib/performance/optimization.ts +++ b/lib/performance/optimization.ts @@ -280,7 +280,7 @@ export class PerformanceMonitor { new PerformanceObserver((list) => { const entries = list.getEntries(); entries.forEach((entry) => { - const fidEntry = entry as any; + const fidEntry = entry as PerformanceEntry & { processingStart: number }; console.log('FID:', fidEntry.processingStart - fidEntry.startTime); }); }).observe({ entryTypes: ['first-input'] }); @@ -290,7 +290,7 @@ export class PerformanceMonitor { let clsValue = 0; const entries = list.getEntries(); entries.forEach((entry) => { - const clsEntry = entry as any; + const clsEntry = entry as PerformanceEntry & { hadRecentInput: boolean; value: number }; if (!clsEntry.hadRecentInput) { clsValue += clsEntry.value; } diff --git a/lib/security/api-wrapper.ts b/lib/security/api-wrapper.ts new file mode 100644 index 00000000..f05dad4d --- /dev/null +++ b/lib/security/api-wrapper.ts @@ -0,0 +1,128 @@ +/** + * API Route Wrapper for Security + * Provides consistent error handling and security measures + */ + +import { NextRequest, NextResponse } from 'next/server'; +import { ErrorSanitizer } from './error-sanitizer'; +import { getCSPConfig } from './csp-config'; + +export interface APIHandler { + (request: NextRequest): Promise; +} + +/** + * Wrap API routes with security measures + */ +export function withSecurity(handler: APIHandler) { + return async (request: NextRequest): Promise => { + const requestId = crypto.randomUUID(); + + try { + // Add request ID to headers for tracking + const response = await handler(request); + + // Add security headers + response.headers.set('X-Request-ID', requestId); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-XSS-Protection', '1; mode=block'); + + // Add CSP header + const cspConfig = getCSPConfig(request); + response.headers.set('Content-Security-Policy', cspConfig.policy); + + return response; + } catch (error) { + const res = ErrorSanitizer.createErrorResponse( + error, + 500, + 'api-wrapper-catch', + requestId + ); + // Ensure error responses also include security headers/CSP + res.headers.set('X-Request-ID', requestId); + res.headers.set('X-Content-Type-Options', 'nosniff'); + res.headers.set('X-Frame-Options', 'DENY'); + res.headers.set('X-XSS-Protection', '1; mode=block'); + const cspConfig = getCSPConfig(request); + res.headers.set('Content-Security-Policy', cspConfig.policy); + return res; + } + }; +} + +/** + * Wrap API routes with authentication + */ +export function withAuth(handler: APIHandler) { + return withSecurity(async (request: NextRequest) => { + // Authentication logic would go here + // For now, just pass through to the handler + return handler(request); + }); +} + +/** + * Wrap API routes with rate limiting + */ +export function withRateLimit(handler: APIHandler) { + return withSecurity(async (request: NextRequest) => { + // Rate limiting logic would go here + // For now, just pass through to the handler + return handler(request); + }); +} + +/** + * Create secure error response + */ +export function createSecureErrorResponse( + error: Error | unknown, + statusCode: number = 500, + context?: string, + request?: NextRequest, + requestId?: string +): Response { + const res = ErrorSanitizer.createErrorResponse(error, statusCode, context, requestId); + res.headers.set('X-Request-ID', requestId || crypto.randomUUID()); + res.headers.set('X-Content-Type-Options', 'nosniff'); + res.headers.set('X-Frame-Options', 'DENY'); + res.headers.set('X-XSS-Protection', '1; mode=block'); + if (request) { + const csp = getCSPConfig(request); + res.headers.set('Content-Security-Policy', csp.policy); + } + return res; +} + +/** + * Create secure success response + */ +export function createSecureSuccessResponse( + data: unknown, + statusCode: number = 200, + request?: NextRequest, + requestId?: string +): Response { + const response = NextResponse.json({ + success: true, + data, + timestamp: new Date().toISOString(), + ...(requestId && { requestId }) + }, { status: statusCode }); + + // Add security headers + response.headers.set('X-Request-ID', requestId || crypto.randomUUID()); + response.headers.set('X-Content-Type-Options', 'nosniff'); + response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-XSS-Protection', '1; mode=block'); + + // Add per-request CSP if request is provided + if (request) { + const csp = getCSPConfig(request); + response.headers.set('Content-Security-Policy', csp.policy); + } + + return response; +} diff --git a/lib/security/auth-middleware.ts b/lib/security/auth-middleware.ts index b6d5caa2..c1d3fd81 100644 --- a/lib/security/auth-middleware.ts +++ b/lib/security/auth-middleware.ts @@ -18,12 +18,15 @@ export interface AuthContext { } // Enhanced authentication middleware -export async function authenticateUser(_request: NextRequest): Promise { +export async function authenticateUser(request: NextRequest): Promise { try { // Rate limiting check - // 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'; + + // Log authentication attempt for security monitoring + console.log(`Authentication attempt from IP: ${clientIp}`); // if (!authRateLimiter.isAllowed(clientIp)) { // return null; diff --git a/lib/security/csp-config.ts b/lib/security/csp-config.ts new file mode 100644 index 00000000..4383327a --- /dev/null +++ b/lib/security/csp-config.ts @@ -0,0 +1,91 @@ +/** + * Content Security Policy Configuration + * Enhanced CSP with nonce support for better security + */ + +import { NextRequest } from 'next/server'; +import crypto from 'crypto'; + +export interface CSPConfig { + nonce: string; + policy: string; +} + +/** + * Generate a secure nonce for CSP + */ +export function generateNonce(): string { + // Prefer Web Crypto (Edge/Browser) + const webCrypto = (globalThis as { crypto?: { getRandomValues?: (arr: Uint8Array) => void } }).crypto; + if (webCrypto?.getRandomValues) { + const arr = new Uint8Array(16); + webCrypto.getRandomValues(arr); + // Base64 encode without Buffer dependency + let binary = ''; + for (let i = 0; i < arr.length; i++) binary += String.fromCharCode(arr[i]); + // btoa is available in Edge/Browser + return typeof (globalThis as { btoa?: (str: string) => string }).btoa === 'function' + ? (globalThis as { btoa: (str: string) => string }).btoa(binary) + : Buffer.from(arr).toString('base64'); + } + // Node.js fallback + return crypto.randomBytes(16).toString('base64'); +} + +/** + * Get CSP configuration for the current request + */ +export function getCSPConfig(request: NextRequest): CSPConfig { + const nonce = generateNonce(); + + // Enhanced CSP policy without unsafe directives + const policy = [ + "default-src 'self'", + "script-src 'self' 'nonce-" + nonce + "' https://vercel.live https://va.vercel-scripts.com", + "style-src 'self' 'nonce-" + nonce + "' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: https: blob:", + "connect-src 'self' https://*.supabase.co https://*.vercel.app wss://*.supabase.co", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests" + ].join('; '); + + // Log CSP generation for security monitoring + console.log(`CSP generated for ${request.url} with nonce: ${nonce.substring(0, 8)}...`); + + return { + nonce, + policy + }; +} + +/** + * Apply CSP headers to response + */ +export function applyCSPHeaders(response: Response, cspConfig: CSPConfig): Response { + response.headers.set('Content-Security-Policy', cspConfig.policy); + return response; +} + +/** + * Development CSP (more permissive for development) + */ +export function getDevelopmentCSP(): string { + return [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: https: blob:", + "connect-src 'self' https://*.supabase.co https://*.vercel.app wss://*.supabase.co", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'" + ].join('; '); +} diff --git a/lib/security/error-sanitizer.ts b/lib/security/error-sanitizer.ts new file mode 100644 index 00000000..6eb879d1 --- /dev/null +++ b/lib/security/error-sanitizer.ts @@ -0,0 +1,259 @@ +/** + * Error Sanitization for Production Security + * Prevents information disclosure through error messages + */ + +export interface SanitizedError { + message: string; + code?: string; + timestamp: string; + requestId?: string; +} + +export interface DetailedError { + message: string; + code?: string; + details?: unknown; + stack?: string; + timestamp: string; + requestId?: string; +} + +/** + * Sanitize error messages for production + */ +export class ErrorSanitizer { + private static readonly isProduction = process.env.NODE_ENV === 'production'; + + /** + * Sanitize error for client response + */ + static sanitizeError(error: Error | unknown, requestId?: string): SanitizedError { + const timestamp = new Date().toISOString(); + + // In production, return generic error messages + if (this.isProduction) { + return this.getGenericError(error, timestamp, requestId); + } + + // In development, return detailed error information + const errorObj = error as { message?: string; code?: string }; + + return { + message: errorObj?.message || 'An error occurred', + code: errorObj?.code, + timestamp, + requestId + }; + } + + /** + * Get generic error message for production + */ + private static getGenericError(error: Error | unknown, timestamp: string, requestId?: string): SanitizedError { + // Map specific error types to generic messages + const errorObj = error as { code?: string; message?: string }; + + if (errorObj?.code === 'PGRST116') { + return { + message: 'Resource not found', + code: 'NOT_FOUND', + timestamp, + requestId + }; + } + + if (errorObj?.code === '23505') { + return { + message: 'Resource already exists', + code: 'CONFLICT', + timestamp, + requestId + }; + } + + if (errorObj?.code === '23503') { + return { + message: 'Invalid reference', + code: 'INVALID_REFERENCE', + timestamp, + requestId + }; + } + + if (errorObj?.message?.includes('JWT')) { + return { + message: 'Authentication failed', + code: 'AUTH_ERROR', + timestamp, + requestId + }; + } + + if (errorObj?.message?.includes('permission') || errorObj?.message?.includes('unauthorized')) { + return { + message: 'Access denied', + code: 'FORBIDDEN', + timestamp, + requestId + }; + } + + if (errorObj?.message?.includes('validation') || errorObj?.message?.includes('invalid')) { + return { + message: 'Invalid input provided', + code: 'VALIDATION_ERROR', + timestamp, + requestId + }; + } + + if (errorObj?.message?.includes('rate limit') || errorObj?.message?.includes('too many')) { + return { + message: 'Too many requests', + code: 'RATE_LIMITED', + timestamp, + requestId + }; + } + + // Default generic error + return { + message: 'An unexpected error occurred', + code: 'INTERNAL_ERROR', + timestamp, + requestId + }; + } + + /** + * Log detailed error for debugging (server-side only) + */ + static logDetailedError(error: Error | unknown, context?: string, requestId?: string): void { + const errorObj = error as { message?: string; code?: string; details?: unknown; stack?: string }; + + const detailedError: DetailedError = { + message: errorObj?.message || 'Unknown error', + code: errorObj?.code, + details: errorObj?.details || error, + stack: errorObj?.stack, + timestamp: new Date().toISOString(), + requestId + }; + + // Log with context + console.error(`[${context || 'ERROR'}]`, { + ...detailedError, + environment: process.env.NODE_ENV, + url: process.env.NEXT_PUBLIC_SITE_URL + }); + } + + /** + * Create standardized error response + */ + static createErrorResponse( + error: Error | unknown, + statusCode: number = 500, + context?: string, + requestId?: string + ): Response { + // Log detailed error for debugging + this.logDetailedError(error, context, requestId); + + // Return sanitized error to client + const sanitizedError = this.sanitizeError(error, requestId); + + return new Response( + JSON.stringify({ + error: sanitizedError.message, + code: sanitizedError.code, + timestamp: sanitizedError.timestamp, + ...(requestId && { requestId }) + }), + { + status: statusCode, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + } + ); + } + + /** + * Validate and sanitize user input errors + */ + static sanitizeValidationError(error: Error | unknown, requestId?: string): SanitizedError { + const timestamp = new Date().toISOString(); + + if (this.isProduction) { + return { + message: 'Please check your input and try again', + code: 'VALIDATION_ERROR', + timestamp, + requestId + }; + } + + const errorObj = error as { message?: string; code?: string }; + + return { + message: errorObj?.message || 'Validation failed', + code: errorObj?.code || 'VALIDATION_ERROR', + timestamp, + requestId + }; + } + + /** + * Sanitize database errors + */ + static sanitizeDatabaseError(error: Error | unknown, requestId?: string): SanitizedError { + const timestamp = new Date().toISOString(); + + if (this.isProduction) { + // Don't expose database-specific error details + return { + message: 'Database operation failed', + code: 'DATABASE_ERROR', + timestamp, + requestId + }; + } + + const errorObj = error as { message?: string; code?: string }; + + return { + message: errorObj?.message || 'Database error', + code: errorObj?.code || 'DATABASE_ERROR', + timestamp, + requestId + }; + } + + /** + * Sanitize authentication errors + */ + static sanitizeAuthError(error: Error | unknown, requestId?: string): SanitizedError { + const timestamp = new Date().toISOString(); + + if (this.isProduction) { + return { + message: 'Authentication failed', + code: 'AUTH_ERROR', + timestamp, + requestId + }; + } + + const errorObj = error as { message?: string; code?: string }; + + return { + message: errorObj?.message || 'Authentication error', + code: errorObj?.code || 'AUTH_ERROR', + timestamp, + requestId + }; + } +} diff --git a/lib/services/global-leaderboard.ts b/lib/services/global-leaderboard.ts index ab048efa..7c39a588 100644 --- a/lib/services/global-leaderboard.ts +++ b/lib/services/global-leaderboard.ts @@ -97,8 +97,7 @@ export class GlobalLeaderboardService { async getUserPoints(userId: string): Promise { try { // Use admin client to bypass RLS - const supabase = this.getSupabaseClient(); - const { data, error } = await this.supabaseAdmin + const { data, error } = await this.supabaseAdmin .from('user_points') .select('*') .eq('user_id', userId) @@ -130,8 +129,7 @@ export class GlobalLeaderboardService { private async createUserPoints(userId: string): Promise { try { // Use admin client to bypass RLS - const supabase = this.getSupabaseClient(); - const { data, error } = await this.supabaseAdmin + const { data, error } = await this.supabaseAdmin .from('user_points') .insert([{ user_id: userId, diff --git a/vercel.json b/vercel.json index da06b330..1a235809 100644 --- a/vercel.json +++ b/vercel.json @@ -73,7 +73,7 @@ }, { "key": "Content-Security-Policy", - "value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://vercel.live https://va.vercel-scripts.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: blob:; connect-src 'self' https://*.supabase.co https://*.vercel.app wss://*.supabase.co; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" + "value": "default-src 'self'; script-src 'self' https://vercel.live https://va.vercel-scripts.com; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: blob:; connect-src 'self' https://*.supabase.co https://*.vercel.app wss://*.supabase.co; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" } ] }