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:
@@ -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')));
|
||||
|
||||
Generated
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+15
-4
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
+39
-9
@@ -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 });
|
||||
});
|
||||
|
||||
+1
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user