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:
14
server/package-lock.json
generated
14
server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user