diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca9f1c9..64bf093 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: name: Tests (Node.js ${{ matrix.node-version }}) diff --git a/CHANGELOG.md b/CHANGELOG.md index da3a1ed..ebdfcb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.2] - 2026-04-01 + +### Security +- Add rate limiting to SPA fallback route to prevent file system hammering via unauthenticated wildcard requests +- Add CSRF protection to auth routes that change state (logout, create user, change password, delete user) — previously bypassed global CSRF middleware due to router registration order +- Fix incomplete vCard escaping in contacts export — backslash characters are now escaped first before other special characters (`,`, `;`, newline), preventing injection via contact fields +- Restrict CI workflow GITHUB_TOKEN to `contents: read` (principle of least privilege) + ## [0.5.1] - 2026-04-01 ### Fixed diff --git a/package.json b/package.json index bc19208..f551378 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.5.1", + "version": "0.5.2", "description": "Selbstgehosteter Familienplaner — Kalender, Aufgaben, Einkauf, Essensplan, Budget und mehr. Privat, offen, ohne Abo.", "main": "server/index.js", "engines": { diff --git a/server/auth.js b/server/auth.js index 21b1090..64e5167 100644 --- a/server/auth.js +++ b/server/auth.js @@ -13,7 +13,7 @@ const session = require('express-session'); const rateLimit = require('express-rate-limit'); const db = require('./db'); -const { generateToken } = require('./middleware/csrf'); +const { generateToken, csrfMiddleware } = require('./middleware/csrf'); const router = express.Router(); // -------------------------------------------------------- @@ -218,7 +218,7 @@ router.post('/login', loginLimiter, async (req, res) => { * POST /api/v1/auth/logout * Response: { ok: true } */ -router.post('/logout', requireAuth, (req, res) => { +router.post('/logout', requireAuth, csrfMiddleware, (req, res) => { req.session.destroy((err) => { if (err) { console.error('[Auth] Logout-Fehler:', err); @@ -274,7 +274,7 @@ router.get('/users', requireAuth, requireAdmin, (req, res) => { * Body: { username, display_name, password, avatar_color?, role? } * Response: { user: { id, username, display_name, avatar_color, role } } */ -router.post('/users', requireAuth, requireAdmin, async (req, res) => { +router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res) => { try { const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body; @@ -313,7 +313,7 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => { * Body: { current_password: string, new_password: string } * Response: { ok: true } */ -router.patch('/me/password', requireAuth, async (req, res) => { +router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { try { const { current_password, new_password } = req.body; @@ -345,7 +345,7 @@ router.patch('/me/password', requireAuth, async (req, res) => { * Admin only. Löscht ein Familienmitglied. * Response: { ok: true } */ -router.delete('/users/:id', requireAuth, requireAdmin, (req, res) => { +router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => { try { const userId = parseInt(req.params.id, 10); diff --git a/server/index.js b/server/index.js index 86ca38f..72e96cb 100644 --- a/server/index.js +++ b/server/index.js @@ -163,10 +163,21 @@ 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('*', (req, res) => { +app.get('*', spaLimiter, (req, res) => { if (req.path.startsWith('/api/')) { return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); } diff --git a/server/routes/contacts.js b/server/routes/contacts.js index a18f294..ab330a8 100644 --- a/server/routes/contacts.js +++ b/server/routes/contacts.js @@ -171,7 +171,7 @@ router.get('/:id/vcard', (req, res) => { const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); if (!contact) return res.status(404).json({ error: 'Kontakt nicht gefunden', code: 404 }); - const esc = (v) => String(v || '').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); + const esc = (v) => String(v || '').replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;'); const lines = [ 'BEGIN:VCARD',