fix: address CodeQL security findings (v0.5.2)

- Rate-limit SPA fallback route (missing rate limiting on fs access)
- Add csrfMiddleware to all state-changing auth routes (logout, create
  user, change password, delete user) — previously bypassed global CSRF
  middleware due to router registration order
- Fix incomplete vCard escaping: escape backslashes before other special
  characters to prevent injection via contact fields
- Restrict CI GITHUB_TOKEN to contents: read (least privilege)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-04-01 18:30:03 +02:00
parent b9b81a461e
commit 91c2e0ad98
6 changed files with 30 additions and 8 deletions
+3
View File
@@ -6,6 +6,9 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
permissions:
contents: read
jobs: jobs:
test: test:
name: Tests (Node.js ${{ matrix.node-version }}) name: Tests (Node.js ${{ matrix.node-version }})
+8
View File
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [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 ## [0.5.1] - 2026-04-01
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "oikos", "name": "oikos",
"version": "0.5.1", "version": "0.5.2",
"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": {
+5 -5
View File
@@ -13,7 +13,7 @@ const session = require('express-session');
const rateLimit = require('express-rate-limit'); const rateLimit = require('express-rate-limit');
const db = require('./db'); const db = require('./db');
const { generateToken } = require('./middleware/csrf'); const { generateToken, csrfMiddleware } = require('./middleware/csrf');
const router = express.Router(); const router = express.Router();
// -------------------------------------------------------- // --------------------------------------------------------
@@ -218,7 +218,7 @@ router.post('/login', loginLimiter, async (req, res) => {
* POST /api/v1/auth/logout * POST /api/v1/auth/logout
* Response: { ok: true } * Response: { ok: true }
*/ */
router.post('/logout', requireAuth, (req, res) => { router.post('/logout', requireAuth, csrfMiddleware, (req, res) => {
req.session.destroy((err) => { req.session.destroy((err) => {
if (err) { if (err) {
console.error('[Auth] Logout-Fehler:', 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? } * Body: { username, display_name, password, avatar_color?, role? }
* Response: { user: { id, username, display_name, 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 { try {
const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body; 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 } * Body: { current_password: string, new_password: string }
* Response: { ok: true } * Response: { ok: true }
*/ */
router.patch('/me/password', requireAuth, async (req, res) => { router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => {
try { try {
const { current_password, new_password } = req.body; 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. * Admin only. Löscht ein Familienmitglied.
* Response: { ok: true } * Response: { ok: true }
*/ */
router.delete('/users/:id', requireAuth, requireAdmin, (req, res) => { router.delete('/users/:id', requireAuth, requireAdmin, csrfMiddleware, (req, res) => {
try { try {
const userId = parseInt(req.params.id, 10); const userId = parseInt(req.params.id, 10);
+12 -1
View File
@@ -163,10 +163,21 @@ app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); 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 // SPA Fallback: Alle nicht-API-Routen → index.html
// -------------------------------------------------------- // --------------------------------------------------------
app.get('*', (req, res) => { app.get('*', spaLimiter, (req, res) => {
if (req.path.startsWith('/api/')) { if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); return res.status(404).json({ error: 'Nicht gefunden.', code: 404 });
} }
+1 -1
View File
@@ -171,7 +171,7 @@ router.get('/:id/vcard', (req, res) => {
const contact = db.get().prepare('SELECT * FROM contacts WHERE id = ?').get(id); 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 }); 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 = [ const lines = [
'BEGIN:VCARD', 'BEGIN:VCARD',