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

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

View File

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

View File

@@ -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;
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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'));
}
},
});

View File

@@ -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'));
}
},
});