From d845057f841477a4922195ac159ded3f7c959b4f Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 21 Mar 2026 15:09:41 +0100 Subject: [PATCH] Security hardening, backup restore fix & restore warning modal - Fix backup restore: try/finally ensures DB always reopens after closeDb - Fix EBUSY on uploads during restore (in-place overwrite instead of rmSync) - Add DB proxy null guard for clearer errors during restore window - Add red warning modal before backup restore (DE/EN, dark mode support) - JWT secret: empty docker-compose default so auto-generation kicks in - OIDC: pass token via URL fragment instead of query param (no server logs) - Block SVG uploads on photos, files and covers (stored XSS prevention) - Add helmet for security headers (HSTS, X-Frame, nosniff, etc.) - Explicit express.json body size limit (100kb) - Fix XSS in Leaflet map markers (escape image_url in HTML) - Remove verbose WebSocket debug logging from client --- client/src/api/websocket.js | 6 +- client/src/components/Admin/BackupPanel.jsx | 122 ++++++++++++++++---- client/src/components/Map/MapView.jsx | 7 +- client/src/pages/LoginPage.jsx | 6 +- docker-compose.yml | 2 +- server/package-lock.json | 14 ++- server/package.json | 1 + server/src/db/database.js | 1 + server/src/index.js | 16 +-- server/src/routes/backup.js | 40 ++++--- server/src/routes/files.js | 5 + server/src/routes/oidc.js | 2 +- server/src/routes/photos.js | 6 +- server/src/routes/trips.js | 9 +- 14 files changed, 175 insertions(+), 62 deletions(-) diff --git a/client/src/api/websocket.js b/client/src/api/websocket.js index d17a0b1..c0c1871 100644 --- a/client/src/api/websocket.js +++ b/client/src/api/websocket.js @@ -29,10 +29,8 @@ function handleMessage(event) { // Store our socket ID from welcome message if (parsed.type === 'welcome') { mySocketId = parsed.socketId - console.log('[WS] Got socketId:', mySocketId) return } - console.log('[WS] Received:', parsed.type, parsed) listeners.forEach(fn => { try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) } }) @@ -61,14 +59,14 @@ function connectInternal(token, isReconnect = false) { socket = new WebSocket(url) socket.onopen = () => { - console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)') + // connection established reconnectDelay = 1000 // Join active trips on any connect (initial or reconnect) if (activeTrips.size > 0) { activeTrips.forEach(tripId => { if (socket && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'join', tripId })) - console.log('[WS] Joined trip', tripId) + // joined trip room } }) // Refetch trip data for active trips diff --git a/client/src/components/Admin/BackupPanel.jsx b/client/src/components/Admin/BackupPanel.jsx index 47c3f8b..d50a5d5 100644 --- a/client/src/components/Admin/BackupPanel.jsx +++ b/client/src/components/Admin/BackupPanel.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react' import { backupApi } from '../../api/client' import { useToast } from '../shared/Toast' -import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react' +import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react' import { useTranslation } from '../../i18n' const INTERVAL_OPTIONS = [ @@ -29,9 +29,10 @@ export default function BackupPanel() { const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 }) const [autoSettingsSaving, setAutoSettingsSaving] = useState(false) const [autoSettingsDirty, setAutoSettingsDirty] = useState(false) + const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? } const fileInputRef = useRef(null) const toast = useToast() - const { t, locale } = useTranslation() + const { t, language, locale } = useTranslation() const loadBackups = async () => { setIsLoading(true) @@ -67,32 +68,42 @@ export default function BackupPanel() { } } - const handleRestore = async (filename) => { - if (!confirm(t('backup.confirm.restore', { name: filename }))) return - setRestoringFile(filename) - try { - await backupApi.restore(filename) - toast.success(t('backup.toast.restored')) - setTimeout(() => window.location.reload(), 1500) - } catch (err) { - toast.error(err.response?.data?.error || t('backup.toast.restoreError')) - setRestoringFile(null) - } + const handleRestore = (filename) => { + setRestoreConfirm({ type: 'file', filename }) } - const handleUploadRestore = async (e) => { + const handleUploadRestore = (e) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' - if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return - setIsUploading(true) - try { - await backupApi.uploadRestore(file) - toast.success(t('backup.toast.restored')) - setTimeout(() => window.location.reload(), 1500) - } catch (err) { - toast.error(err.response?.data?.error || t('backup.toast.uploadError')) - setIsUploading(false) + setRestoreConfirm({ type: 'upload', filename: file.name, file }) + } + + const executeRestore = async () => { + if (!restoreConfirm) return + const { type, filename, file } = restoreConfirm + setRestoreConfirm(null) + + if (type === 'file') { + setRestoringFile(filename) + try { + await backupApi.restore(filename) + toast.success(t('backup.toast.restored')) + setTimeout(() => window.location.reload(), 1500) + } catch (err) { + toast.error(err.response?.data?.error || t('backup.toast.restoreError')) + setRestoringFile(null) + } + } else { + setIsUploading(true) + try { + await backupApi.uploadRestore(file) + toast.success(t('backup.toast.restored')) + setTimeout(() => window.location.reload(), 1500) + } catch (err) { + toast.error(err.response?.data?.error || t('backup.toast.uploadError')) + setIsUploading(false) + } } } @@ -357,6 +368,71 @@ export default function BackupPanel() { + + {/* Restore Warning Modal */} + {restoreConfirm && ( +
setRestoreConfirm(null)} + > +
e.stopPropagation()} + style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }} + className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700" + > + {/* Red header */} +
+
+ +
+
+

+ {language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'} +

+

+ {restoreConfirm.filename} +

+
+
+ + {/* Body */} +
+

+ {language === 'de' + ? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.' + : 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'} +

+ +
+ {language === 'de' + ? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.' + : 'Tip: Create a backup of the current state before restoring.'} +
+
+ + {/* Footer */} +
+ + +
+
+
+ )} ) } diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.jsx index 98b4664..08d2241 100644 --- a/client/src/components/Map/MapView.jsx +++ b/client/src/components/Map/MapView.jsx @@ -19,6 +19,11 @@ L.Icon.Default.mergeOptions({ * Create a round photo-circle marker. * Shows image_url if available, otherwise category icon in colored circle. */ +function escAttr(s) { + if (!s) return '' + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') +} + function createPlaceIcon(place, orderNumber, isSelected) { const size = isSelected ? 44 : 36 const borderColor = isSelected ? '#111827' : 'white' @@ -55,7 +60,7 @@ function createPlaceIcon(place, orderNumber, isSelected) { cursor:pointer;flex-shrink:0;position:relative; ">
- +
${badgeHtml} `, diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx index 9bf346c..50a8b5f 100644 --- a/client/src/pages/LoginPage.jsx +++ b/client/src/pages/LoginPage.jsx @@ -29,9 +29,11 @@ export default function LoginPage() { } }) - // Handle OIDC callback token + // Handle OIDC callback token (via URL fragment to avoid logging) + const hash = window.location.hash.substring(1) + const hashParams = new URLSearchParams(hash) + const token = hashParams.get('token') const params = new URLSearchParams(window.location.search) - const token = params.get('token') const oidcError = params.get('oidc_error') if (token) { localStorage.setItem('auth_token', token) diff --git a/docker-compose.yml b/docker-compose.yml index 4fab5db..b311145 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: - "3000:3000" environment: - NODE_ENV=production - - JWT_SECRET=${JWT_SECRET:-change-me-to-a-long-random-string} + - JWT_SECRET=${JWT_SECRET:-} # - ALLOWED_ORIGINS=https://yourdomain.com # Optional: restrict CORS to specific origins - PORT=3000 volumes: diff --git a/server/package-lock.json b/server/package-lock.json index d6e8769..fe49ca1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,18 +1,19 @@ { "name": "nomad-server", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nomad-server", - "version": "2.4.1", + "version": "2.5.0", "dependencies": { "archiver": "^6.0.1", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", @@ -995,6 +996,15 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz", + "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/server/package.json b/server/package.json index bf46111..7d48676 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^16.4.1", "express": "^4.18.3", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", "multer": "^1.4.5-lts.1", "node-cron": "^4.2.1", diff --git a/server/src/db/database.js b/server/src/db/database.js index fe5f909..bf212df 100644 --- a/server/src/db/database.js +++ b/server/src/db/database.js @@ -421,6 +421,7 @@ if (process.env.DEMO_MODE === 'true') { // without needing a server restart after reinitialize() const db = new Proxy({}, { get(_, prop) { + if (!_db) throw new Error('Database connection is not available (restore in progress?)'); const val = _db[prop]; return typeof val === 'function' ? val.bind(_db) : val; }, diff --git a/server/src/index.js b/server/src/index.js index 0ca3fa3..93493ed 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -1,6 +1,7 @@ require('dotenv').config(); const express = require('express'); const cors = require('cors'); +const helmet = require('helmet'); const path = require('path'); const fs = require('fs'); @@ -42,16 +43,11 @@ app.use(cors({ origin: corsOrigin, credentials: true })); -app.use(express.json()); - -// Security headers -app.use((req, res, next) => { - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('X-Frame-Options', 'SAMEORIGIN'); - res.setHeader('X-XSS-Protection', '1; mode=block'); - res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin'); - next(); -}); +app.use(helmet({ + contentSecurityPolicy: false, // managed by frontend meta tag or reverse proxy + crossOriginEmbedderPolicy: false, // allows loading external images (maps, etc.) +})); +app.use(express.json({ limit: '100kb' })); app.use(express.urlencoded({ extended: true })); // Serve uploaded files diff --git a/server/src/routes/backup.js b/server/src/routes/backup.js index 6b7b157..cc9d87a 100644 --- a/server/src/routes/backup.js +++ b/server/src/routes/backup.js @@ -138,25 +138,37 @@ async function restoreFromZip(zipPath, res) { // Step 1: close DB connection BEFORE touching the file (required on Windows) closeDb(); - // Step 2: remove WAL/SHM and overwrite DB file - const dbDest = path.join(dataDir, 'travel.db'); - for (const ext of ['', '-wal', '-shm']) { - try { fs.unlinkSync(dbDest + ext); } catch (e) {} - } - fs.copyFileSync(extractedDb, dbDest); + try { + // Step 2: remove WAL/SHM and overwrite DB file + const dbDest = path.join(dataDir, 'travel.db'); + for (const ext of ['', '-wal', '-shm']) { + try { fs.unlinkSync(dbDest + ext); } catch (e) {} + } + fs.copyFileSync(extractedDb, dbDest); - // Step 3: restore uploads - const extractedUploads = path.join(extractDir, 'uploads'); - if (fs.existsSync(extractedUploads)) { - if (fs.existsSync(uploadsDir)) fs.rmSync(uploadsDir, { recursive: true, force: true }); - fs.cpSync(extractedUploads, uploadsDir, { recursive: true }); + // Step 3: restore uploads — overwrite in-place instead of rmSync + // (rmSync fails with EBUSY because express.static holds the directory) + const extractedUploads = path.join(extractDir, 'uploads'); + if (fs.existsSync(extractedUploads)) { + // Clear contents of each subdirectory without removing the root uploads dir + for (const sub of fs.readdirSync(uploadsDir)) { + const subPath = path.join(uploadsDir, sub); + if (fs.statSync(subPath).isDirectory()) { + for (const file of fs.readdirSync(subPath)) { + try { fs.unlinkSync(path.join(subPath, file)); } catch (e) {} + } + } + } + // Copy restored files over + fs.cpSync(extractedUploads, uploadsDir, { recursive: true, force: true }); + } + } finally { + // Step 4: ALWAYS reopen DB — even if file copy failed, so the server stays functional + reinitialize(); } fs.rmSync(extractDir, { recursive: true, force: true }); - // Step 4: reopen DB with restored data - reinitialize(); - res.json({ success: true }); } catch (err) { console.error('Restore error:', err); diff --git a/server/src/routes/files.js b/server/src/routes/files.js index cca09e2..87519ef 100644 --- a/server/src/routes/files.js +++ b/server/src/routes/files.js @@ -35,6 +35,11 @@ const upload = multer({ 'text/plain', 'text/csv', ]; + const ext = path.extname(file.originalname).toLowerCase(); + const blockedExts = ['.svg', '.html', '.htm', '.xml']; + if (blockedExts.includes(ext) || file.mimetype.includes('svg')) { + return cb(new Error('File type not allowed')); + } if (allowed.includes(file.mimetype) || file.mimetype.startsWith('image/')) { cb(null, true); } else { diff --git a/server/src/routes/oidc.js b/server/src/routes/oidc.js index 96b7a4d..e9ad340 100644 --- a/server/src/routes/oidc.js +++ b/server/src/routes/oidc.js @@ -196,7 +196,7 @@ router.get('/callback', async (req, res) => { // Generate JWT and redirect to frontend const token = generateToken(user); // In dev mode, frontend runs on a different port - res.redirect(frontendUrl(`/login?token=${token}`)); + res.redirect(frontendUrl(`/login#token=${token}`)); } catch (err) { console.error('[OIDC] Callback error:', err); res.redirect(frontendUrl('/login?oidc_error=server_error')); diff --git a/server/src/routes/photos.js b/server/src/routes/photos.js index c9bf568..420d404 100644 --- a/server/src/routes/photos.js +++ b/server/src/routes/photos.js @@ -25,10 +25,12 @@ const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10MB fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith('image/')) { + const ext = path.extname(file.originalname).toLowerCase(); + const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) { cb(null, true); } else { - cb(new Error('Nur Bilddateien sind erlaubt')); + cb(new Error('Only jpg, png, gif, webp images allowed')); } }, }); diff --git a/server/src/routes/trips.js b/server/src/routes/trips.js index 87db2fc..9548617 100644 --- a/server/src/routes/trips.js +++ b/server/src/routes/trips.js @@ -24,8 +24,13 @@ const uploadCover = multer({ storage: coverStorage, limits: { fileSize: 20 * 1024 * 1024 }, fileFilter: (req, file, cb) => { - if (file.mimetype.startsWith('image/')) cb(null, true); - else cb(new Error('Nur Bilder erlaubt')); + const ext = path.extname(file.originalname).toLowerCase(); + const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp']; + if (file.mimetype.startsWith('image/') && !file.mimetype.includes('svg') && allowedExts.includes(ext)) { + cb(null, true); + } else { + cb(new Error('Only jpg, png, gif, webp images allowed')); + } }, });