3d2604bab9
Fix stored XSS in tasks (titles/subtasks) and settings (member list) by applying escHtml(). Harden trust proxy to loopback default, add OAuth state parameter for Google Calendar CSRF protection, sanitize CSV export against formula injection, invalidate sessions on user deletion, restrict usernames to alphanumeric chars, and require admin role for calendar sync triggers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
230 lines
8.8 KiB
JavaScript
230 lines
8.8 KiB
JavaScript
/**
|
|
* Modul: Server Entry Point
|
|
* Zweck: Express-App initialisieren, Middleware einbinden, Routen registrieren
|
|
* Abhängigkeiten: express, helmet, dotenv, server/db.js, server/auth.js, server/routes/*
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
require('dotenv').config();
|
|
const express = require('express');
|
|
const helmet = require('helmet');
|
|
const rateLimit = require('express-rate-limit');
|
|
const path = require('path');
|
|
|
|
// --------------------------------------------------------
|
|
// Datenbank initialisieren (muss vor require('./auth') stehen,
|
|
// da BetterSQLiteStore im Konstruktor db.get() aufruft)
|
|
// --------------------------------------------------------
|
|
const db = require('./db');
|
|
db.init();
|
|
|
|
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
|
|
const { csrfMiddleware } = require('./middleware/csrf');
|
|
const googleCalendar = require('./services/google-calendar');
|
|
const appleCalendar = require('./services/apple-calendar');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
|
|
// --------------------------------------------------------
|
|
// Security-Middleware
|
|
// --------------------------------------------------------
|
|
const isSecure = process.env.SESSION_SECURE !== 'false';
|
|
app.use(helmet({
|
|
contentSecurityPolicy: {
|
|
directives: {
|
|
defaultSrc: ["'self'"],
|
|
scriptSrc: [
|
|
"'self'",
|
|
// Inline-Script: Theme-Detection (Flash-Prevention)
|
|
"'sha256-vqqBNo1oitnzIntwkG83UaYqkUAnV/oZ/RkvcA41Y6A='",
|
|
// Alpine.js CDN (optional, falls verwendet)
|
|
'https://cdn.jsdelivr.net',
|
|
],
|
|
styleSrc: ["'self'", "'unsafe-inline'"],
|
|
imgSrc: ["'self'", 'data:'],
|
|
connectSrc: ["'self'"],
|
|
fontSrc: ["'self'"],
|
|
objectSrc: ["'none'"],
|
|
frameSrc: ["'none'"],
|
|
// upgrade-insecure-requests nur mit HTTPS aktivieren
|
|
upgradeInsecureRequests: isSecure ? [] : null,
|
|
},
|
|
},
|
|
// HSTS nur mit HTTPS aktivieren
|
|
hsts: isSecure ? {
|
|
maxAge: 31536000,
|
|
includeSubDomains: true,
|
|
preload: true,
|
|
} : false,
|
|
}));
|
|
|
|
// Trust Proxy: nur aktivieren wenn ein Reverse Proxy vorgeschaltet ist (TRUST_PROXY env var).
|
|
// Default 'loopback' akzeptiert nur X-Forwarded-For von localhost - verhindert IP-Spoofing.
|
|
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
|
|
|
|
// --------------------------------------------------------
|
|
// Request-Parsing
|
|
// --------------------------------------------------------
|
|
app.use(express.json({ limit: '1mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
|
|
|
// JSON-Parse-Fehler abfangen (gibt sonst HTML zurück)
|
|
app.use((err, req, res, next) => {
|
|
if (err.type === 'entity.parse.failed') {
|
|
return res.status(400).json({ error: 'Ungültiges JSON im Request-Body.', code: 400 });
|
|
}
|
|
if (err.type === 'entity.too.large') {
|
|
return res.status(413).json({ error: 'Request-Body zu groß (max. 1 MB).', code: 413 });
|
|
}
|
|
next(err);
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Sessions
|
|
// --------------------------------------------------------
|
|
app.use(sessionMiddleware);
|
|
|
|
// --------------------------------------------------------
|
|
// API-Antworten: kein Browser-Caching (Sicherheit + Aktualität)
|
|
// --------------------------------------------------------
|
|
app.use('/api/', (req, res, next) => {
|
|
res.setHeader('Cache-Control', 'no-store');
|
|
next();
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Statische Dateien (Frontend) - differenzierte Caching-Strategie
|
|
//
|
|
// HTML + JS + CSS: no-cache (Browser revalidiert via ETag/304, kein stale Content
|
|
// nach Deployment). Bei unverändertem File → 304 Not Modified ohne Übertragung.
|
|
// Bilder + Icons + Fonts: 30 Tage immutable (ändern sich praktisch nie).
|
|
// manifest.json + sw.js: no-cache (PWA-Updates sollen sofort greifen).
|
|
// --------------------------------------------------------
|
|
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
|
etag: true,
|
|
lastModified: true,
|
|
setHeaders(res, filePath) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
const isPwaIcon = /\/icons\/(icon-|apple-touch-icon|favicon)/.test(filePath);
|
|
if (isPwaIcon) {
|
|
// PWA-Icons müssen bei Deployments sofort aktualisiert werden
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
} else if (['.png', '.jpg', '.jpeg', '.ico', '.svg', '.webp', '.woff2', '.woff'].includes(ext)) {
|
|
res.setHeader('Cache-Control', 'public, max-age=2592000, immutable'); // 30 Tage
|
|
} else {
|
|
// HTML, JS, CSS, JSON, manifest, sw - immer revalidieren
|
|
res.setHeader('Cache-Control', 'no-cache, must-revalidate');
|
|
}
|
|
// manifest.json: korrekter MIME-Type für PWA-Erkennung durch Chrome/Android
|
|
if (filePath.endsWith('manifest.json')) {
|
|
res.setHeader('Content-Type', 'application/manifest+json; charset=utf-8');
|
|
}
|
|
},
|
|
}));
|
|
|
|
// --------------------------------------------------------
|
|
// Globaler API-Rate-Limiter (Schritt 29)
|
|
// Verhindert Brute-Force und DoS auf allen API-Endpunkten.
|
|
// Login hat einen eigenen, strengeren Limiter (auth.js).
|
|
// --------------------------------------------------------
|
|
const apiLimiter = rateLimit({
|
|
windowMs: 60_000, // 1 Minute
|
|
max: 300, // 300 Requests/Minute pro IP (großzügig für Familien-App)
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 },
|
|
skip: (req) => req.path === '/health', // Health-Check ausgenommen
|
|
});
|
|
app.use('/api/', apiLimiter);
|
|
|
|
// --------------------------------------------------------
|
|
// API-Routen
|
|
// --------------------------------------------------------
|
|
app.use('/api/v1/auth', authRouter);
|
|
|
|
// Alle weiteren API-Routen erfordern Authentifizierung + CSRF-Schutz
|
|
app.use('/api/v1', requireAuth);
|
|
app.use('/api/v1', csrfMiddleware);
|
|
app.use('/api/v1/dashboard', require('./routes/dashboard'));
|
|
app.use('/api/v1/tasks', require('./routes/tasks'));
|
|
app.use('/api/v1/shopping', require('./routes/shopping'));
|
|
app.use('/api/v1/meals', require('./routes/meals'));
|
|
app.use('/api/v1/calendar', require('./routes/calendar'));
|
|
app.use('/api/v1/notes', require('./routes/notes'));
|
|
app.use('/api/v1/contacts', require('./routes/contacts'));
|
|
app.use('/api/v1/budget', require('./routes/budget'));
|
|
app.use('/api/v1/weather', require('./routes/weather'));
|
|
|
|
// --------------------------------------------------------
|
|
// Health-Check (für Docker)
|
|
// --------------------------------------------------------
|
|
app.get('/health', (req, res) => {
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Rate-Limiter für SPA-Fallback (verhindert Dateisystem-Hammering)
|
|
// --------------------------------------------------------
|
|
const spaLimiter = rateLimit({
|
|
windowMs: 60_000,
|
|
max: 200,
|
|
standardHeaders: true,
|
|
legacyHeaders: false,
|
|
message: { error: 'Zu viele Anfragen. Bitte warte kurz.', code: 429 },
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// SPA Fallback: Alle nicht-API-Routen → index.html
|
|
// --------------------------------------------------------
|
|
app.get('*', spaLimiter, (req, res) => {
|
|
if (req.path.startsWith('/api/')) {
|
|
return res.status(404).json({ error: 'Nicht gefunden.', code: 404 });
|
|
}
|
|
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Globaler Error-Handler
|
|
// --------------------------------------------------------
|
|
app.use((err, req, res, _next) => {
|
|
console.error('[Server] Unbehandelter Fehler:', err);
|
|
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
|
});
|
|
|
|
// --------------------------------------------------------
|
|
// Auto-Sync Scheduler (Google + Apple Calendar)
|
|
// --------------------------------------------------------
|
|
|
|
const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15) * 60_000;
|
|
|
|
async function runSync() {
|
|
const { connected: googleConnected } = googleCalendar.getStatus();
|
|
if (googleConnected) {
|
|
googleCalendar.sync().catch((e) => console.error('[Sync] Google Fehler:', e.message));
|
|
}
|
|
|
|
const { configured: appleConfigured } = appleCalendar.getStatus();
|
|
if (appleConfigured) {
|
|
appleCalendar.sync().catch((e) => console.error('[Sync] Apple Fehler:', e.message));
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Server starten
|
|
// --------------------------------------------------------
|
|
app.listen(PORT, () => {
|
|
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
|
|
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
|
|
|
|
// Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
|
|
setTimeout(() => {
|
|
runSync();
|
|
setInterval(runSync, SYNC_INTERVAL_MS);
|
|
console.log(`[Sync] Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`);
|
|
}, 10_000);
|
|
});
|
|
|
|
module.exports = app;
|