diff --git a/index.js b/index.js index 6656ce2..e2cbbab 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,34 @@ const express = require('express'); const cookieParser = require('cookie-parser'); +const helmet = require('helmet'); const path = require('path'); const routes = require('./src/routes'); const { router: filesRouter } = require('./src/files'); 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 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(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); diff --git a/package-lock.json b/package-lock.json index a9ecd6b..4165d0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "cookie-parser": "^1.4.7", "express": "^5.2.1", "express-rate-limit": "^8.3.2", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "otplib": "^13.4.0", @@ -833,6 +834,15 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", diff --git a/package.json b/package.json index 91f7d31..8031ec1 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "cookie-parser": "^1.4.7", "express": "^5.2.1", "express-rate-limit": "^8.3.2", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "otplib": "^13.4.0", diff --git a/src/auth.js b/src/auth.js index d65ce7a..18653ea 100644 --- a/src/auth.js +++ b/src/auth.js @@ -1,5 +1,14 @@ 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) { 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) { const token = req.cookies?.token; if (!token) return res.status(401).json({ error: 'Nicht eingeloggt' }); - const user = verifyToken(token); - if (!user) return res.status(401).json({ error: 'Ungültige Sitzung' }); + const payload = verifyToken(token); + 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; next(); } -module.exports = { signToken, verifyToken, requireAuth }; +module.exports = { signToken, verifyToken, requireAuth, COOKIE_OPTIONS }; diff --git a/src/files.js b/src/files.js index 6f4f1a5..9ce0815 100644 --- a/src/files.js +++ b/src/files.js @@ -75,7 +75,6 @@ const ALLOWED_MIME = new Set([ 'text/csv', 'application/json', 'text/json', 'application/xml', 'text/xml', - 'application/octet-stream', ]); const storage = multer.diskStorage({ diff --git a/src/routes.js b/src/routes.js index ab0fe93..bd27eec 100644 --- a/src/routes.js +++ b/src/routes.js @@ -4,7 +4,7 @@ const rateLimit = require('express-rate-limit'); const { generateSecret, generateURI, verifySync } = require('otplib'); const QRCode = require('qrcode'); const db = require('./db'); -const { signToken, requireAuth } = require('./auth'); +const { signToken, requireAuth, COOKIE_OPTIONS } = require('./auth'); const { deleteUserFiles } = require('./files'); const router = express.Router(); @@ -17,15 +17,33 @@ const loginLimiter = rateLimit({ 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 --- 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']; -router.post('/register', (req, res) => { +router.post('/register', registerLimiter, (req, res) => { const { email, password, role, subject } = req.body; 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 (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 safeRole = (role === 'teacher') ? 'teacher' : 'student'; 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 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 }); } catch (e) { 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' }); } 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 }); }); @@ -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 }); }); -router.put('/me/password', requireAuth, (req, res) => { +router.put('/me/password', requireAuth, passwordChangeLimiter, (req, res) => { const { currentPassword, newPassword } = req.body; 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); if (!bcrypt.compareSync(currentPassword, user.password_hash)) { 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) => { + 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); res.json({ ok: true }); }); @@ -514,12 +537,19 @@ router.get('/2fa/status', requireAuth, (req, res) => { }); // --- 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) { router.get(`/${path}`, requireAuth, (req, res) => { res.json(db.prepare(`SELECT * FROM ${table} WHERE user_id = ?`).all(req.user.id)); }); 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 placeholders = ['?', ...fields.map(() => '?')].join(', '); 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) => { 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); res.json({ ok: true }); }); diff --git a/src/teacher.js b/src/teacher.js index 78c6fdc..5fd4267 100644 --- a/src/teacher.js +++ b/src/teacher.js @@ -43,7 +43,7 @@ const ALLOWED_MIME = new Set([ 'image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff', 'application/zip','application/x-zip-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 = { pdf:'application/pdf', doc:'application/msword',