From 3d2604bab91bb974f9e21565bf4989576deaa0d1 Mon Sep 17 00:00:00 2001 From: Ulas Date: Fri, 3 Apr 2026 17:28:36 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 22 +++++++++++++++++++++- package.json | 2 +- public/pages/settings.js | 17 +++++++++++++---- public/pages/tasks.js | 15 ++++++++++++--- server/auth.js | 15 +++++++++++++-- server/index.js | 5 +++-- server/routes/budget.js | 9 +++++++-- server/routes/calendar.js | 15 +++++++++++---- server/services/apple-calendar.js | 4 ++++ server/services/google-calendar.js | 12 +++++++++++- 10 files changed, 96 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9deedb..967f988 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.9] - 2026-04-03 + +### Security +- Fix stored XSS in task titles and subtask titles - all user-provided text in tasks.js is now escaped via `escHtml()` before insertion into innerHTML templates +- Fix stored XSS in settings page member list - display_name and username are now escaped via `escHtml()` in `memberHtml()` +- Fix rate limiter bypass via X-Forwarded-For IP spoofing - `trust proxy` now defaults to `loopback` instead of unconditional `1`; configurable via `TRUST_PROXY` env var +- Fix Google OAuth CSRF - add cryptographic `state` parameter to OAuth flow, validated on callback +- Fix CSV injection in budget export - fields starting with `=`, `+`, `-`, `@`, tab, or carriage return are now prefixed with apostrophe +- Fix missing session invalidation on user deletion - all active sessions of deleted users are now destroyed +- Restrict username to `[a-zA-Z0-9._-]` with minimum 3 characters, preventing HTML/script injection via usernames +- Restrict Google Calendar sync trigger (`POST /google/sync`) and Apple Calendar sync trigger (`POST /apple/sync`) to admin role +- Add warning log when Apple CalDAV credentials are stored without DB encryption enabled + ## [0.5.8] - 2026-04-03 ### Added @@ -184,7 +197,14 @@ Initial release of Oikos - a self-hosted family planner for 2–6 person househo - No user data cached by service worker (API requests are network-only) - Hardened `.gitignore` and `.dockerignore` to prevent accidental secret or binary leakage -[Unreleased]: https://github.com/ulsklyc/oikos/compare/v0.5.2...HEAD +[Unreleased]: https://github.com/ulsklyc/oikos/compare/v0.5.9...HEAD +[0.5.9]: https://github.com/ulsklyc/oikos/compare/v0.5.8...v0.5.9 +[0.5.8]: https://github.com/ulsklyc/oikos/compare/v0.5.7...v0.5.8 +[0.5.7]: https://github.com/ulsklyc/oikos/compare/v0.5.6...v0.5.7 +[0.5.6]: https://github.com/ulsklyc/oikos/compare/v0.5.5...v0.5.6 +[0.5.5]: https://github.com/ulsklyc/oikos/compare/v0.5.4...v0.5.5 +[0.5.4]: https://github.com/ulsklyc/oikos/compare/v0.5.3...v0.5.4 +[0.5.3]: https://github.com/ulsklyc/oikos/compare/v0.5.2...v0.5.3 [0.5.2]: https://github.com/ulsklyc/oikos/compare/v0.5.1...v0.5.2 [0.5.1]: https://github.com/ulsklyc/oikos/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/ulsklyc/oikos/compare/v0.4.0...v0.5.0 diff --git a/package.json b/package.json index 8b8cd0b..5a49f8c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.5.8", + "version": "0.5.9", "description": "Selbstgehosteter Familienplaner - Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.", "main": "server/index.js", "engines": { diff --git a/public/pages/settings.js b/public/pages/settings.js index e47f380..0bd7406 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -488,15 +488,24 @@ function bindDeleteButtons(container, user) { // Helfer // -------------------------------------------------------- +function escHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + function memberHtml(u) { return `
  • -
    ${initials(u.display_name)}
    +
    ${initials(u.display_name)}
    - ${u.display_name} - @${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')} + ${escHtml(u.display_name)} + @${escHtml(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}
    -
  • diff --git a/public/pages/tasks.js b/public/pages/tasks.js index b305a6e..97e39cf 100644 --- a/public/pages/tasks.js +++ b/public/pages/tasks.js @@ -10,6 +10,15 @@ import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSucces import { stagger, vibrate } from '/utils/ux.js'; import { t, formatDate } from '/i18n.js'; +function escHtml(str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + // -------------------------------------------------------- // Konstanten // -------------------------------------------------------- @@ -155,7 +164,7 @@ function renderTaskCard(task, opts = {}) { data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}"> ${s.status === 'done' ? '' : ''} - ${s.title} + ${escHtml(s.title)} `).join('') : ''; @@ -170,7 +179,7 @@ function renderTaskCard(task, opts = {}) {
    - ${task.title} + ${escHtml(task.title)}
    ${renderPriorityBadge(task.priority)} @@ -504,7 +513,7 @@ function renderKanbanCard(task) { return `
    -
    ${task.title}
    +
    ${escHtml(task.title)}
    ${renderPriorityBadge(task.priority)} ${due ? ` ${due.label}` : ''} diff --git a/server/auth.js b/server/auth.js index 2765a43..0de0e60 100644 --- a/server/auth.js +++ b/server/auth.js @@ -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); diff --git a/server/index.js b/server/index.js index ed3d463..44797b4 100644 --- a/server/index.js +++ b/server/index.js @@ -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 diff --git a/server/routes/budget.js b/server/routes/budget.js index fbf2832..98722f1 100644 --- a/server/routes/budget.js +++ b/server/routes/budget.js @@ -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'); diff --git a/server/routes/calendar.js b/server/routes/calendar.js index a8e41e3..835c3b2 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -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(); diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js index 45f6bf2..042ad36 100644 --- a/server/services/apple-calendar.js +++ b/server/services/apple-calendar.js @@ -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); diff --git a/server/services/google-calendar.js b/server/services/google-calendar.js index 81cbb0c..a783580 100644 --- a/server/services/google-calendar.js +++ b/server/services/google-calendar.js @@ -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, }); }