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