fix(security): address critical and high findings from security audit
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>
This commit is contained in:
+13
-2
@@ -286,8 +286,8 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 });
|
||||
}
|
||||
|
||||
if (username.length > 64) {
|
||||
return res.status(400).json({ error: 'Benutzername darf maximal 64 Zeichen lang sein.', code: 400 });
|
||||
if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
|
||||
return res.status(400).json({ error: 'Benutzername muss 3-64 Zeichen lang sein und darf nur Buchstaben, Zahlen, Punkte, Bindestriche und Unterstriche enthalten.', code: 400 });
|
||||
}
|
||||
|
||||
if (display_name.length > 128) {
|
||||
@@ -384,6 +384,17 @@ router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
|
||||
}
|
||||
|
||||
// Alle aktiven Sessions des geloeschten Users invalidieren
|
||||
const allSessions = db.get().prepare('SELECT sid, sess FROM sessions').all();
|
||||
for (const row of allSessions) {
|
||||
try {
|
||||
const sess = JSON.parse(row.sess);
|
||||
if (sess.userId === userId) {
|
||||
db.get().prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid);
|
||||
}
|
||||
} catch { /* ignore malformed session */ }
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Auth] User-Löschen-Fehler:', err);
|
||||
|
||||
+3
-2
@@ -60,8 +60,9 @@ app.use(helmet({
|
||||
} : false,
|
||||
}));
|
||||
|
||||
// Trust Proxy für korrekte IP hinter Nginx
|
||||
app.set('trust proxy', 1);
|
||||
// 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
|
||||
|
||||
@@ -147,14 +147,19 @@ router.get('/export', (req, res) => {
|
||||
`).all(from, to);
|
||||
|
||||
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n';
|
||||
const csvSafe = (val) => {
|
||||
let s = String(val || '').replace(/"/g, '""');
|
||||
if (/^[=+\-@\t\r]/.test(s)) s = "'" + s;
|
||||
return `"${s}"`;
|
||||
};
|
||||
const rows = entries.map((e) =>
|
||||
[
|
||||
e.date,
|
||||
`"${(e.title || '').replace(/"/g, '""')}"`,
|
||||
csvSafe(e.title),
|
||||
e.amount.toFixed(2).replace('.', ','),
|
||||
e.category,
|
||||
e.is_recurring ? 'Ja' : 'Nein',
|
||||
`"${(e.creator_name || '').replace(/"/g, '""')}"`,
|
||||
csvSafe(e.creator_name),
|
||||
].join(',')
|
||||
).join('\n');
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ router.get('/upcoming', (req, res) => {
|
||||
*/
|
||||
router.get('/google/auth', requireAdmin, (req, res) => {
|
||||
try {
|
||||
const url = googleCalendar.getAuthUrl();
|
||||
const url = googleCalendar.getAuthUrl(req.session);
|
||||
if (!url) return res.status(503).json({ error: 'Google nicht konfiguriert.', code: 503 });
|
||||
res.redirect(url);
|
||||
} catch (err) {
|
||||
@@ -222,10 +222,17 @@ router.get('/google/auth', requireAdmin, (req, res) => {
|
||||
*/
|
||||
router.get('/google/callback', async (req, res) => {
|
||||
try {
|
||||
const { code, error } = req.query;
|
||||
const { code, error, state } = req.query;
|
||||
if (error) return res.redirect('/settings?sync_error=google');
|
||||
if (!code) return res.status(400).json({ error: 'Kein Code erhalten.', code: 400 });
|
||||
|
||||
// OAuth CSRF-Schutz: state-Parameter validieren
|
||||
if (!state || !req.session.googleOAuthState || state !== req.session.googleOAuthState) {
|
||||
console.error('[calendar/google/callback] OAuth state mismatch');
|
||||
return res.redirect('/settings?sync_error=google');
|
||||
}
|
||||
delete req.session.googleOAuthState;
|
||||
|
||||
await googleCalendar.handleCallback(code);
|
||||
|
||||
// Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen)
|
||||
@@ -243,7 +250,7 @@ router.get('/google/callback', async (req, res) => {
|
||||
* Manueller Sync-Trigger.
|
||||
* Response: { ok: true, lastSync: string }
|
||||
*/
|
||||
router.post('/google/sync', async (req, res) => {
|
||||
router.post('/google/sync', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await googleCalendar.sync();
|
||||
const { lastSync } = googleCalendar.getStatus();
|
||||
@@ -304,7 +311,7 @@ router.get('/apple/status', (req, res) => {
|
||||
* Manueller Sync-Trigger.
|
||||
* Response: { ok: true, lastSync: string }
|
||||
*/
|
||||
router.post('/apple/sync', async (req, res) => {
|
||||
router.post('/apple/sync', requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await appleCalendar.sync();
|
||||
const { lastSync } = appleCalendar.getStatus();
|
||||
|
||||
@@ -53,6 +53,10 @@ function getCredentials() {
|
||||
}
|
||||
|
||||
function saveCredentials(url, username, password) {
|
||||
// Warnung wenn DB-Verschluesselung nicht aktiv - Credentials liegen dann im Klartext
|
||||
if (!process.env.DB_ENCRYPTION_KEY) {
|
||||
console.warn('[Apple] WARNUNG: DB_ENCRYPTION_KEY nicht gesetzt - CalDAV-Credentials werden unverschluesselt gespeichert.');
|
||||
}
|
||||
cfgSet('apple_caldav_url', url);
|
||||
cfgSet('apple_username', username);
|
||||
cfgSet('apple_app_password', password);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
'use strict';
|
||||
|
||||
const { google } = require('googleapis');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
|
||||
const GOOGLE_COLOR = '#4285F4';
|
||||
@@ -92,12 +93,21 @@ function loadAuthorizedClient() {
|
||||
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
|
||||
* @returns {string} Auth-URL
|
||||
*/
|
||||
function getAuthUrl() {
|
||||
/**
|
||||
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
|
||||
* Enthalt einen CSRF-sicheren state-Parameter.
|
||||
* @param {object} session - Express-Session-Objekt (state wird dort gespeichert)
|
||||
* @returns {string} Auth-URL
|
||||
*/
|
||||
function getAuthUrl(session) {
|
||||
const client = createClient();
|
||||
const state = crypto.randomBytes(32).toString('hex');
|
||||
if (session) session.googleOAuthState = state;
|
||||
return client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
scope: ['https://www.googleapis.com/auth/calendar'],
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user