fix(security): address multiple security findings from audit

- Fix SQLCipher PRAGMA key interpolation (hex-encode key to prevent crash on single quotes)
- Enforce min password length (8 chars) on admin user creation
- Add length bounds on username/display_name and login inputs
- Invalidate other sessions on password change
- Multi-stage Docker build (exclude build tools from runtime)
- Exclude docs/ from Docker image
- Consolidate dotenv.config() to single entry point
- Document flat family authorization model in SECURITY.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-04-03 09:11:17 +02:00
parent 7a520a24de
commit 6e0eda8ba4
6 changed files with 70 additions and 12 deletions
+2 -5
View File
@@ -9,11 +9,8 @@ node_modules
.gitignore .gitignore
.dockerignore .dockerignore
# Documentation & screenshots (not needed at runtime) # Documentation (not needed at runtime)
docs/screenshots/ docs/
docs/superpowers/
docs/social-preview.png
docs/logo.svg
# Tests # Tests
test-*.js test-*.js
+13
View File
@@ -7,8 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.5.3] - 2026-04-03
### Security ### 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 - 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 ## [0.5.2] - 2026-04-01
+15 -3
View File
@@ -1,4 +1,4 @@
FROM node:22-slim FROM node:22-slim AS build
# SQLCipher-Abhängigkeiten # SQLCipher-Abhängigkeiten
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
@@ -6,7 +6,6 @@ RUN apt-get update && apt-get install -y \
make \ make \
g++ \ g++ \
libsqlcipher-dev \ libsqlcipher-dev \
gosu \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
@@ -15,7 +14,20 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev 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 . . COPY . .
# Daten-Volume-Verzeichnis anlegen (Permissions werden zur Laufzeit gesetzt) # Daten-Volume-Verzeichnis anlegen (Permissions werden zur Laufzeit gesetzt)
+9
View File
@@ -36,6 +36,15 @@ Vulnerabilities that require physical access to the host or root on the server a
- Optional SQLCipher AES-256 database encryption - Optional SQLCipher AES-256 database encryption
- No API endpoint accessible without session auth (except login) - 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 ## Supported Versions
Only the latest version on `main` receives security updates. There are no LTS branches. Only the latest version on `main` receives security updates. There are no LTS branches.
+29 -1
View File
@@ -6,7 +6,6 @@
'use strict'; 'use strict';
require('dotenv').config();
const express = require('express'); const express = require('express');
const bcrypt = require('bcrypt'); const bcrypt = require('bcrypt');
const session = require('express-session'); 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 }); 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); const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) { 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 }); 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)) { if (!['admin', 'member'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 }); 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); const hash = await bcrypt.hash(new_password, 12);
db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId); 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 }); res.json({ ok: true });
} catch (err) { } catch (err) {
console.error('[Auth] Passwort-Ändern-Fehler:', err); console.error('[Auth] Passwort-Ändern-Fehler:', err);
+2 -3
View File
@@ -1,7 +1,7 @@
/** /**
* Modul: Datenbank (Database) * Modul: Datenbank (Database)
* Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer * Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer
* Abhängigkeiten: better-sqlite3, dotenv * Abhängigkeiten: better-sqlite3
* *
* SQLCipher-Hinweis: * SQLCipher-Hinweis:
* Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde. * Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde.
@@ -11,7 +11,6 @@
'use strict'; 'use strict';
require('dotenv').config();
const Database = require('better-sqlite3'); const Database = require('better-sqlite3');
const path = require('path'); const path = require('path');
@@ -34,7 +33,7 @@ function init() {
if (DB_KEY) { if (DB_KEY) {
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker) // 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 // Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
try { try {
db.prepare('SELECT count(*) FROM sqlite_master').get(); db.prepare('SELECT count(*) FROM sqlite_master').get();