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:
@@ -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}`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user