diff --git a/src/backend/config.js b/src/backend/config.js index 56831fb..22cb4b2 100644 --- a/src/backend/config.js +++ b/src/backend/config.js @@ -15,7 +15,16 @@ const __dirname = dirname(__filename); const isDevelopment = process.env.NODE_ENV !== 'production'; -export const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production'; +// SECURITY: Require JWT_SECRET in production - no fallback for security +// This prevents JWT token forgery if environment variable is not set +if (!process.env.JWT_SECRET && !isDevelopment) { + throw new Error( + 'FATAL: JWT_SECRET environment variable must be set in production. ' + + 'Generate one with: openssl rand -base64 32' + ); +} + +export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-key-change-in-production'; export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info'); // =================================================================== diff --git a/src/backend/index.js b/src/backend/index.js index ea86082..3b66aad 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -1,6 +1,8 @@ import { Elysia, t } from 'elysia'; import { cors } from '@elysiajs/cors'; import { jwt } from '@elysiajs/jwt'; +import { resolve, normalize } from 'path'; +import { existsSync } from 'fs'; import { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js'; import { registerUser, @@ -33,12 +35,116 @@ import { * Serves API routes and static frontend files */ -// Get allowed origins from environment or allow all in development +// SECURITY: Stricter email validation +// Elysia's built-in email format check is lenient; this provides better validation +// Allows: alphanumeric, dots, hyphens, underscores in local part +// Requires: valid domain with at least one dot +const EMAIL_REGEX = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + +function isValidEmail(email) { + // Basic format check with regex + if (!EMAIL_REGEX.test(email)) { + return false; + } + + // Additional checks + const [local, domain] = email.split('@'); + + // Local part shouldn't start or end with dot + if (local.startsWith('.') || local.endsWith('.')) { + return false; + } + + // Local part shouldn't have consecutive dots + if (local.includes('..')) { + return false; + } + + // Domain must have at least one dot and valid TLD + if (!domain || !domain.includes('.') || domain.split('.').pop().length < 2) { + return false; + } + + // Length checks (RFC 5321 limits) + if (email.length > 254 || local.length > 64) { + return false; + } + + return true; +} + +// SECURITY: Validate job ID parameter +// Prevents injection and DoS via extremely large numbers +function isValidJobId(jobId) { + const id = parseInt(jobId, 10); + // Check: must be a number, positive, and within reasonable range (1 to 2^31-1) + return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id); +} + +// SECURITY: Validate QSO ID parameter +function isValidQsoId(qsoId) { + const id = parseInt(qsoId, 10); + return !isNaN(id) && id > 0 && id <= 2147483647 && Number.isSafeInteger(id); +} + +// SECURITY: In-memory rate limiter for auth endpoints +// Prevents brute force attacks on login/register +const rateLimitStore = new Map(); // IP -> { count, resetTime } + +function checkRateLimit(ip, limit = 5, windowMs = 60000) { + const now = Date.now(); + const record = rateLimitStore.get(ip); + + // Clean up expired records + if (record && now > record.resetTime) { + rateLimitStore.delete(ip); + return { allowed: true, remaining: limit - 1 }; + } + + // Check if limit exceeded + if (record) { + if (record.count >= limit) { + const retryAfter = Math.ceil((record.resetTime - now) / 1000); + return { allowed: false, retryAfter }; + } + record.count++; + return { allowed: true, remaining: limit - record.count }; + } + + // Create new record + rateLimitStore.set(ip, { + count: 1, + resetTime: now + windowMs, + }); + + return { allowed: true, remaining: limit - 1 }; +} + +// SECURITY: Validate that a path doesn't escape the allowed directory +// Prevents path traversal attacks like ../../../etc/passwd +const BUILD_DIR = resolve('src/frontend/build'); + +function isPathSafe(requestedPath) { + try { + // Normalize the path to resolve any ../ sequences + const normalized = normalize(requestedPath); + const fullPath = resolve(BUILD_DIR, normalized); + + // Check if the resolved path is within the build directory + return fullPath.startsWith(BUILD_DIR); + } catch { + return false; + } +} + +// Get allowed origins from environment +// SECURITY: Never allow all origins, even in development +// Always require explicit ALLOWED_ORIGINS or VITE_APP_URL const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS ? process.env.ALLOWED_ORIGINS.split(',') : process.env.NODE_ENV === 'production' ? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de'] - : true; // Allow all in development + : [process.env.VITE_APP_URL || 'http://localhost:5173']; // Default dev origin const app = new Elysia() // Enable CORS for frontend communication @@ -80,6 +186,46 @@ const app = new Elysia() } }) + // Security headers middleware + .onAfterHandle(({ set, request }) => { + const url = new URL(request.url); + const isProduction = process.env.NODE_ENV === 'production'; + + // Prevent clickjacking + set.headers['X-Frame-Options'] = 'DENY'; + + // Prevent MIME type sniffing + set.headers['X-Content-Type-Options'] = 'nosniff'; + + // Enable XSS filter + set.headers['X-XSS-Protection'] = '1; mode=block'; + + // Referrer policy + set.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'; + + // Content Security Policy (basic protection) + // In development, allow any origin; in production, restrict + const appUrl = process.env.VITE_APP_URL || 'https://awards.dj7nt.de'; + const connectSrc = isProduction ? `'self' ${appUrl}` : "'self' *"; + + set.headers['Content-Security-Policy'] = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline scripts + `style-src 'self' 'unsafe-inline'`, // unsafe-inline needed for SvelteKit inline styles + `img-src 'self' data: https:`, + `connect-src ${connectSrc}`, + "font-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '); + + // Strict Transport Security (HTTPS only) - only in production + if (isProduction) { + set.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'; + } + }) + // Request logging middleware .onRequest(({ request, params }) => { const url = new URL(request.url); @@ -124,7 +270,31 @@ const app = new Elysia() */ .post( '/api/auth/register', - async ({ body, jwt, set }) => { + async ({ body, jwt, set, request }) => { + // SECURITY: Rate limiting to prevent abuse + const ip = request.headers.get('x-forwarded-for') || 'unknown'; + const rateLimit = checkRateLimit(ip, 5, 60000); // 5 requests per minute + + if (!rateLimit.allowed) { + set.status = 429; + set.headers['Retry-After'] = rateLimit.retryAfter.toString(); + return { + success: false, + error: 'Too many registration attempts. Please try again later.', + }; + } + + set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString(); + + // SECURITY: Additional server-side email validation + if (!isValidEmail(body.email)) { + set.status = 400; + return { + success: false, + error: 'Invalid email address format', + }; + } + // Create user const user = await registerUser(body); @@ -136,11 +306,13 @@ const app = new Elysia() }; } - // Generate JWT token + // Generate JWT token with expiration (24 hours) + const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now const token = await jwt.sign({ userId: user.id, email: user.email, callsign: user.callsign, + exp, }); set.status = 201; @@ -175,7 +347,22 @@ const app = new Elysia() */ .post( '/api/auth/login', - async ({ body, jwt, set }) => { + async ({ body, jwt, set, request }) => { + // SECURITY: Rate limiting to prevent brute force attacks + const ip = request.headers.get('x-forwarded-for') || 'unknown'; + const rateLimit = checkRateLimit(ip, 10, 60000); // 10 requests per minute (more lenient than register) + + if (!rateLimit.allowed) { + set.status = 429; + set.headers['Retry-After'] = rateLimit.retryAfter.toString(); + return { + success: false, + error: 'Too many login attempts. Please try again later.', + }; + } + + set.headers['X-RateLimit-Remaining'] = rateLimit.remaining.toString(); + // Authenticate user const user = await authenticateUser(body.email, body.password); @@ -187,11 +374,13 @@ const app = new Elysia() }; } - // Generate JWT token + // Generate JWT token with expiration (24 hours) + const exp = Math.floor(Date.now() / 1000) + (24 * 60 * 60); // 24 hours from now const token = await jwt.sign({ userId: user.id, email: user.email, callsign: user.callsign, + exp, }); return { @@ -389,11 +578,12 @@ const app = new Elysia() } try { - const jobId = parseInt(params.jobId); - if (isNaN(jobId)) { + // SECURITY: Validate job ID to prevent injection and DoS + if (!isValidJobId(params.jobId)) { set.status = 400; return { success: false, error: 'Invalid job ID' }; } + const jobId = parseInt(params.jobId, 10); const job = await getJobStatus(jobId); if (!job) { @@ -498,11 +688,12 @@ const app = new Elysia() } try { - const jobId = parseInt(params.jobId); - if (isNaN(jobId)) { + // SECURITY: Validate job ID to prevent injection and DoS + if (!isValidJobId(params.jobId)) { set.status = 400; return { success: false, error: 'Invalid job ID' }; } + const jobId = parseInt(params.jobId, 10); const result = await cancelJob(jobId, user.id); @@ -576,11 +767,12 @@ const app = new Elysia() } try { - const qsoId = parseInt(params.id); - if (isNaN(qsoId)) { + // SECURITY: Validate QSO ID to prevent injection and DoS + if (!isValidQsoId(params.id)) { set.status = 400; return { success: false, error: 'Invalid QSO ID' }; } + const qsoId = parseInt(params.id, 10); const qso = await getQSOById(user.id, qsoId); @@ -844,28 +1036,35 @@ const app = new Elysia() // Extract the actual file path after %sveltekit.assets%/ const assetPath = pathname.replace('/%sveltekit.assets%/', ''); - try { - // Try to serve from assets directory first - const assetsPath = `src/frontend/build/_app/immutable/assets/${assetPath}`; - const file = Bun.file(assetsPath); - const exists = file.exists(); + // SECURITY: Validate path before serving + const assetsDirPath = `_app/immutable/assets/${assetPath}`; + const rootPath = assetPath; - if (exists) { - return new Response(file); + if (isPathSafe(assetsDirPath)) { + try { + const assetsPath = `src/frontend/build/${assetsDirPath}`; + const file = Bun.file(assetsPath); + const exists = file.exists(); + + if (exists) { + return new Response(file); + } + } catch (err) { + // Fall through to 404 } - } catch (err) { - // Fall through to 404 } - // If not in assets, try root directory - try { - const rootFile = Bun.file(`src/frontend/build/${assetPath}`); - const rootExists = rootFile.exists(); - if (rootExists) { - return new Response(rootFile); + // If not in assets, try root directory (with path validation) + if (isPathSafe(rootPath)) { + try { + const rootFile = Bun.file(`src/frontend/build/${rootPath}`); + const rootExists = rootFile.exists(); + if (rootExists) { + return new Response(rootFile); + } + } catch (err) { + // Fall through to 404 } - } catch (err) { - // Fall through to 404 } return new Response('Not found', { status: 404 }); @@ -875,6 +1074,12 @@ const app = new Elysia() // Remove leading slash for file path const filePath = pathname === '/' ? '/index.html' : pathname; + // SECURITY: Validate path is within build directory before serving + if (!isPathSafe(filePath.substring(1))) { // Remove leading slash for relative path + logger.warn('Path traversal attempt blocked', { pathname }); + return new Response('Not found', { status: 404 }); + } + try { const fullPath = `src/frontend/build${filePath}`; diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index fd7823b..6537b5d 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -15,6 +15,35 @@ const MAX_RETRIES = 30; const RETRY_DELAY = 10000; const REQUEST_TIMEOUT = 60000; +/** + * SECURITY: Sanitize search input to prevent injection and DoS + * Limits length and removes potentially harmful characters + */ +function sanitizeSearchInput(searchTerm) { + if (!searchTerm || typeof searchTerm !== 'string') { + return ''; + } + + // Trim whitespace + let sanitized = searchTerm.trim(); + + // Limit length (DoS prevention) + const MAX_SEARCH_LENGTH = 100; + if (sanitized.length > MAX_SEARCH_LENGTH) { + sanitized = sanitized.substring(0, MAX_SEARCH_LENGTH); + } + + // Remove potentially dangerous SQL pattern wildcards from user input + // We'll add our own wildcards for the LIKE query + // Note: Drizzle ORM escapes parameters, but this adds defense-in-depth + sanitized = sanitized.replace(/[%_\\]/g, ''); + + // Remove null bytes and other control characters + sanitized = sanitized.replace(/[\x00-\x1F\x7F]/g, ''); + + return sanitized; +} + /** * Check if LoTW response indicates the report is still being prepared */ @@ -419,12 +448,16 @@ export async function getUserQSOs(userId, filters = {}, options = {}) { // Search filter: callsign, entity, or grid if (filters.search) { - const searchTerm = `%${filters.search}%`; - conditions.push(or( - like(qsos.callsign, searchTerm), - like(qsos.entity, searchTerm), - like(qsos.grid, searchTerm) - )); + // SECURITY: Sanitize search input to prevent injection + const sanitized = sanitizeSearchInput(filters.search); + if (sanitized) { + const searchTerm = `%${sanitized}%`; + conditions.push(or( + like(qsos.callsign, searchTerm), + like(qsos.entity, searchTerm), + like(qsos.grid, searchTerm) + )); + } } // Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)