security: implement multiple security hardening fixes

This commit addresses several critical and high-severity security
vulnerabilities identified in a comprehensive security audit:

Critical Fixes:
- Enforce JWT_SECRET in production (throws error if not set)
- Add JWT token expiration (24 hours)
- Implement path traversal protection for static file serving
- Add rate limiting to authentication endpoints (5/10 req per minute)
- Fix CORS to never allow all origins (even in development)

High/Medium Fixes:
- Add comprehensive security headers (CSP, HSTS, X-Frame-Options, etc.)
- Implement stricter email validation (RFC 5321 compliant)
- Add input sanitization for search parameters (length limit, wildcard removal)
- Improve job/QSO ID validation (range checks, safe integer validation)

Files modified:
- src/backend/config.js: JWT secret enforcement
- src/backend/index.js: JWT expiration, security headers, rate limiting,
  email validation, path traversal protection, CORS hardening, ID validation
- src/backend/services/lotw.service.js: search input sanitization

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-01-20 17:40:31 +01:00
parent 2aebfb0771
commit db0145782a
3 changed files with 283 additions and 36 deletions

View File

@@ -15,7 +15,16 @@ const __dirname = dirname(__filename);
const isDevelopment = process.env.NODE_ENV !== 'production'; 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'); export const LOG_LEVEL = process.env.LOG_LEVEL || (isDevelopment ? 'debug' : 'info');
// =================================================================== // ===================================================================

View File

@@ -1,6 +1,8 @@
import { Elysia, t } from 'elysia'; import { Elysia, t } from 'elysia';
import { cors } from '@elysiajs/cors'; import { cors } from '@elysiajs/cors';
import { jwt } from '@elysiajs/jwt'; 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 { JWT_SECRET, logger, LOG_LEVEL, logToFrontend } from './config.js';
import { import {
registerUser, registerUser,
@@ -33,12 +35,116 @@ import {
* Serves API routes and static frontend files * 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 const ALLOWED_ORIGINS = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',') ? process.env.ALLOWED_ORIGINS.split(',')
: process.env.NODE_ENV === 'production' : process.env.NODE_ENV === 'production'
? [process.env.VITE_APP_URL || 'https://awards.dj7nt.de'] ? [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() const app = new Elysia()
// Enable CORS for frontend communication // 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 // Request logging middleware
.onRequest(({ request, params }) => { .onRequest(({ request, params }) => {
const url = new URL(request.url); const url = new URL(request.url);
@@ -124,7 +270,31 @@ const app = new Elysia()
*/ */
.post( .post(
'/api/auth/register', '/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 // Create user
const user = await registerUser(body); 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({ const token = await jwt.sign({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
exp,
}); });
set.status = 201; set.status = 201;
@@ -175,7 +347,22 @@ const app = new Elysia()
*/ */
.post( .post(
'/api/auth/login', '/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 // Authenticate user
const user = await authenticateUser(body.email, body.password); 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({ const token = await jwt.sign({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
callsign: user.callsign, callsign: user.callsign,
exp,
}); });
return { return {
@@ -389,11 +578,12 @@ const app = new Elysia()
} }
try { try {
const jobId = parseInt(params.jobId); // SECURITY: Validate job ID to prevent injection and DoS
if (isNaN(jobId)) { if (!isValidJobId(params.jobId)) {
set.status = 400; set.status = 400;
return { success: false, error: 'Invalid job ID' }; return { success: false, error: 'Invalid job ID' };
} }
const jobId = parseInt(params.jobId, 10);
const job = await getJobStatus(jobId); const job = await getJobStatus(jobId);
if (!job) { if (!job) {
@@ -498,11 +688,12 @@ const app = new Elysia()
} }
try { try {
const jobId = parseInt(params.jobId); // SECURITY: Validate job ID to prevent injection and DoS
if (isNaN(jobId)) { if (!isValidJobId(params.jobId)) {
set.status = 400; set.status = 400;
return { success: false, error: 'Invalid job ID' }; return { success: false, error: 'Invalid job ID' };
} }
const jobId = parseInt(params.jobId, 10);
const result = await cancelJob(jobId, user.id); const result = await cancelJob(jobId, user.id);
@@ -576,11 +767,12 @@ const app = new Elysia()
} }
try { try {
const qsoId = parseInt(params.id); // SECURITY: Validate QSO ID to prevent injection and DoS
if (isNaN(qsoId)) { if (!isValidQsoId(params.id)) {
set.status = 400; set.status = 400;
return { success: false, error: 'Invalid QSO ID' }; return { success: false, error: 'Invalid QSO ID' };
} }
const qsoId = parseInt(params.id, 10);
const qso = await getQSOById(user.id, qsoId); const qso = await getQSOById(user.id, qsoId);
@@ -844,9 +1036,13 @@ const app = new Elysia()
// Extract the actual file path after %sveltekit.assets%/ // Extract the actual file path after %sveltekit.assets%/
const assetPath = pathname.replace('/%sveltekit.assets%/', ''); const assetPath = pathname.replace('/%sveltekit.assets%/', '');
// SECURITY: Validate path before serving
const assetsDirPath = `_app/immutable/assets/${assetPath}`;
const rootPath = assetPath;
if (isPathSafe(assetsDirPath)) {
try { try {
// Try to serve from assets directory first const assetsPath = `src/frontend/build/${assetsDirPath}`;
const assetsPath = `src/frontend/build/_app/immutable/assets/${assetPath}`;
const file = Bun.file(assetsPath); const file = Bun.file(assetsPath);
const exists = file.exists(); const exists = file.exists();
@@ -856,10 +1052,12 @@ const app = new Elysia()
} catch (err) { } catch (err) {
// Fall through to 404 // Fall through to 404
} }
}
// If not in assets, try root directory // If not in assets, try root directory (with path validation)
if (isPathSafe(rootPath)) {
try { try {
const rootFile = Bun.file(`src/frontend/build/${assetPath}`); const rootFile = Bun.file(`src/frontend/build/${rootPath}`);
const rootExists = rootFile.exists(); const rootExists = rootFile.exists();
if (rootExists) { if (rootExists) {
return new Response(rootFile); return new Response(rootFile);
@@ -867,6 +1065,7 @@ const app = new Elysia()
} catch (err) { } catch (err) {
// Fall through to 404 // Fall through to 404
} }
}
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
} }
@@ -875,6 +1074,12 @@ const app = new Elysia()
// Remove leading slash for file path // Remove leading slash for file path
const filePath = pathname === '/' ? '/index.html' : pathname; 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 { try {
const fullPath = `src/frontend/build${filePath}`; const fullPath = `src/frontend/build${filePath}`;

View File

@@ -15,6 +15,35 @@ const MAX_RETRIES = 30;
const RETRY_DELAY = 10000; const RETRY_DELAY = 10000;
const REQUEST_TIMEOUT = 60000; 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 * Check if LoTW response indicates the report is still being prepared
*/ */
@@ -419,13 +448,17 @@ export async function getUserQSOs(userId, filters = {}, options = {}) {
// Search filter: callsign, entity, or grid // Search filter: callsign, entity, or grid
if (filters.search) { if (filters.search) {
const searchTerm = `%${filters.search}%`; // SECURITY: Sanitize search input to prevent injection
const sanitized = sanitizeSearchInput(filters.search);
if (sanitized) {
const searchTerm = `%${sanitized}%`;
conditions.push(or( conditions.push(or(
like(qsos.callsign, searchTerm), like(qsos.callsign, searchTerm),
like(qsos.entity, searchTerm), like(qsos.entity, searchTerm),
like(qsos.grid, searchTerm) like(qsos.grid, searchTerm)
)); ));
} }
}
// Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory) // Use SQL COUNT for efficient pagination (avoids loading all QSOs into memory)
const [{ count }] = await db const [{ count }] = await db