diff --git a/.dockerignore b/.dockerignore index 15a9921..a2817b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,11 +9,8 @@ node_modules .gitignore .dockerignore -# Documentation & screenshots (not needed at runtime) -docs/screenshots/ -docs/superpowers/ -docs/social-preview.png -docs/logo.svg +# Documentation (not needed at runtime) +docs/ # Tests test-*.js diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f182f..8e2c1ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.3] - 2026-04-03 + ### Security +- Fix SQLCipher PRAGMA key interpolation — encryption keys containing single quotes no longer crash on startup; key is now hex-encoded +- Enforce minimum password length (8 characters) when admin creates new users — previously any 1-character password was accepted +- Add length bounds on username (64 chars) and display_name (128 chars) to prevent unbounded input +- Add input length bounds on login (username 64 chars, password 1024 chars) +- Invalidate all other sessions when a user changes their password — previously active sessions survived password reset - Session and CSRF cookies now have `secure: true` by default; HTTP is only allowed when `SESSION_SECURE=false` is explicitly set in `.env` — previously cookies were sent without `Secure` flag in non-production environments +- Document authorization model in SECURITY.md — clarify that all family members share read/write access to all data by design + +### Changed +- Use multi-stage Docker build to exclude build tools (python3, make, g++) from runtime image +- Exclude `docs/` directory from Docker image via `.dockerignore` +- Consolidate `dotenv.config()` to single call in `server/index.js` — remove duplicate calls from `server/db.js` and `server/auth.js` ## [0.5.2] - 2026-04-01 diff --git a/Dockerfile b/Dockerfile index c0f870b..afd82d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-slim +FROM node:22-slim AS build # SQLCipher-Abhängigkeiten RUN apt-get update && apt-get install -y \ @@ -6,7 +6,6 @@ RUN apt-get update && apt-get install -y \ make \ g++ \ libsqlcipher-dev \ - gosu \ && rm -rf /var/lib/apt/lists/* WORKDIR /app @@ -15,7 +14,20 @@ WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev -# Anwendungscode +# ---- Runtime stage ---- +FROM node:22-slim + +RUN apt-get update && apt-get install -y \ + libsqlcipher0 \ + gosu \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Node modules aus Build-Stage kopieren +COPY --from=build /app/node_modules ./node_modules + +# Anwendungscode (docs/ wird via .dockerignore ausgeschlossen) COPY . . # Daten-Volume-Verzeichnis anlegen (Permissions werden zur Laufzeit gesetzt) diff --git a/SECURITY.md b/SECURITY.md index 8239fdf..d599414 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -36,6 +36,15 @@ Vulnerabilities that require physical access to the host or root on the server a - Optional SQLCipher AES-256 database encryption - No API endpoint accessible without session auth (except login) +## Authorization Model + +Oikos uses a flat family authorization model: + +- **Admin** can create, edit, and delete all user accounts and all shared data. +- **Member** can read and write all shared data (tasks, shopping lists, meals, calendar events, notes, contacts, budget entries) but cannot manage user accounts. + +There is no per-user data isolation — all family members see and can edit all data. This is intentional: Oikos is a shared family planner, not a multi-tenant application. + ## Supported Versions Only the latest version on `main` receives security updates. There are no LTS branches. diff --git a/server/auth.js b/server/auth.js index 85a2434..68fe22c 100644 --- a/server/auth.js +++ b/server/auth.js @@ -6,7 +6,6 @@ 'use strict'; -require('dotenv').config(); const express = require('express'); const bcrypt = require('bcrypt'); const session = require('express-session'); @@ -164,6 +163,10 @@ router.post('/login', loginLimiter, async (req, res) => { return res.status(400).json({ error: 'Benutzername und Passwort erforderlich.', code: 400 }); } + if (username.length > 64 || password.length > 1024) { + return res.status(400).json({ error: 'Eingabe zu lang.', code: 400 }); + } + const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username); if (!user) { @@ -279,6 +282,18 @@ router.post('/users', requireAuth, requireAdmin, csrfMiddleware, async (req, res return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 }); } + if (password.length < 8) { + 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 (display_name.length > 128) { + return res.status(400).json({ error: 'Anzeigename darf maximal 128 Zeichen lang sein.', code: 400 }); + } + if (!['admin', 'member'].includes(role)) { return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 }); } @@ -330,6 +345,19 @@ router.patch('/me/password', requireAuth, csrfMiddleware, async (req, res) => { const hash = await bcrypt.hash(new_password, 12); db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId); + // Alle anderen Sessions dieses Users invalidieren (aktuelle behalten) + const currentSid = req.sessionID; + const allSessions = db.get().prepare('SELECT sid, sess FROM sessions').all(); + for (const row of allSessions) { + if (row.sid === currentSid) continue; + try { + const sess = JSON.parse(row.sess); + if (sess.userId === req.session.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] Passwort-Ändern-Fehler:', err); diff --git a/server/db.js b/server/db.js index 4be645f..c146d95 100644 --- a/server/db.js +++ b/server/db.js @@ -1,7 +1,7 @@ /** * Modul: Datenbank (Database) * Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer - * Abhängigkeiten: better-sqlite3, dotenv + * Abhängigkeiten: better-sqlite3 * * SQLCipher-Hinweis: * Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde. @@ -11,7 +11,6 @@ 'use strict'; -require('dotenv').config(); const Database = require('better-sqlite3'); const path = require('path'); @@ -34,7 +33,7 @@ function init() { if (DB_KEY) { // Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker) - db.pragma(`key='${DB_KEY}'`); + db.pragma(`key=x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'`); // Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist try { db.prepare('SELECT count(*) FROM sqlite_master').get();