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
This commit is contained in:
Maurice
2026-03-21 15:09:41 +01:00
parent e70fe50ae3
commit d845057f84
14 changed files with 175 additions and 62 deletions

View File

@@ -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

View File

@@ -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() {
</div>
</div>
</div>
{/* Restore Warning Modal */}
{restoreConfirm && (
<div
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={() => setRestoreConfirm(null)}
>
<div
onClick={e => 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 */}
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<AlertTriangle size={20} style={{ color: 'white' }} />
</div>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
</h3>
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
{restoreConfirm.filename}
</p>
</div>
</div>
{/* Body */}
<div style={{ padding: '20px 24px' }}>
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
{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.'}
</p>
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
>
{language === 'de'
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
: 'Tip: Create a backup of the current state before restoring.'}
</div>
</div>
{/* Footer */}
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setRestoreConfirm(null)}
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
>
{language === 'de' ? 'Abbrechen' : 'Cancel'}
</button>
<button
onClick={executeRestore}
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
>
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
<img src="${escAttr(place.image_url)}" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,

View File

@@ -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)