harden security: enforce JWT_SECRET, helmet, CSP, stricter rate limits

- Require JWT_SECRET env var (fatal exit if missing)
- Add helmet middleware with custom CSP
- Cookie Secure flag when NODE_ENV=production
- requireAuth re-verifies user.status from DB on every request
- class_events DELETE restricted to creator or admin
- Rate limit /register (5/hr) and PUT /me/password (5/15min)
- Password minimum 6 to 8 chars
- crudRoutes truncates strings to 1000 chars
- Remove application/octet-stream from allowed upload MIMEs
This commit is contained in:
Simon
2026-04-17 23:40:27 +02:00
parent 8f75bc6a10
commit b2de630983
7 changed files with 87 additions and 15 deletions
+21
View File
@@ -1,13 +1,34 @@
const express = require('express'); const express = require('express');
const cookieParser = require('cookie-parser'); const cookieParser = require('cookie-parser');
const helmet = require('helmet');
const path = require('path'); const path = require('path');
const routes = require('./src/routes'); const routes = require('./src/routes');
const { router: filesRouter } = require('./src/files'); const { router: filesRouter } = require('./src/files');
const teacherRouter = require('./src/teacher'); const teacherRouter = require('./src/teacher');
if (!process.env.JWT_SECRET) {
console.error('FATAL: JWT_SECRET environment variable is not set.');
process.exit(1);
}
const app = express(); const app = express();
const PORT = 3010; const PORT = 3010;
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", 'unpkg.com'],
scriptSrcAttr: ["'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com'],
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'", 'https://api.open-meteo.com'],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
},
},
}));
app.use(express.json()); app.use(express.json());
app.use(cookieParser()); app.use(cookieParser());
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
+10
View File
@@ -14,6 +14,7 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
@@ -833,6 +834,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+1
View File
@@ -15,6 +15,7 @@
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2", "express-rate-limit": "^8.3.2",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"multer": "^2.1.1", "multer": "^2.1.1",
"otplib": "^13.4.0", "otplib": "^13.4.0",
+15 -4
View File
@@ -1,5 +1,14 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET || 'info1-ifb-secret-change-in-prod'; const db = require('./db');
const SECRET = process.env.JWT_SECRET;
const COOKIE_OPTIONS = {
httpOnly: true,
maxAge: 30 * 24 * 60 * 60 * 1000,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
};
function signToken(user) { function signToken(user) {
return jwt.sign({ id: user.id, username: user.username, role: user.role, subject: user.subject || null }, SECRET, { expiresIn: '30d' }); return jwt.sign({ id: user.id, username: user.username, role: user.role, subject: user.subject || null }, SECRET, { expiresIn: '30d' });
@@ -16,10 +25,12 @@ function verifyToken(token) {
function requireAuth(req, res, next) { function requireAuth(req, res, next) {
const token = req.cookies?.token; const token = req.cookies?.token;
if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' }); if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' });
const user = verifyToken(token); const payload = verifyToken(token);
if (!user) return res.status(401).json({ error: 'Ungültige Sitzung' }); if (!payload) return res.status(401).json({ error: 'Ungültige Sitzung' });
const user = db.prepare('SELECT id, username, role, status, subject FROM users WHERE id = ?').get(payload.id);
if (!user || user.status !== 'active') return res.status(403).json({ error: 'Konto nicht aktiv' });
req.user = user; req.user = user;
next(); next();
} }
module.exports = { signToken, verifyToken, requireAuth }; module.exports = { signToken, verifyToken, requireAuth, COOKIE_OPTIONS };
-1
View File
@@ -75,7 +75,6 @@ const ALLOWED_MIME = new Set([
'text/csv', 'text/csv',
'application/json', 'text/json', 'application/json', 'text/json',
'application/xml', 'text/xml', 'application/xml', 'text/xml',
'application/octet-stream',
]); ]);
const storage = multer.diskStorage({ const storage = multer.diskStorage({
+39 -9
View File
@@ -4,7 +4,7 @@ const rateLimit = require('express-rate-limit');
const { generateSecret, generateURI, verifySync } = require('otplib'); const { generateSecret, generateURI, verifySync } = require('otplib');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const db = require('./db'); const db = require('./db');
const { signToken, requireAuth } = require('./auth'); const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth');
const { deleteUserFiles } = require('./files'); const { deleteUserFiles } = require('./files');
const router = express.Router(); const router = express.Router();
@@ -17,15 +17,33 @@ const loginLimiter = rateLimit({
legacyHeaders: false, legacyHeaders: false,
}); });
const registerLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
max: 5,
message: { error: 'Zu viele Registrierungsversuche. Bitte 1 Stunde warten.' },
standardHeaders: true,
legacyHeaders: false,
});
const passwordChangeLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
keyGenerator: (req) => String(req.user?.id ?? req.ip),
message: { error: 'Zu viele Passwortänderungen. Bitte 15 Minuten warten.' },
standardHeaders: true,
legacyHeaders: false,
validate: { keyGeneratorIpFallback: false },
});
// --- AUTH --- // --- AUTH ---
const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i; const IFB_EMAIL_RE = /^[a-z]\.[a-z]{2,}@ifb-schulen\.com$/i;
const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges']; const VALID_SUBJECTS = ['Deutsch','Mathematik','Englisch','Informatik','Wirtschaft','Buchführung','BWL','VWL','Recht','Rechnungswesen','Sport','Religion','Geschichte','Gemeinschaftskunde','Physik','Chemie','Biologie','Sozialkunde','Ethik','Sonstiges'];
router.post('/register', (req, res) => { router.post('/register', registerLimiter, (req, res) => {
const { email, password, role, subject } = req.body; const { email, password, role, subject } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' }); if (!email || !password) return res.status(400).json({ error: 'Alle Felder erforderlich' });
if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' }); if (!IFB_EMAIL_RE.test(email)) return res.status(403).json({ error: 'Ungültige E-Mail-Adresse' });
if (password.length < 6) return res.status(400).json({ error: 'Passwort zu kurz (min. 6 Zeichen)' }); if (password.length < 8) return res.status(400).json({ error: 'Passwort zu kurz (min. 8 Zeichen)' });
const username = email.split('@')[0].toLowerCase(); const username = email.split('@')[0].toLowerCase();
const safeRole = (role === 'teacher') ? 'teacher' : 'student'; const safeRole = (role === 'teacher') ? 'teacher' : 'student';
const initialStatus = safeRole === 'teacher' ? 'pending' : 'active'; const initialStatus = safeRole === 'teacher' ? 'pending' : 'active';
@@ -46,7 +64,7 @@ router.post('/register', (req, res) => {
} }
const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null }; const user = { id: result.lastInsertRowid, username, role: safeRole, subject: null };
const token = signToken(user); const token = signToken(user);
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' }); res.cookie('token', token, COOKIE_OPTIONS);
res.json({ ok: true, pending: false }); res.json({ ok: true, pending: false });
} catch (e) { } catch (e) {
if (e.message.includes('UNIQUE')) { if (e.message.includes('UNIQUE')) {
@@ -81,7 +99,7 @@ router.post('/login', loginLimiter, (req, res) => {
if (!totpResult || !totpResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' }); if (!totpResult || !totpResult.valid) return res.status(401).json({ error: 'Ungültiger 2FA-Code' });
} }
const token = signToken(user); const token = signToken(user);
res.cookie('token', token, { httpOnly: true, maxAge: 30 * 24 * 60 * 60 * 1000, sameSite: 'lax' }); res.cookie('token', token, COOKIE_OPTIONS);
res.json({ ok: true, username: user.username, role: user.role, subject: user.subject }); res.json({ ok: true, username: user.username, role: user.role, subject: user.subject });
}); });
@@ -96,10 +114,10 @@ router.get('/me', requireAuth, (req, res) => {
res.json({ username: user.username, id: user.id, role: user.role, email: user.email, subject: user.subject }); res.json({ username: user.username, id: user.id, role: user.role, email: user.email, subject: user.subject });
}); });
router.put('/me/password', requireAuth, (req, res) => { router.put('/me/password', requireAuth, passwordChangeLimiter, (req, res) => {
const { currentPassword, newPassword } = req.body; const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Felder erforderlich' }); if (!currentPassword || !newPassword) return res.status(400).json({ error: 'Felder erforderlich' });
if (newPassword.length < 6) return res.status(400).json({ error: 'Neues Passwort zu kurz (min. 6 Zeichen)' }); if (newPassword.length < 8) return res.status(400).json({ error: 'Neues Passwort zu kurz (min. 8 Zeichen)' });
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id); const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
if (!bcrypt.compareSync(currentPassword, user.password_hash)) { if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
return res.status(401).json({ error: 'Aktuelles Passwort falsch' }); return res.status(401).json({ error: 'Aktuelles Passwort falsch' });
@@ -313,6 +331,11 @@ router.post('/class-events', requireAuth, (req, res) => {
}); });
router.delete('/class-events/:id', requireAuth, (req, res) => { router.delete('/class-events/:id', requireAuth, (req, res) => {
const event = db.prepare('SELECT id, created_by FROM class_events WHERE id = ?').get(req.params.id);
if (!event) return res.status(404).json({ error: 'Ereignis nicht gefunden' });
if (req.user.role !== 'admin' && event.created_by !== req.user.id) {
return res.status(403).json({ error: 'Keine Berechtigung' });
}
db.prepare('DELETE FROM class_events WHERE id = ?').run(req.params.id); db.prepare('DELETE FROM class_events WHERE id = ?').run(req.params.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
@@ -514,12 +537,19 @@ router.get('/2fa/status', requireAuth, (req, res) => {
}); });
// --- PERSONAL CRUD --- // --- PERSONAL CRUD ---
const CRUD_TEXT_MAX = 1000;
function sanitizeCrudVal(val) {
if (typeof val === 'string' && val.length > CRUD_TEXT_MAX) return val.slice(0, CRUD_TEXT_MAX);
return val ?? null;
}
function crudRoutes(path, table, fields) { function crudRoutes(path, table, fields) {
router.get(`/${path}`, requireAuth, (req, res) => { router.get(`/${path}`, requireAuth, (req, res) => {
res.json(db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(req.user.id)); res.json(db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(req.user.id));
}); });
router.post(`/${path}`, requireAuth, (req, res) => { router.post(`/${path}`, requireAuth, (req, res) => {
const vals = fields.map(f => req.body[f] ?? null); const vals = fields.map(f => sanitizeCrudVal(req.body[f]));
const cols = ['user_id', ...fields].join(', '); const cols = ['user_id', ...fields].join(', ');
const placeholders = ['?', ...fields.map(() => '?')].join(', '); const placeholders = ['?', ...fields.map(() => '?')].join(', ');
const result = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`).run(req.user.id, ...vals); const result = db.prepare(`INSERT INTO ${table} (${cols}) VALUES (${placeholders})`).run(req.user.id, ...vals);
@@ -527,7 +557,7 @@ function crudRoutes(path, table, fields) {
}); });
router.put(`/${path}/:id`, requireAuth, (req, res) => { router.put(`/${path}/:id`, requireAuth, (req, res) => {
const sets = fields.map(f => `${f} = ?`).join(', '); const sets = fields.map(f => `${f} = ?`).join(', ');
const vals = fields.map(f => req.body[f] ?? null); const vals = fields.map(f => sanitizeCrudVal(req.body[f]));
db.prepare(`UPDATE ${table} SET ${sets} WHERE id = ? AND user_id = ?`).run(...vals, req.params.id, req.user.id); db.prepare(`UPDATE ${table} SET ${sets} WHERE id = ? AND user_id = ?`).run(...vals, req.params.id, req.user.id);
res.json({ ok: true }); res.json({ ok: true });
}); });
+1 -1
View File
@@ -43,7 +43,7 @@ const ALLOWED_MIME = new Set([
'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff', 'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff',
'application/zip','application/x-zip-compressed', 'application/zip','application/x-zip-compressed',
'application/vnd.rar','application/x-rar-compressed', 'application/vnd.rar','application/x-rar-compressed',
'application/x-7z-compressed','text/csv','application/octet-stream', 'application/x-7z-compressed','text/csv',
]); ]);
const EXT_MIME = { const EXT_MIME = {
pdf:'application/pdf', doc:'application/msword', pdf:'application/pdf', doc:'application/msword',