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:
Ulas
2026-04-03 17:28:36 +02:00
parent 1122bd269b
commit 3d2604bab9
10 changed files with 96 additions and 20 deletions
+21 -1
View File
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.5.8] - 2026-04-03
### Added ### Added
@@ -184,7 +197,14 @@ Initial release of Oikos - a self-hosted family planner for 26 person househo
- No user data cached by service worker (API requests are network-only) - No user data cached by service worker (API requests are network-only)
- Hardened `.gitignore` and `.dockerignore` to prevent accidental secret or binary leakage - 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.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.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 [0.5.0]: https://github.com/ulsklyc/oikos/compare/v0.4.0...v0.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.5.8", "version": "0.5.9",
"description": "Selbstgehosteter Familienplaner - Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.", "description": "Selbstgehosteter Familienplaner - Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.",
"main": "server/index.js", "main": "server/index.js",
"engines": { "engines": {
+13 -4
View File
@@ -488,15 +488,24 @@ function bindDeleteButtons(container, user) {
// Helfer // Helfer
// -------------------------------------------------------- // --------------------------------------------------------
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function memberHtml(u) { function memberHtml(u) {
return ` return `
<li class="settings-member" data-id="${u.id}"> <li class="settings-member" data-id="${u.id}">
<div class="settings-avatar settings-avatar--sm" style="background:${u.avatar_color}">${initials(u.display_name)}</div> <div class="settings-avatar settings-avatar--sm" style="background:${escHtml(u.avatar_color)}">${initials(u.display_name)}</div>
<div class="settings-member__info"> <div class="settings-member__info">
<span class="settings-member__name">${u.display_name}</span> <span class="settings-member__name">${escHtml(u.display_name)}</span>
<span class="settings-member__meta">@${u.username} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span> <span class="settings-member__meta">@${escHtml(u.username)} · ${u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleMember')}</span>
</div> </div>
<button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${u.display_name}" aria-label="${u.display_name} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}"> <button class="btn btn--icon btn--danger-outline" data-delete-user="${u.id}" data-name="${escHtml(u.display_name)}" aria-label="${escHtml(u.display_name)} ${t('settings.deleteMemberLabel')}" title="${t('settings.deleteMemberLabel')}">
<i data-lucide="trash-2" aria-hidden="true"></i> <i data-lucide="trash-2" aria-hidden="true"></i>
</button> </button>
</li> </li>
+12 -3
View File
@@ -10,6 +10,15 @@ import { openModal as openSharedModal, closeModal, wireBlurValidation, btnSucces
import { stagger, vibrate } from '/utils/ux.js'; import { stagger, vibrate } from '/utils/ux.js';
import { t, formatDate } from '/i18n.js'; import { t, formatDate } from '/i18n.js';
function escHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// -------------------------------------------------------- // --------------------------------------------------------
// Konstanten // Konstanten
// -------------------------------------------------------- // --------------------------------------------------------
@@ -155,7 +164,7 @@ function renderTaskCard(task, opts = {}) {
data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}"> data-status="${s.status}" aria-label="${t('tasks.subtaskMarkDone', { title: s.title })}">
${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''} ${s.status === 'done' ? '<i data-lucide="check" style="width:10px;height:10px;color:#fff" aria-hidden="true"></i>' : ''}
</button> </button>
<span class="subtask-item__title">${s.title}</span> <span class="subtask-item__title">${escHtml(s.title)}</span>
</div>`).join('') </div>`).join('')
: ''; : '';
@@ -170,7 +179,7 @@ function renderTaskCard(task, opts = {}) {
<div class="task-card__body"> <div class="task-card__body">
<div class="task-card__title" data-action="open-task" data-id="${task.id}"> <div class="task-card__title" data-action="open-task" data-id="${task.id}">
${task.title} ${escHtml(task.title)}
</div> </div>
<div class="task-card__meta"> <div class="task-card__meta">
${renderPriorityBadge(task.priority)} ${renderPriorityBadge(task.priority)}
@@ -504,7 +513,7 @@ function renderKanbanCard(task) {
return ` return `
<div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}" <div class="kanban-card ${task.status === 'done' ? 'kanban-card--done' : ''}"
data-task-id="${task.id}" draggable="true"> data-task-id="${task.id}" draggable="true">
<div class="kanban-card__title">${task.title}</div> <div class="kanban-card__title">${escHtml(task.title)}</div>
<div class="kanban-card__meta"> <div class="kanban-card__meta">
${renderPriorityBadge(task.priority)} ${renderPriorityBadge(task.priority)}
${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''} ${due ? `<span class="due-date ${due.cls}"><i data-lucide="clock" style="width:10px;height:10px" aria-hidden="true"></i> ${due.label}</span>` : ''}
+13 -2
View File
@@ -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 }); return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben.', code: 400 });
} }
if (username.length > 64) { if (!/^[a-zA-Z0-9._-]{3,64}$/.test(username)) {
return res.status(400).json({ error: 'Benutzername darf maximal 64 Zeichen lang sein.', code: 400 }); 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) { 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 }); 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
console.error('[Auth] User-Löschen-Fehler:', err); console.error('[Auth] User-Löschen-Fehler:', err);
+3 -2
View File
@@ -60,8 +60,9 @@ app.use(helmet({
} : false, } : false,
})); }));
// Trust Proxy für korrekte IP hinter Nginx // Trust Proxy: nur aktivieren wenn ein Reverse Proxy vorgeschaltet ist (TRUST_PROXY env var).
app.set('trust proxy', 1); // Default 'loopback' akzeptiert nur X-Forwarded-For von localhost - verhindert IP-Spoofing.
app.set('trust proxy', process.env.TRUST_PROXY || 'loopback');
// -------------------------------------------------------- // --------------------------------------------------------
// Request-Parsing // Request-Parsing
+7 -2
View File
@@ -147,14 +147,19 @@ router.get('/export', (req, res) => {
`).all(from, to); `).all(from, to);
const header = 'Datum,Titel,Betrag,Kategorie,Wiederkehrend,Erstellt von\n'; 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) => const rows = entries.map((e) =>
[ [
e.date, e.date,
`"${(e.title || '').replace(/"/g, '""')}"`, csvSafe(e.title),
e.amount.toFixed(2).replace('.', ','), e.amount.toFixed(2).replace('.', ','),
e.category, e.category,
e.is_recurring ? 'Ja' : 'Nein', e.is_recurring ? 'Ja' : 'Nein',
`"${(e.creator_name || '').replace(/"/g, '""')}"`, csvSafe(e.creator_name),
].join(',') ].join(',')
).join('\n'); ).join('\n');
+11 -4
View File
@@ -206,7 +206,7 @@ router.get('/upcoming', (req, res) => {
*/ */
router.get('/google/auth', requireAdmin, (req, res) => { router.get('/google/auth', requireAdmin, (req, res) => {
try { try {
const url = googleCalendar.getAuthUrl(); const url = googleCalendar.getAuthUrl(req.session);
if (!url) return res.status(503).json({ error: 'Google nicht konfiguriert.', code: 503 }); if (!url) return res.status(503).json({ error: 'Google nicht konfiguriert.', code: 503 });
res.redirect(url); res.redirect(url);
} catch (err) { } catch (err) {
@@ -222,10 +222,17 @@ router.get('/google/auth', requireAdmin, (req, res) => {
*/ */
router.get('/google/callback', async (req, res) => { router.get('/google/callback', async (req, res) => {
try { try {
const { code, error } = req.query; const { code, error, state } = req.query;
if (error) return res.redirect('/settings?sync_error=google'); if (error) return res.redirect('/settings?sync_error=google');
if (!code) return res.status(400).json({ error: 'Kein Code erhalten.', code: 400 }); 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); await googleCalendar.handleCallback(code);
// Initialen Sync im Hintergrund starten (kein await - Redirect soll sofort erfolgen) // 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. * Manueller Sync-Trigger.
* Response: { ok: true, lastSync: string } * Response: { ok: true, lastSync: string }
*/ */
router.post('/google/sync', async (req, res) => { router.post('/google/sync', requireAdmin, async (req, res) => {
try { try {
await googleCalendar.sync(); await googleCalendar.sync();
const { lastSync } = googleCalendar.getStatus(); const { lastSync } = googleCalendar.getStatus();
@@ -304,7 +311,7 @@ router.get('/apple/status', (req, res) => {
* Manueller Sync-Trigger. * Manueller Sync-Trigger.
* Response: { ok: true, lastSync: string } * Response: { ok: true, lastSync: string }
*/ */
router.post('/apple/sync', async (req, res) => { router.post('/apple/sync', requireAdmin, async (req, res) => {
try { try {
await appleCalendar.sync(); await appleCalendar.sync();
const { lastSync } = appleCalendar.getStatus(); const { lastSync } = appleCalendar.getStatus();
+4
View File
@@ -53,6 +53,10 @@ function getCredentials() {
} }
function saveCredentials(url, username, password) { 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_caldav_url', url);
cfgSet('apple_username', username); cfgSet('apple_username', username);
cfgSet('apple_app_password', password); cfgSet('apple_app_password', password);
+11 -1
View File
@@ -14,6 +14,7 @@
'use strict'; 'use strict';
const { google } = require('googleapis'); const { google } = require('googleapis');
const crypto = require('crypto');
const db = require('../db'); const db = require('../db');
const GOOGLE_COLOR = '#4285F4'; const GOOGLE_COLOR = '#4285F4';
@@ -92,12 +93,21 @@ function loadAuthorizedClient() {
* Generiert die Google OAuth2-URL zum Weiterleiten des Admins. * Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
* @returns {string} Auth-URL * @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 client = createClient();
const state = crypto.randomBytes(32).toString('hex');
if (session) session.googleOAuthState = state;
return client.generateAuthUrl({ return client.generateAuthUrl({
access_type: 'offline', access_type: 'offline',
prompt: 'consent', prompt: 'consent',
scope: ['https://www.googleapis.com/auth/calendar'], scope: ['https://www.googleapis.com/auth/calendar'],
state,
}); });
} }